main
  1// flux generates the personal activity stream for vincent.demeester.fr/flux/.
  2//
  3// Usage:
  4//
  5//	flux generate          # fetch sources, merge, render
  6//	flux generate -v       # verbose
  7//	flux generate --dry-run
  8//	flux list              # show cached entries
  9package main
 10
 11import (
 12	"context"
 13	"flag"
 14	"fmt"
 15	"log"
 16	"os"
 17	"os/exec"
 18	"path/filepath"
 19	"time"
 20
 21	"github.com/vdemeester/www/internal/flux"
 22)
 23
 24func main() {
 25	if len(os.Args) < 2 {
 26		usage()
 27		os.Exit(1)
 28	}
 29
 30	switch os.Args[1] {
 31	case "generate":
 32		if err := cmdGenerate(os.Args[2:]); err != nil {
 33			log.Fatalf("generate: %v", err)
 34		}
 35	case "list":
 36		if err := cmdList(os.Args[2:]); err != nil {
 37			log.Fatalf("list: %v", err)
 38		}
 39	case "version":
 40		fmt.Println("flux dev")
 41	case "help", "-h", "--help":
 42		usage()
 43	default:
 44		fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
 45		usage()
 46		os.Exit(1)
 47	}
 48}
 49
 50func usage() {
 51	fmt.Fprintf(os.Stderr, `Usage: flux <command> [flags]
 52
 53Commands:
 54  generate    Fetch sources, merge entries, render HTML + feeds
 55  list        Show cached entries
 56  version     Print version
 57  help        Show this help
 58
 59`)
 60}
 61
 62func cmdGenerate(args []string) error {
 63	fs := flag.NewFlagSet("generate", flag.ExitOnError)
 64	verbose := fs.Bool("v", false, "verbose output")
 65	dryRun := fs.Bool("dry-run", false, "show what would be generated without writing")
 66	storeFile := fs.String("store", "", "path to entries.json (default: <output>/entries.json)")
 67	outputDir := fs.String("output", "", "output directory (default: ./flux)")
 68	githubUser := fs.String("github-user", "vdemeester", "GitHub username")
 69	tilFile := fs.String("til", "", "path to til.org (default: ~/desktop/org/til.org)")
 70	bookmarksFile := fs.String("bookmarks", "", "path to bookmarks.org (default: ~/desktop/org/bookmarks.org)")
 71	baseURL := fs.String("base-url", "https://vincent.demeester.fr/flux", "base URL for feeds")
 72	siteURL := fs.String("site-url", "https://vincent.demeester.fr", "site root URL")
 73	feedLimit := fs.Int("feed-limit", 100, "max entries in feeds")
 74	fs.Parse(args)
 75
 76	// Resolve output dir
 77	outDir := *outputDir
 78	if outDir == "" {
 79		outDir = "flux"
 80	}
 81	outDir, _ = filepath.Abs(outDir)
 82
 83	// Resolve store path
 84	storePath := *storeFile
 85	if storePath == "" {
 86		storePath = filepath.Join(outDir, "entries.json")
 87	}
 88
 89	if *verbose {
 90		log.Printf("store: %s", storePath)
 91		log.Printf("output: %s", outDir)
 92	}
 93
 94	// Load existing entries
 95	store, err := flux.LoadStore(storePath)
 96	if err != nil {
 97		return fmt.Errorf("loading store: %w", err)
 98	}
 99	if *verbose {
100		log.Printf("loaded %d existing entries", len(store.Entries))
101	}
102
103	// Find the most recent entry date for incremental fetch
104	var since time.Time
105	if len(store.Entries) > 0 {
106		store.Sort()
107		since = store.Entries[0].Date.Add(-24 * time.Hour) // overlap by 1 day for safety
108	}
109
110	// Set up sources
111	token := os.Getenv("GITHUB_TOKEN")
112	if token == "" {
113		// Try gh auth
114		token = ghToken()
115	}
116
117	// Resolve repo path for git log source
118	repoPath, _ := filepath.Abs(".")
119
120	// Resolve TIL file path
121	tilPath := *tilFile
122	if tilPath == "" {
123		tilPath = filepath.Join(os.Getenv("HOME"), "desktop", "org", "til.org")
124	}
125
126	// Resolve bookmarks file path
127	bmPath := *bookmarksFile
128	if bmPath == "" {
129		bmPath = filepath.Join(os.Getenv("HOME"), "desktop", "org", "bookmarks.org")
130	}
131
132	sources := []flux.Source{
133		&flux.GitHubSource{
134			User:        *githubUser,
135			Token:       token,
136			ReleaseOrgs: []string{"tektoncd", "vdemeester"},
137		},
138		&flux.GitLogSource{
139			RepoPath: repoPath,
140			BaseURL:  *siteURL,
141		},
142		&flux.TILSource{
143			File: tilPath,
144		},
145		&flux.BookmarkSource{
146			File: bmPath,
147		},
148		&flux.MastodonSource{
149			Instance: "fosstodon.org",
150			Username: "vdemeester",
151		},
152	}
153
154	// Fetch from all sources
155	ctx := context.Background()
156	var newEntries []flux.Entry
157	for _, src := range sources {
158		if *verbose {
159			log.Printf("fetching from %s (since %s)...", src.Name(), since.Format("2006-01-02"))
160		}
161		entries, err := src.Fetch(ctx, since)
162		if err != nil {
163			log.Printf("warning: %s: %v", src.Name(), err)
164			continue
165		}
166		if *verbose {
167			log.Printf("  %s: got %d entries", src.Name(), len(entries))
168		}
169		newEntries = append(newEntries, entries...)
170	}
171
172	// Merge and sort
173	added := store.Merge(newEntries)
174	store.Sort()
175	if *verbose {
176		log.Printf("merged: %d new, %d total", added, len(store.Entries))
177	}
178
179	if *dryRun {
180		fmt.Printf("Would write %d entries to %s\n", len(store.Entries), storePath)
181		fmt.Printf("Would render to %s\n", outDir)
182		for i, e := range store.Latest(20) {
183			fmt.Printf("  %d. [%s] %s (%s)\n", i+1, e.Kind, e.Title, e.Date.Format("2006-01-02"))
184		}
185		return nil
186	}
187
188	// Ensure output dir exists
189	if err := os.MkdirAll(outDir, 0755); err != nil {
190		return fmt.Errorf("creating output dir: %w", err)
191	}
192
193	// Save store
194	if err := store.Save(storePath); err != nil {
195		return fmt.Errorf("saving store: %w", err)
196	}
197	if *verbose {
198		log.Printf("saved %d entries to %s", len(store.Entries), storePath)
199	}
200
201	// Render
202	cfg := flux.Config{
203		Title:       "Flux",
204		Description: "What I'm working on, reading, and thinking about.",
205		Author:      "Vincent Demeester",
206		BaseURL:     *baseURL,
207		SiteURL:     *siteURL,
208		OutputDir:   outDir,
209	}
210
211	feedEntries := store.Latest(*feedLimit)
212
213	if err := flux.RenderJSONFeed(cfg, feedEntries); err != nil {
214		return fmt.Errorf("rendering JSON feed: %w", err)
215	}
216	if err := flux.RenderAtomFeed(cfg, feedEntries); err != nil {
217		return fmt.Errorf("rendering Atom feed: %w", err)
218	}
219
220	// Content-only feeds (pages, TIL, bookmarks — no GitHub)
221	contentEntries := store.ContentOnly()
222	if len(contentEntries) > *feedLimit {
223		contentEntries = contentEntries[:*feedLimit]
224	}
225	contentCfg := cfg
226	contentCfg.Title = "Vincent Demeester"
227	contentCfg.Description = "Pages, notes, and bookmarks."
228	if err := flux.RenderJSONFeedTo(contentCfg, contentEntries, filepath.Join(outDir, "content.json")); err != nil {
229		return fmt.Errorf("rendering content JSON feed: %w", err)
230	}
231	if err := flux.RenderAtomFeedTo(contentCfg, contentEntries, filepath.Join(outDir, "content.xml")); err != nil {
232		return fmt.Errorf("rendering content Atom feed: %w", err)
233	}
234	if err := flux.RenderHTML(cfg, store); err != nil {
235		return fmt.Errorf("rendering HTML: %w", err)
236	}
237
238	// Render homepage snippet (latest 8 entries, mixed types)
239	snippetPath := filepath.Join(outDir, "home-snippet.html")
240	if err := flux.RenderHomeSnippet(cfg, store.Latest(12), snippetPath); err != nil {
241		return fmt.Errorf("rendering home snippet: %w", err)
242	}
243
244	if *verbose {
245		log.Printf("rendered to %s", outDir)
246	}
247	fmt.Printf("✓ %d entries (%d new) → %s\n", len(store.Entries), added, outDir)
248
249	return nil
250}
251
252func cmdList(args []string) error {
253	fs := flag.NewFlagSet("list", flag.ExitOnError)
254	storeFile := fs.String("store", "flux/entries.json", "path to entries.json")
255	limit := fs.Int("limit", 20, "max entries to show")
256	source := fs.String("source", "", "filter by source")
257	fs.Parse(args)
258
259	store, err := flux.LoadStore(*storeFile)
260	if err != nil {
261		return err
262	}
263	store.Sort()
264
265	shown := 0
266	for _, e := range store.Entries {
267		if *source != "" && e.Source != *source {
268			continue
269		}
270		fmt.Printf("[%s] %s — %s (%s)\n", e.Kind, e.Title, e.Date.Format("2006-01-02"), e.Source)
271		shown++
272		if shown >= *limit {
273			break
274		}
275	}
276	fmt.Printf("\n%d/%d entries shown\n", shown, len(store.Entries))
277	return nil
278}
279
280// ghToken tries to get a token from the gh CLI.
281func ghToken() string {
282	out, err := exec.Command("gh", "auth", "token").Output()
283	if err != nil {
284		return ""
285	}
286	// Trim trailing newline
287	s := string(out)
288	if len(s) > 0 && s[len(s)-1] == '\n' {
289		s = s[:len(s)-1]
290	}
291	return s
292}