summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-07 23:25:10 +0300
committerPaul Buetow <paul@buetow.org>2025-07-07 23:25:10 +0300
commit4526c8a171dbe40762c116e5b8a404f20131d2b1 (patch)
treeea3d544cbad995dabb616f4b6136e6e24a097524
parent64095a2c8d5a3a72c55d7bd0737c5542a5aeee09 (diff)
feat: add comprehensive showcase generation with metadata and images
- Add --showcase flag to generate project showcases using Claude - Extract repository metadata (languages, commits, LOC, dates, license) - Support image extraction from README files (local and remote) - Add caching with --force flag to regenerate - Add exclude_from_showcase config option - Add standalone showcase mode (--showcase without sync) - Sort projects by recent activity (avg age of last 100 commits) - Output in Gemini Gemtext template format (.gmi.tpl) - Fix backup location fetching when --backup flag not set 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--cmd/gitsyncer/main.go43
-rw-r--r--internal/cli/flags.go4
-rw-r--r--internal/cli/showcase_handler.go35
-rw-r--r--internal/cli/showcase_only_handler.go112
-rw-r--r--internal/config/config.go9
-rw-r--r--internal/showcase/images.go258
-rw-r--r--internal/showcase/metadata.go365
-rw-r--r--internal/showcase/showcase.go560
-rw-r--r--internal/sync/sync.go50
9 files changed, 1428 insertions, 8 deletions
diff --git a/cmd/gitsyncer/main.go b/cmd/gitsyncer/main.go
index ddf00e3..9828776 100644
--- a/cmd/gitsyncer/main.go
+++ b/cmd/gitsyncer/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "fmt"
"os"
"path/filepath"
@@ -59,25 +60,61 @@ func main() {
// Handle sync operation
if flags.SyncRepo != "" {
- os.Exit(cli.HandleSync(cfg, flags))
+ exitCode := cli.HandleSync(cfg, flags)
+ if exitCode == 0 && flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
+ }
+ os.Exit(exitCode)
}
// Handle sync all operation
if flags.SyncAll {
- os.Exit(cli.HandleSyncAll(cfg, flags))
+ exitCode := cli.HandleSyncAll(cfg, flags)
+ if exitCode == 0 && flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
+ }
+ os.Exit(exitCode)
}
// Handle sync Codeberg public repos
if flags.SyncCodebergPublic {
exitCode := cli.HandleSyncCodebergPublic(cfg, flags)
if exitCode != 0 || !flags.SyncGitHubPublic {
+ if exitCode == 0 && flags.Showcase && !flags.SyncGitHubPublic {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
+ }
os.Exit(exitCode)
}
}
// Handle sync GitHub public repos
if flags.SyncGitHubPublic {
- os.Exit(cli.HandleSyncGitHubPublic(cfg, flags))
+ exitCode := cli.HandleSyncGitHubPublic(cfg, flags)
+
+ // Run showcase generation if requested and sync was successful
+ if exitCode == 0 && flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
+ }
+
+ os.Exit(exitCode)
+ }
+
+ // Handle standalone showcase mode (no sync operations specified)
+ if flags.Showcase {
+ fmt.Println("Running showcase generation for all repositories (clone-only mode)...")
+ os.Exit(cli.HandleShowcaseOnly(cfg, flags))
}
// Default: show usage
diff --git a/internal/cli/flags.go b/internal/cli/flags.go
index 322ad5a..7640398 100644
--- a/internal/cli/flags.go
+++ b/internal/cli/flags.go
@@ -25,6 +25,8 @@ type Flags struct {
Clean bool
DeleteRepo string
Backup bool
+ Showcase bool
+ Force bool
}
// ParseFlags parses command-line flags and returns the flags struct
@@ -50,6 +52,8 @@ func ParseFlags() *Flags {
flag.BoolVar(&f.Clean, "clean", false, "delete all repositories in work directory (with confirmation)")
flag.StringVar(&f.DeleteRepo, "delete-repo", "", "delete specified repository from all configured organizations (with confirmation)")
flag.BoolVar(&f.Backup, "backup", false, "enable syncing to backup locations")
+ flag.BoolVar(&f.Showcase, "showcase", false, "generate project showcase using Claude after syncing")
+ flag.BoolVar(&f.Force, "force", false, "force regeneration of cached data")
flag.Parse()
diff --git a/internal/cli/showcase_handler.go b/internal/cli/showcase_handler.go
new file mode 100644
index 0000000..a3dfd26
--- /dev/null
+++ b/internal/cli/showcase_handler.go
@@ -0,0 +1,35 @@
+package cli
+
+import (
+ "fmt"
+ "log"
+
+ "codeberg.org/snonux/gitsyncer/internal/config"
+ "codeberg.org/snonux/gitsyncer/internal/showcase"
+)
+
+// HandleShowcase handles the showcase generation after syncing
+func HandleShowcase(cfg *config.Config, flags *Flags) int {
+ // Determine which repositories to process
+ var repoFilter []string
+ if flags.SyncRepo != "" {
+ // Only process the specific repository that was synced
+ repoFilter = []string{flags.SyncRepo}
+ fmt.Printf("\nGenerating showcase for %s...\n", flags.SyncRepo)
+ } else {
+ // Process all repositories for --sync-all or public sync operations
+ fmt.Println("\nGenerating project showcase for all repositories...")
+ }
+
+ // Create showcase generator
+ generator := showcase.New(cfg, flags.WorkDir)
+
+ // Generate showcase with optional filter
+ if err := generator.GenerateShowcase(repoFilter, flags.Force); err != nil {
+ log.Printf("ERROR: Failed to generate showcase: %v\n", err)
+ return 1
+ }
+
+ fmt.Println("Showcase generated successfully!")
+ return 0
+} \ No newline at end of file
diff --git a/internal/cli/showcase_only_handler.go b/internal/cli/showcase_only_handler.go
new file mode 100644
index 0000000..ff2a82a
--- /dev/null
+++ b/internal/cli/showcase_only_handler.go
@@ -0,0 +1,112 @@
+package cli
+
+import (
+ "fmt"
+ "log"
+
+ "codeberg.org/snonux/gitsyncer/internal/codeberg"
+ "codeberg.org/snonux/gitsyncer/internal/config"
+ "codeberg.org/snonux/gitsyncer/internal/github"
+ "codeberg.org/snonux/gitsyncer/internal/showcase"
+ "codeberg.org/snonux/gitsyncer/internal/sync"
+)
+
+// HandleShowcaseOnly handles showcase generation without syncing
+// It will clone repositories if they don't exist locally, but won't sync changes
+func HandleShowcaseOnly(cfg *config.Config, flags *Flags) int {
+ // Get all repositories from all sources
+ allRepos, err := getAllRepositories(cfg)
+ if err != nil {
+ log.Printf("ERROR: Failed to get repositories: %v\n", err)
+ return 1
+ }
+
+ if len(allRepos) == 0 {
+ fmt.Println("No repositories found")
+ return 1
+ }
+
+ fmt.Printf("Found %d repositories total\n", len(allRepos))
+
+ // Create a minimal syncer just for cloning
+ syncer := sync.New(cfg, flags.WorkDir)
+ syncer.SetBackupEnabled(false) // Never use backup in showcase-only mode
+
+ // Ensure repositories are cloned (but not synced)
+ fmt.Println("\nEnsuring repositories are cloned locally...")
+ for _, repo := range allRepos {
+ if err := syncer.EnsureRepositoryCloned(repo); err != nil {
+ fmt.Printf("WARNING: Failed to clone %s: %v\n", repo, err)
+ // Continue with other repos
+ }
+ }
+
+ // Generate showcase for all repositories
+ fmt.Println("\nGenerating showcase for all repositories...")
+ generator := showcase.New(cfg, flags.WorkDir)
+
+ // Pass empty filter to process all repos
+ if err := generator.GenerateShowcase(nil, flags.Force); err != nil {
+ log.Printf("ERROR: Failed to generate showcase: %v\n", err)
+ return 1
+ }
+
+ fmt.Println("Showcase generation completed!")
+ return 0
+}
+
+// getAllRepositories collects all unique repository names from all sources
+func getAllRepositories(cfg *config.Config) ([]string, error) {
+ repoMap := make(map[string]bool)
+
+ // Add configured repositories
+ for _, repo := range cfg.Repositories {
+ repoMap[repo] = true
+ }
+
+ // Add Codeberg public repos if configured
+ if codebergOrg := cfg.FindCodebergOrg(); codebergOrg != nil {
+ fmt.Printf("Fetching public repositories from Codeberg user/org: %s...\n", codebergOrg.Name)
+ client := codeberg.NewClient(codebergOrg.Name, codebergOrg.CodebergToken)
+
+ repos, err := client.ListPublicRepos()
+ if err != nil {
+ // Try as user
+ repos, err = client.ListUserPublicRepos()
+ if err != nil {
+ fmt.Printf("Warning: Failed to fetch Codeberg repos: %v\n", err)
+ }
+ }
+
+ for _, repo := range repos {
+ repoMap[repo.Name] = true
+ }
+ }
+
+ // Add GitHub public repos if configured
+ if githubOrg := cfg.FindGitHubOrg(); githubOrg != nil {
+ fmt.Printf("Fetching public repositories from GitHub user/org: %s...\n", githubOrg.Name)
+ client := github.NewClient(githubOrg.GitHubToken, githubOrg.Name)
+
+ if client.HasToken() {
+ repos, err := client.ListPublicRepos()
+ if err != nil {
+ fmt.Printf("Warning: Failed to fetch GitHub repos: %v\n", err)
+ } else {
+ for _, repo := range repos {
+ repoMap[repo.Name] = true
+ }
+ }
+ } else {
+ fmt.Println("Warning: No GitHub token found, skipping GitHub repos")
+ }
+ }
+
+ // Convert map to slice
+ var allRepos []string
+ for repo := range repoMap {
+ allRepos = append(allRepos, repo)
+ }
+
+ return allRepos, nil
+} \ No newline at end of file
diff --git a/internal/config/config.go b/internal/config/config.go
index 7dedee7..cb27058 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -19,10 +19,11 @@ type Organization struct {
// Config holds the application configuration
type Config struct {
- Organizations []Organization `json:"organizations"`
- Repositories []string `json:"repositories,omitempty"`
- ExcludeBranches []string `json:"exclude_branches,omitempty"` // Regex patterns for branches to exclude
- WorkDir string `json:"work_dir,omitempty"` // Working directory for cloning repositories
+ Organizations []Organization `json:"organizations"`
+ Repositories []string `json:"repositories,omitempty"`
+ ExcludeBranches []string `json:"exclude_branches,omitempty"` // Regex patterns for branches to exclude
+ WorkDir string `json:"work_dir,omitempty"` // Working directory for cloning repositories
+ ExcludeFromShowcase []string `json:"exclude_from_showcase,omitempty"` // Repository names to exclude from showcase
}
// Load reads and parses the configuration file
diff --git a/internal/showcase/images.go b/internal/showcase/images.go
new file mode 100644
index 0000000..fe5be72
--- /dev/null
+++ b/internal/showcase/images.go
@@ -0,0 +1,258 @@
+package showcase
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// extractImagesFromRepo extracts up to 2 images from README.md and copies them to showcase directory
+func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, error) {
+ // Look for README files
+ readmeFiles := []string{"README.md", "readme.md", "Readme.md", "README.MD"}
+ var readmePath string
+
+ for _, filename := range readmeFiles {
+ path := filepath.Join(repoPath, filename)
+ if _, err := os.Stat(path); err == nil {
+ readmePath = path
+ break
+ }
+ }
+
+ if readmePath == "" {
+ return nil, nil // No README found, not an error
+ }
+
+ // Read README content
+ content, err := os.ReadFile(readmePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read README: %w", err)
+ }
+
+ fmt.Printf("Found README at: %s\n", readmePath)
+
+ // Extract image references
+ images := extractImageReferences(string(content))
+ fmt.Printf("Found %d images in README\n", len(images))
+ for i, img := range images {
+ fmt.Printf(" Image %d: %s\n", i+1, img)
+ }
+
+ if len(images) == 0 {
+ return nil, nil
+ }
+
+ // Limit to first and last image (max 2)
+ var selectedImages []string
+ if len(images) == 1 {
+ selectedImages = images
+ } else {
+ selectedImages = []string{images[0], images[len(images)-1]}
+ }
+
+ // Create showcase subdirectory for this repo
+ repoShowcaseDir := filepath.Join(showcaseDir, "showcase", repoName)
+ if err := os.MkdirAll(repoShowcaseDir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create showcase directory: %w", err)
+ }
+
+ // Copy images and collect relative paths
+ var copiedImages []string
+ for i, imgPath := range selectedImages {
+ var destFilename string
+ var err error
+
+ if strings.HasPrefix(imgPath, "http://") || strings.HasPrefix(imgPath, "https://") {
+ // Handle URL - download the image
+ // Extract extension from URL, handling query parameters
+ urlParts := strings.Split(imgPath, "?")
+ basePath := urlParts[0]
+ ext := filepath.Ext(basePath)
+ if ext == "" || len(ext) > 5 { // Likely not a real extension
+ ext = ".png" // Default extension
+ }
+ destFilename = fmt.Sprintf("image-%d%s", i+1, ext)
+ destPath := filepath.Join(repoShowcaseDir, destFilename)
+
+ if err = downloadImage(imgPath, destPath); err != nil {
+ fmt.Printf("Warning: Failed to download image %s: %v\n", imgPath, err)
+ continue
+ }
+ } else {
+ // Handle local file
+ srcPath := imgPath
+ if !filepath.IsAbs(imgPath) {
+ srcPath = filepath.Join(repoPath, imgPath)
+ }
+
+ // Check if image exists
+ if _, err := os.Stat(srcPath); err != nil {
+ fmt.Printf("Warning: Image not found: %s\n", srcPath)
+ continue
+ }
+
+ // Generate destination filename
+ ext := filepath.Ext(srcPath)
+ destFilename = fmt.Sprintf("image-%d%s", i+1, ext)
+ destPath := filepath.Join(repoShowcaseDir, destFilename)
+
+ // Copy image
+ if err := copyFile(srcPath, destPath); err != nil {
+ fmt.Printf("Warning: Failed to copy image %s: %v\n", srcPath, err)
+ continue
+ }
+ }
+
+ // Store relative path from showcase directory
+ relativePath := filepath.Join("showcase", repoName, destFilename)
+ copiedImages = append(copiedImages, relativePath)
+ fmt.Printf("Copied/Downloaded image: %s -> %s\n", imgPath, relativePath)
+ }
+
+ return copiedImages, nil
+}
+
+// extractImageReferences extracts image references from markdown content
+func extractImageReferences(content string) []string {
+ var images []string
+ seen := make(map[string]bool)
+
+ // Regex patterns for markdown images
+ patterns := []string{
+ `!\[([^\]]*)\]\(([^)]+)\)`, // ![alt](url)
+ `<img[^>]+src=["']([^"']+)["'][^>]*>`, // <img src="url">
+ `!\[([^\]]*)\]\[([^\]]+)\]`, // ![alt][ref]
+ `\[([^\]]+)\]:\s*(.+?)(?:\s+"[^"]+")?\s*$`, // [ref]: url "title"
+ }
+
+ fmt.Printf("DEBUG: Content length: %d bytes\n", len(content))
+
+ // Extract from markdown image syntax
+ for i, pattern := range patterns[:2] { // First two patterns have URLs in different positions
+ re := regexp.MustCompile(pattern)
+ matches := re.FindAllStringSubmatch(content, -1)
+ fmt.Printf("DEBUG: Pattern %d (%s) found %d matches\n", i, pattern, len(matches))
+
+ for _, match := range matches {
+ var url string
+ if pattern == patterns[0] {
+ url = match[2] // For ![alt](url)
+ } else {
+ url = match[1] // For <img src="url">
+ }
+
+ // Clean and validate URL
+ url = strings.TrimSpace(url)
+ fmt.Printf("DEBUG: Found potential image URL: %s\n", url)
+
+ if isImageFile(url) {
+ fmt.Printf("DEBUG: URL is image file\n")
+ if !seen[url] {
+ // Handle different types of URLs
+ if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
+ // Local file
+ fmt.Printf("DEBUG: Adding local image: %s\n", url)
+ images = append(images, url)
+ seen[url] = true
+ } else if isGitHostedImage(url) {
+ // GitHub/Codeberg hosted images - we can download these
+ fmt.Printf("DEBUG: Found git-hosted image: %s\n", url)
+ images = append(images, url)
+ seen[url] = true
+ } else {
+ fmt.Printf("DEBUG: Skipping external URL: %s\n", url)
+ }
+ }
+ } else {
+ fmt.Printf("DEBUG: Not recognized as image file: %s\n", url)
+ }
+ }
+ }
+
+ // Handle reference-style images
+ refPattern := regexp.MustCompile(patterns[3])
+ refMatches := refPattern.FindAllStringSubmatch(content, -1)
+ refs := make(map[string]string)
+ for _, match := range refMatches {
+ refs[match[1]] = strings.TrimSpace(match[2])
+ }
+
+ // Find reference-style image uses
+ refUsePattern := regexp.MustCompile(patterns[2])
+ refUseMatches := refUsePattern.FindAllStringSubmatch(content, -1)
+ for _, match := range refUseMatches {
+ ref := match[2]
+ if url, ok := refs[ref]; ok && isImageFile(url) && !seen[url] {
+ if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
+ images = append(images, url)
+ seen[url] = true
+ }
+ }
+ }
+
+ return images
+}
+
+// isImageFile checks if a URL points to an image file
+func isImageFile(url string) bool {
+ url = strings.ToLower(url)
+ extensions := []string{".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
+ for _, ext := range extensions {
+ if strings.HasSuffix(url, ext) {
+ return true
+ }
+ }
+ return false
+}
+
+// isGitHostedImage checks if URL is from GitHub/Codeberg
+func isGitHostedImage(url string) bool {
+ return strings.Contains(url, "github.com") ||
+ strings.Contains(url, "githubusercontent.com") ||
+ strings.Contains(url, "codeberg.org") ||
+ strings.Contains(url, "codeberg.page")
+}
+
+// copyFile copies a file from src to dst
+func copyFile(src, dst string) error {
+ sourceFile, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer sourceFile.Close()
+
+ destFile, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer destFile.Close()
+
+ _, err = io.Copy(destFile, sourceFile)
+ if err != nil {
+ return err
+ }
+
+ return destFile.Sync()
+}
+
+// downloadImage downloads an image from URL to dst
+func downloadImage(url, dst string) error {
+ // Use curl to download the image
+ cmd := exec.Command("curl", "-L", "-o", dst, url)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("curl failed: %v, output: %s", err, string(output))
+ }
+
+ // Verify the file was created
+ if _, err := os.Stat(dst); err != nil {
+ return fmt.Errorf("downloaded file not found: %v", err)
+ }
+
+ return nil
+} \ No newline at end of file
diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go
new file mode 100644
index 0000000..209c7b6
--- /dev/null
+++ b/internal/showcase/metadata.go
@@ -0,0 +1,365 @@
+package showcase
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// RepoMetadata holds metadata about a repository
+type RepoMetadata struct {
+ Languages []string
+ CommitCount int
+ LinesOfCode int
+ FirstCommitDate string
+ LastCommitDate string
+ License string
+ AvgCommitAge float64 // Average age of last 100 commits in days
+}
+
+// extractRepoMetadata extracts metadata from a repository
+func extractRepoMetadata(repoPath string) (*RepoMetadata, error) {
+ metadata := &RepoMetadata{}
+
+ // Get programming languages by analyzing file extensions
+ languages, err := detectLanguages(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to detect languages: %v\n", err)
+ }
+ metadata.Languages = languages
+
+ // Get commit count
+ commitCount, err := getCommitCount(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to get commit count: %v\n", err)
+ }
+ metadata.CommitCount = commitCount
+
+ // Get lines of code
+ loc, err := countLinesOfCode(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to count lines of code: %v\n", err)
+ }
+ metadata.LinesOfCode = loc
+
+ // Get first and last commit dates
+ firstDate, err := getFirstCommitDate(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to get first commit date: %v\n", err)
+ }
+ metadata.FirstCommitDate = firstDate
+
+ lastDate, err := getLastCommitDate(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to get last commit date: %v\n", err)
+ }
+ metadata.LastCommitDate = lastDate
+
+ // Check for license file
+ license := detectLicense(repoPath)
+ metadata.License = license
+
+ // Get average age of last 100 commits
+ avgAge, err := getAverageCommitAge(repoPath, 100)
+ if err != nil {
+ fmt.Printf("Warning: Failed to get average commit age: %v\n", err)
+ }
+ metadata.AvgCommitAge = avgAge
+
+ return metadata, nil
+}
+
+// detectLanguages detects programming languages used in the repository
+func detectLanguages(repoPath string) ([]string, error) {
+ languageMap := make(map[string]bool)
+
+ // Define common language extensions
+ langExtensions := map[string]string{
+ ".go": "Go",
+ ".py": "Python",
+ ".js": "JavaScript",
+ ".ts": "TypeScript",
+ ".java": "Java",
+ ".c": "C",
+ ".cpp": "C++",
+ ".cc": "C++",
+ ".h": "C/C++",
+ ".hpp": "C++",
+ ".cs": "C#",
+ ".rb": "Ruby",
+ ".php": "PHP",
+ ".swift": "Swift",
+ ".kt": "Kotlin",
+ ".rs": "Rust",
+ ".scala": "Scala",
+ ".r": "R",
+ ".m": "Objective-C",
+ ".mm": "Objective-C++",
+ ".sh": "Shell",
+ ".bash": "Bash",
+ ".zsh": "Zsh",
+ ".pl": "Perl",
+ ".lua": "Lua",
+ ".vim": "Vim Script",
+ ".el": "Emacs Lisp",
+ ".clj": "Clojure",
+ ".hs": "Haskell",
+ ".ml": "OCaml",
+ ".ex": "Elixir",
+ ".exs": "Elixir",
+ ".dart": "Dart",
+ ".jl": "Julia",
+ ".nim": "Nim",
+ ".v": "V",
+ ".zig": "Zig",
+ }
+
+ // Walk through the repository
+ err := filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil // Skip errors
+ }
+
+ // Skip hidden directories and common non-code directories
+ if info.IsDir() {
+ name := info.Name()
+ if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" || name == "target" || name == "dist" || name == "build" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ // Check file extension
+ ext := strings.ToLower(filepath.Ext(path))
+ if lang, ok := langExtensions[ext]; ok {
+ languageMap[lang] = true
+ }
+
+ // Check for special files
+ basename := filepath.Base(path)
+ switch strings.ToLower(basename) {
+ case "makefile", "gnumakefile":
+ languageMap["Make"] = true
+ case "dockerfile":
+ languageMap["Docker"] = true
+ case "cmakelists.txt":
+ languageMap["CMake"] = true
+ case "rakefile":
+ languageMap["Ruby"] = true
+ case "gemfile":
+ languageMap["Ruby"] = true
+ case "package.json":
+ languageMap["JavaScript/Node.js"] = true
+ case "cargo.toml":
+ languageMap["Rust"] = true
+ case "go.mod":
+ languageMap["Go"] = true
+ case "pom.xml":
+ languageMap["Java/Maven"] = true
+ case "build.gradle", "build.gradle.kts":
+ languageMap["Java/Gradle"] = true
+ case "requirements.txt", "setup.py", "pyproject.toml":
+ languageMap["Python"] = true
+ case "composer.json":
+ languageMap["PHP"] = true
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert map to slice
+ var languages []string
+ for lang := range languageMap {
+ languages = append(languages, lang)
+ }
+
+ return languages, nil
+}
+
+// getCommitCount returns the total number of commits
+func getCommitCount(repoPath string) (int, error) {
+ cmd := exec.Command("git", "-C", repoPath, "rev-list", "--all", "--count")
+ output, err := cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+
+ count, err := strconv.Atoi(strings.TrimSpace(string(output)))
+ if err != nil {
+ return 0, err
+ }
+
+ return count, nil
+}
+
+// countLinesOfCode counts lines of code (excluding binary files and common non-code files)
+func countLinesOfCode(repoPath string) (int, error) {
+ // Use git ls-files to get tracked files, then count lines
+ // Exclude binary files and common non-code files
+ cmd := exec.Command("bash", "-c", fmt.Sprintf(
+ `cd "%s" && git ls-files | grep -E '\.(go|py|js|ts|java|c|cpp|h|hpp|cs|rb|php|swift|kt|rs|scala|r|sh|bash|zsh|pl|lua|vim|el|clj|hs|ml|ex|exs|dart|jl|nim|v|zig|html|css|scss|sass|json|xml|yaml|yml|toml|ini|conf|cfg)$' | xargs wc -l 2>/dev/null | tail -n 1 | awk '{print $1}'`,
+ repoPath,
+ ))
+
+ output, err := cmd.Output()
+ if err != nil {
+ // Fallback: try a simpler approach
+ cmd = exec.Command("bash", "-c", fmt.Sprintf(
+ `find "%s" -type f -name "*.go" -o -name "*.py" -o -name "*.js" -o -name "*.java" -o -name "*.c" -o -name "*.cpp" -o -name "*.rs" | xargs wc -l 2>/dev/null | tail -n 1 | awk '{print $1}'`,
+ repoPath,
+ ))
+ output, err = cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+ }
+
+ loc, err := strconv.Atoi(strings.TrimSpace(string(output)))
+ if err != nil {
+ return 0, err
+ }
+
+ return loc, nil
+}
+
+// getFirstCommitDate returns the date of the first commit
+func getFirstCommitDate(repoPath string) (string, error) {
+ cmd := exec.Command("git", "-C", repoPath, "log", "--reverse", "--pretty=format:%ai", "--date=short")
+ output, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+
+ lines := strings.Split(string(output), "\n")
+ if len(lines) > 0 && lines[0] != "" {
+ // Extract just the date part (YYYY-MM-DD)
+ parts := strings.Fields(lines[0])
+ if len(parts) > 0 {
+ return parts[0], nil
+ }
+ }
+
+ return "", fmt.Errorf("no commits found")
+}
+
+// getLastCommitDate returns the date of the last commit
+func getLastCommitDate(repoPath string) (string, error) {
+ cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--pretty=format:%ai", "--date=short")
+ output, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+
+ // Extract just the date part (YYYY-MM-DD)
+ parts := strings.Fields(string(output))
+ if len(parts) > 0 {
+ return parts[0], nil
+ }
+
+ return "", fmt.Errorf("no commits found")
+}
+
+// detectLicense checks for common license files
+func detectLicense(repoPath string) string {
+ licenseFiles := []string{
+ "LICENSE",
+ "LICENSE.txt",
+ "LICENSE.md",
+ "license",
+ "license.txt",
+ "license.md",
+ "COPYING",
+ "COPYING.txt",
+ "COPYRIGHT",
+ "COPYRIGHT.txt",
+ }
+
+ for _, filename := range licenseFiles {
+ path := filepath.Join(repoPath, filename)
+ if info, err := os.Stat(path); err == nil && !info.IsDir() {
+ // Try to detect license type by reading the file
+ content, err := os.ReadFile(path)
+ if err == nil {
+ contentStr := string(content)
+ switch {
+ case strings.Contains(contentStr, "MIT License"):
+ return "MIT"
+ case strings.Contains(contentStr, "Apache License") && strings.Contains(contentStr, "Version 2.0"):
+ return "Apache-2.0"
+ case strings.Contains(contentStr, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(contentStr, "Version 3"):
+ return "GPL-3.0"
+ case strings.Contains(contentStr, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(contentStr, "Version 2"):
+ return "GPL-2.0"
+ case strings.Contains(contentStr, "BSD 3-Clause License"):
+ return "BSD-3-Clause"
+ case strings.Contains(contentStr, "BSD 2-Clause License"):
+ return "BSD-2-Clause"
+ case strings.Contains(contentStr, "Mozilla Public License Version 2.0"):
+ return "MPL-2.0"
+ case strings.Contains(contentStr, "ISC License"):
+ return "ISC"
+ case strings.Contains(contentStr, "GNU LESSER GENERAL PUBLIC LICENSE"):
+ return "LGPL"
+ case strings.Contains(contentStr, "The Unlicense"):
+ return "Unlicense"
+ case strings.Contains(contentStr, "CC0"):
+ return "CC0"
+ default:
+ return "Custom License"
+ }
+ }
+ return "License file found"
+ }
+ }
+
+ return "No license found"
+}
+
+// getAverageCommitAge calculates the average age of the last N commits in days
+func getAverageCommitAge(repoPath string, commitCount int) (float64, error) {
+ // Get the last N commit dates
+ cmd := exec.Command("git", "-C", repoPath, "log", fmt.Sprintf("-%d", commitCount), "--pretty=format:%at")
+ output, err := cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ if len(lines) == 0 || lines[0] == "" {
+ return 0, fmt.Errorf("no commits found")
+ }
+
+ // Calculate average age
+ now := float64(time.Now().Unix())
+ var totalAge float64
+ validCommits := 0
+
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+
+ timestamp, err := strconv.ParseInt(line, 10, 64)
+ if err != nil {
+ continue
+ }
+
+ age := (now - float64(timestamp)) / 86400 // Convert to days
+ totalAge += age
+ validCommits++
+ }
+
+ if validCommits == 0 {
+ return 0, fmt.Errorf("no valid commits found")
+ }
+
+ return totalAge / float64(validCommits), nil
+} \ No newline at end of file
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go
new file mode 100644
index 0000000..9c5afa6
--- /dev/null
+++ b/internal/showcase/showcase.go
@@ -0,0 +1,560 @@
+package showcase
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "codeberg.org/snonux/gitsyncer/internal/config"
+)
+
+// Generator handles showcase generation for repositories
+type Generator struct {
+ config *config.Config
+ workDir string
+}
+
+// ProjectSummary holds the summary information for a project
+type ProjectSummary struct {
+ Name string
+ Summary string
+ CodebergURL string
+ GitHubURL string
+ Metadata *RepoMetadata
+ Images []string // Relative paths to images in showcase directory
+}
+
+// New creates a new showcase generator
+func New(cfg *config.Config, workDir string) *Generator {
+ return &Generator{
+ config: cfg,
+ workDir: workDir,
+ }
+}
+
+// GenerateShowcase generates a showcase for repositories
+// If repoFilter is provided, only those repositories are processed
+// If repoFilter is empty/nil, all repositories in work directory are processed
+func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool) error {
+ var repos []string
+ var err error
+
+ if len(repoFilter) > 0 {
+ // Use the provided filter
+ repos = repoFilter
+ } else {
+ // Get all repositories in work directory
+ repos, err = g.getRepositories()
+ if err != nil {
+ return fmt.Errorf("failed to get repositories: %w", err)
+ }
+ }
+
+ if len(repos) == 0 {
+ return fmt.Errorf("no repositories found")
+ }
+
+ // Filter out excluded repositories
+ filteredRepos := g.filterExcludedRepos(repos)
+
+ fmt.Printf("Found %d repositories to process (after filtering %d excluded)\n",
+ len(filteredRepos), len(repos)-len(filteredRepos))
+
+ // Generate summaries for each repository
+ summaries := make([]ProjectSummary, 0, len(filteredRepos))
+ successCount := 0
+
+ for i, repo := range filteredRepos {
+ fmt.Printf("\n[%d/%d] Processing %s...\n", i+1, len(filteredRepos), repo)
+
+ summary, err := g.generateProjectSummary(repo, forceRegenerate)
+ if err != nil {
+ fmt.Printf("WARNING: Failed to generate summary for %s: %v\n", repo, err)
+ continue
+ }
+
+ // Print the generated summary to stdout
+ fmt.Printf("\n--- Generated summary for %s ---\n", repo)
+ fmt.Println(summary.Summary)
+ if summary.Metadata != nil {
+ fmt.Printf("Languages: %s\n", strings.Join(summary.Metadata.Languages, ", "))
+ fmt.Printf("Commits: %d\n", summary.Metadata.CommitCount)
+ fmt.Printf("Lines of Code: %d\n", summary.Metadata.LinesOfCode)
+ fmt.Printf("First Commit: %s\n", summary.Metadata.FirstCommitDate)
+ fmt.Printf("Last Commit: %s\n", summary.Metadata.LastCommitDate)
+ fmt.Printf("License: %s\n", summary.Metadata.License)
+ fmt.Printf("Avg. age of last 100 commits: %.1f days\n", summary.Metadata.AvgCommitAge)
+ }
+ fmt.Println("--- End of summary ---")
+
+ summaries = append(summaries, *summary)
+ successCount++
+ }
+
+ if successCount == 0 {
+ return fmt.Errorf("failed to generate any summaries")
+ }
+
+ fmt.Printf("\nSuccessfully generated %d/%d summaries\n", successCount, len(repos))
+
+ // Sort summaries by average commit age (newest first)
+ sort.Slice(summaries, func(i, j int) bool {
+ // If metadata is missing, put at the end
+ if summaries[i].Metadata == nil {
+ return false
+ }
+ if summaries[j].Metadata == nil {
+ return true
+ }
+ // Lower average age means more recent activity
+ return summaries[i].Metadata.AvgCommitAge < summaries[j].Metadata.AvgCommitAge
+ })
+
+ // When filtering (single repo), we need to update existing showcase
+ if len(repoFilter) > 0 {
+ if err := g.updateShowcaseFile(summaries); err != nil {
+ return fmt.Errorf("failed to update showcase file: %w", err)
+ }
+ } else {
+ // Full regeneration - format as Gemtext and write
+ content := g.formatGemtext(summaries)
+ if err := g.writeShowcaseFile(content); err != nil {
+ return fmt.Errorf("failed to write showcase file: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// getRepositories returns a list of repository directories in the work directory
+func (g *Generator) getRepositories() ([]string, error) {
+ entries, err := os.ReadDir(g.workDir)
+ if err != nil {
+ return nil, err
+ }
+
+ var repos []string
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ // Check if it's a git repository
+ gitDir := filepath.Join(g.workDir, entry.Name(), ".git")
+ if info, err := os.Stat(gitDir); err == nil && info.IsDir() {
+ repos = append(repos, entry.Name())
+ }
+ }
+
+ // Sort repositories alphabetically
+ sort.Strings(repos)
+ return repos, nil
+}
+
+// generateProjectSummary generates a summary for a single project
+func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool) (*ProjectSummary, error) {
+ repoPath := filepath.Join(g.workDir, repoName)
+
+ // Check cache first
+ cacheDir := filepath.Join(g.workDir, ".gitsyncer-showcase-cache")
+ cacheFile := filepath.Join(cacheDir, repoName+".json")
+
+ if !forceRegenerate {
+ if cached, err := g.loadFromCache(cacheFile); err == nil {
+ fmt.Printf("Using cached summary (cache file: %s)\n", cacheFile)
+ // Verify that cached images still exist
+ if err := g.verifyImages(cached); err != nil {
+ fmt.Printf("Warning: Cached images missing, regenerating: %v\n", err)
+ } else {
+ return cached, nil
+ }
+ }
+ }
+
+ // Check if claude command exists
+ if _, err := exec.LookPath("claude"); err != nil {
+ return nil, fmt.Errorf("claude command not found. Please install Claude CLI")
+ }
+
+ // Change to repository directory
+ originalDir, err := os.Getwd()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get current directory: %w", err)
+ }
+ defer os.Chdir(originalDir)
+
+ if err := os.Chdir(repoPath); err != nil {
+ return nil, fmt.Errorf("failed to change to repository directory: %w", err)
+ }
+
+ // Extract metadata first
+ fmt.Printf("Extracting repository metadata...\n")
+ metadata, err := extractRepoMetadata(repoPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to extract some metadata: %v\n", err)
+ // Continue anyway with partial metadata
+ }
+
+ // Run claude command
+ prompt := "Please provide a 1-2 paragraph summary of this project, explaining what it does, why it's useful, and how it's implemented. Focus on the key features and architecture. Be concise but informative."
+
+ fmt.Printf("Running Claude command:\n")
+ fmt.Printf(" claude --model sonnet \"%s\"\n", prompt)
+
+ cmd := exec.Command("claude", "--model", "sonnet", prompt)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to run claude: %w", err)
+ }
+
+ summary := strings.TrimSpace(string(output))
+ if summary == "" {
+ return nil, fmt.Errorf("received empty summary from claude")
+ }
+
+ // Build URLs
+ codebergURL := ""
+ githubURL := ""
+
+ if codebergOrg := g.config.FindCodebergOrg(); codebergOrg != nil {
+ codebergURL = fmt.Sprintf("https://codeberg.org/%s/%s", codebergOrg.Name, repoName)
+ }
+
+ if githubOrg := g.config.FindGitHubOrg(); githubOrg != nil {
+ githubURL = fmt.Sprintf("https://github.com/%s/%s", githubOrg.Name, repoName)
+ }
+
+ // Extract images from README
+ fmt.Printf("Extracting images from README...\n")
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get home directory: %w", err)
+ }
+ showcaseDir := filepath.Join(home, "git", "foo.zone-content", "gemtext", "about")
+ images, err := extractImagesFromRepo(repoPath, repoName, showcaseDir)
+ if err != nil {
+ fmt.Printf("Warning: Failed to extract images: %v\n", err)
+ // Continue without images
+ }
+
+ projectSummary := &ProjectSummary{
+ Name: repoName,
+ Summary: summary,
+ CodebergURL: codebergURL,
+ GitHubURL: githubURL,
+ Metadata: metadata,
+ Images: images,
+ }
+
+ // Save to cache
+ if err := g.saveToCache(cacheFile, projectSummary); err != nil {
+ fmt.Printf("Warning: Failed to save to cache: %v\n", err)
+ } else {
+ fmt.Printf("Summary cached at: %s\n", cacheFile)
+ }
+
+ return projectSummary, nil
+}
+
+// formatGemtext formats the summaries as Gemini Gemtext
+func (g *Generator) formatGemtext(summaries []ProjectSummary) string {
+ var builder strings.Builder
+
+ // Header
+ builder.WriteString("# Project Showcase\n\n")
+
+ // Introduction paragraph
+ builder.WriteString("This page showcases my open source projects, providing an overview of what each project does, its technical implementation, and key metrics. Each project summary includes information about the programming languages used, development activity, and licensing.\n\n")
+
+ // Template inline TOC
+ builder.WriteString("<< template::inline::toc\n\n")
+
+ builder.WriteString(fmt.Sprintf("Generated on: %s\n\n", time.Now().Format("2006-01-02")))
+
+ // Add each project
+ for i, summary := range summaries {
+ if i > 0 {
+ builder.WriteString("\n---\n\n")
+ }
+
+ builder.WriteString(fmt.Sprintf("## %s\n\n", summary.Name))
+
+ // Add metadata if available
+ if summary.Metadata != nil {
+ if len(summary.Metadata.Languages) > 0 {
+ builder.WriteString(fmt.Sprintf("* Languages: %s\n", strings.Join(summary.Metadata.Languages, ", ")))
+ }
+ builder.WriteString(fmt.Sprintf("* Commits: %d\n", summary.Metadata.CommitCount))
+ builder.WriteString(fmt.Sprintf("* Lines of Code: %d\n", summary.Metadata.LinesOfCode))
+ builder.WriteString(fmt.Sprintf("* Development Period: %s to %s\n", summary.Metadata.FirstCommitDate, summary.Metadata.LastCommitDate))
+ builder.WriteString(fmt.Sprintf("* License: %s\n\n", summary.Metadata.License))
+ }
+
+ // Handle images and paragraphs
+ paragraphs := strings.Split(summary.Summary, "\n\n")
+
+ // If we have images, distribute them nicely
+ if len(summary.Images) > 0 {
+ // First image after metadata, before text
+ builder.WriteString(fmt.Sprintf("=> %s %s screenshot\n\n", summary.Images[0], summary.Name))
+
+ // First paragraph
+ if len(paragraphs) > 0 {
+ builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[0])))
+ }
+
+ // Second image after first paragraph (if we have 2 images and multiple paragraphs)
+ if len(summary.Images) > 1 && len(paragraphs) > 1 {
+ builder.WriteString(fmt.Sprintf("=> %s %s screenshot\n\n", summary.Images[1], summary.Name))
+ }
+
+ // Remaining paragraphs
+ for i := 1; i < len(paragraphs); i++ {
+ builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[i])))
+ }
+ } else {
+ // No images, just add paragraphs normally
+ for _, para := range paragraphs {
+ builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(para)))
+ }
+ }
+
+ // Add links
+ if summary.CodebergURL != "" {
+ builder.WriteString(fmt.Sprintf("=> %s View on Codeberg\n", summary.CodebergURL))
+ }
+ if summary.GitHubURL != "" {
+ builder.WriteString(fmt.Sprintf("=> %s View on GitHub\n", summary.GitHubURL))
+ }
+ }
+
+ return builder.String()
+}
+
+// writeShowcaseFile writes the showcase content to the target file
+func (g *Generator) writeShowcaseFile(content string) error {
+ // Build target path
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return fmt.Errorf("failed to get home directory: %w", err)
+ }
+
+ targetDir := filepath.Join(home, "git", "foo.zone-content", "gemtext", "about")
+ targetFile := filepath.Join(targetDir, "showcase.gmi.tpl")
+
+ // Create directory if it doesn't exist
+ if err := os.MkdirAll(targetDir, 0755); err != nil {
+ return fmt.Errorf("failed to create directory %s: %w", targetDir, err)
+ }
+
+ // Write file
+ if err := os.WriteFile(targetFile, []byte(content), 0644); err != nil {
+ return fmt.Errorf("failed to write file: %w", err)
+ }
+
+ fmt.Printf("\nShowcase written to: %s\n", targetFile)
+ return nil
+}
+
+// updateShowcaseFile updates specific entries in an existing showcase file
+func (g *Generator) updateShowcaseFile(newSummaries []ProjectSummary) error {
+ // Build target path
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return fmt.Errorf("failed to get home directory: %w", err)
+ }
+
+ targetDir := filepath.Join(home, "git", "foo.zone-content", "gemtext", "about")
+ targetFile := filepath.Join(targetDir, "showcase.gmi.tpl")
+
+ // Read existing file if it exists
+ existingSummaries := make(map[string]ProjectSummary)
+ if data, err := os.ReadFile(targetFile); err == nil {
+ // Parse existing summaries (simplified - in production would need proper parsing)
+ existingSummaries = g.parseExistingSummaries(string(data))
+ }
+
+ // Update with new summaries
+ for _, summary := range newSummaries {
+ existingSummaries[summary.Name] = summary
+ }
+
+ // Convert map to slice
+ var allSummaries []ProjectSummary
+ for _, summary := range existingSummaries {
+ allSummaries = append(allSummaries, summary)
+ }
+
+ // Sort by average commit age (newest first)
+ sort.Slice(allSummaries, func(i, j int) bool {
+ // If metadata is missing, put at the end
+ if allSummaries[i].Metadata == nil {
+ return false
+ }
+ if allSummaries[j].Metadata == nil {
+ return true
+ }
+ // Lower average age means more recent activity
+ return allSummaries[i].Metadata.AvgCommitAge < allSummaries[j].Metadata.AvgCommitAge
+ })
+
+ // Format and write
+ content := g.formatGemtext(allSummaries)
+ if err := g.writeShowcaseFile(content); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// parseExistingSummaries parses existing showcase file to extract summaries
+// This is a simplified implementation - in production would need more robust parsing
+func (g *Generator) parseExistingSummaries(content string) map[string]ProjectSummary {
+ summaries := make(map[string]ProjectSummary)
+
+ // Split by repository sections
+ sections := strings.Split(content, "\n---\n")
+
+ for _, section := range sections {
+ lines := strings.Split(section, "\n")
+ var currentRepo string
+ var summaryText strings.Builder
+ var codebergURL, githubURL string
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+
+ // Extract repository name
+ if strings.HasPrefix(line, "## ") {
+ currentRepo = strings.TrimPrefix(line, "## ")
+ continue
+ }
+
+ // Extract URLs
+ if strings.HasPrefix(line, "=> ") {
+ if strings.Contains(line, "codeberg.org") {
+ parts := strings.SplitN(line, " ", 3)
+ if len(parts) >= 2 {
+ codebergURL = parts[1]
+ }
+ } else if strings.Contains(line, "github.com") {
+ parts := strings.SplitN(line, " ", 3)
+ if len(parts) >= 2 {
+ githubURL = parts[1]
+ }
+ }
+ continue
+ }
+
+ // Skip header lines
+ if strings.HasPrefix(line, "#") || strings.Contains(line, "Generated on:") {
+ continue
+ }
+
+ // Collect summary text
+ if line != "" && currentRepo != "" {
+ if summaryText.Len() > 0 {
+ summaryText.WriteString("\n\n")
+ }
+ summaryText.WriteString(line)
+ }
+ }
+
+ if currentRepo != "" && summaryText.Len() > 0 {
+ summaries[currentRepo] = ProjectSummary{
+ Name: currentRepo,
+ Summary: strings.TrimSpace(summaryText.String()),
+ CodebergURL: codebergURL,
+ GitHubURL: githubURL,
+ }
+ }
+ }
+
+ return summaries
+}
+
+// loadFromCache loads a project summary from cache
+func (g *Generator) loadFromCache(cacheFile string) (*ProjectSummary, error) {
+ data, err := os.ReadFile(cacheFile)
+ if err != nil {
+ return nil, err
+ }
+
+ var summary ProjectSummary
+ if err := json.Unmarshal(data, &summary); err != nil {
+ return nil, err
+ }
+
+ return &summary, nil
+}
+
+// saveToCache saves a project summary to cache
+func (g *Generator) saveToCache(cacheFile string, summary *ProjectSummary) error {
+ // Create cache directory if it doesn't exist
+ cacheDir := filepath.Dir(cacheFile)
+ if err := os.MkdirAll(cacheDir, 0755); err != nil {
+ return err
+ }
+
+ // Marshal to JSON
+ data, err := json.MarshalIndent(summary, "", " ")
+ if err != nil {
+ return err
+ }
+
+ // Write to file
+ return os.WriteFile(cacheFile, data, 0644)
+}
+
+// verifyImages checks if cached images still exist
+func (g *Generator) verifyImages(summary *ProjectSummary) error {
+ if len(summary.Images) == 0 {
+ return nil
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+
+ showcaseDir := filepath.Join(home, "git", "foo.zone-content", "gemtext", "about")
+
+ for _, imgPath := range summary.Images {
+ fullPath := filepath.Join(showcaseDir, imgPath)
+ if _, err := os.Stat(fullPath); err != nil {
+ return fmt.Errorf("image not found: %s", imgPath)
+ }
+ }
+
+ return nil
+}
+
+// filterExcludedRepos filters out repositories that are in the exclusion list
+func (g *Generator) filterExcludedRepos(repos []string) []string {
+ if len(g.config.ExcludeFromShowcase) == 0 {
+ return repos
+ }
+
+ // Create a map for quick lookup
+ excludeMap := make(map[string]bool)
+ for _, excluded := range g.config.ExcludeFromShowcase {
+ excludeMap[excluded] = true
+ }
+
+ // Filter repositories
+ var filtered []string
+ for _, repo := range repos {
+ if !excludeMap[repo] {
+ filtered = append(filtered, repo)
+ } else {
+ fmt.Printf("Excluding repository from showcase: %s\n", repo)
+ }
+ }
+
+ return filtered
+} \ No newline at end of file
diff --git a/internal/sync/sync.go b/internal/sync/sync.go
index af0feba..85372fa 100644
--- a/internal/sync/sync.go
+++ b/internal/sync/sync.go
@@ -114,6 +114,49 @@ func (s *Syncer) SyncRepository(repoName string) error {
return nil
}
+// EnsureRepositoryCloned ensures a repository is cloned locally without syncing
+// This is used for showcase-only mode
+func (s *Syncer) EnsureRepositoryCloned(repoName string) error {
+ s.repoName = repoName
+
+ // Create work directory if it doesn't exist
+ if err := os.MkdirAll(s.workDir, 0755); err != nil {
+ return fmt.Errorf("failed to create work directory: %w", err)
+ }
+
+ // Check if repository already exists
+ repoPath := filepath.Join(s.workDir, repoName)
+ if _, err := os.Stat(repoPath); err == nil {
+ // Repository exists, nothing to do
+ fmt.Printf(" Repository %s already exists locally\n", repoName)
+ return nil
+ }
+
+ // Repository doesn't exist, clone it
+ fmt.Printf(" Cloning %s...\n", repoName)
+
+ // Find first non-backup organization to clone from
+ var sourceOrg *config.Organization
+ for i := range s.config.Organizations {
+ if !s.config.Organizations[i].BackupLocation {
+ sourceOrg = &s.config.Organizations[i]
+ break
+ }
+ }
+
+ if sourceOrg == nil {
+ return fmt.Errorf("no non-backup organizations configured to clone from")
+ }
+
+ // Clone the repository
+ if err := s.cloneRepository(sourceOrg, repoPath); err != nil {
+ return fmt.Errorf("failed to clone repository: %w", err)
+ }
+
+ fmt.Printf(" Successfully cloned %s\n", repoName)
+ return nil
+}
+
// cloneRepository clones a repository from an organization
func (s *Syncer) cloneRepository(org *config.Organization, repoPath string) error {
// Skip cloning from backup locations
@@ -186,8 +229,13 @@ func (s *Syncer) fetchAll() error {
// Fetch from each remote
for remote := range remotes {
- // Skip backup locations - we don't fetch from them
+ // Skip backup locations if backup is not enabled
if org, exists := remotesMap[remote]; exists && org.BackupLocation {
+ if !s.backupEnabled {
+ // Silently skip - don't even print a message since backup is not enabled
+ continue
+ }
+ // Even when backup is enabled, we don't fetch from backup locations
fmt.Printf("Skipping fetch from backup location %s\n", remote)
continue
}