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}