main
1package flux
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "regexp"
9 "strings"
10 "time"
11)
12
13// TILSource parses a TIL org file and extracts entries.
14type TILSource struct {
15 File string // path to til.org
16}
17
18func (t *TILSource) Name() string { return "til" }
19
20// heading matches: * Title :tag1:tag2:
21var headingRe = regexp.MustCompile(`^\*\s+(.+?)\s+:([^:]+(?::[^:]+)*):$`)
22
23// headingNoTags matches: * Title (no tags)
24var headingNoTagsRe = regexp.MustCompile(`^\*\s+(.+)$`)
25
26// orgDate matches: <2026-03-12 Wed> or [2026-03-12 Wed]
27var orgDateRe = regexp.MustCompile(`[<\[](\d{4}-\d{2}-\d{2})\s+\w+[>\]]`)
28
29// orgMarkup patterns for stripping to plain text
30var orgBoldRe = regexp.MustCompile(`\*([^*]+)\*`)
31var orgItalicRe = regexp.MustCompile(`/([^/]+)/`)
32var orgCodeRe = regexp.MustCompile(`[=~]([^=~]+)[=~]`)
33var orgLinkRe = regexp.MustCompile(`\[\[([^\]]+)\]\[([^\]]+)\]\]`)
34
35// Fetch parses the org file and returns TIL entries.
36func (t *TILSource) Fetch(ctx context.Context, since time.Time) ([]Entry, error) {
37 f, err := os.Open(t.File)
38 if err != nil {
39 if os.IsNotExist(err) {
40 return nil, nil
41 }
42 return nil, fmt.Errorf("opening %s: %w", t.File, err)
43 }
44 defer f.Close()
45
46 var entries []Entry
47 var current *tilEntry
48
49 scanner := bufio.NewScanner(f)
50 for scanner.Scan() {
51 line := scanner.Text()
52
53 // Check for heading with tags
54 if m := headingRe.FindStringSubmatch(line); m != nil {
55 // Flush previous entry
56 if current != nil {
57 if e := current.toEntry(); e != nil {
58 entries = append(entries, *e)
59 }
60 }
61 current = &tilEntry{
62 title: cleanOrgMarkup(m[1]),
63 tags: strings.Split(m[2], ":"),
64 }
65 continue
66 }
67
68 // Check for heading without tags
69 if headingNoTagsRe.MatchString(line) && !strings.HasPrefix(line, "**") && strings.HasPrefix(line, "* ") {
70 // Only match top-level headings, skip bold text like *Don't.*
71 if current != nil {
72 if e := current.toEntry(); e != nil {
73 entries = append(entries, *e)
74 }
75 }
76 title := strings.TrimPrefix(line, "* ")
77 current = &tilEntry{
78 title: cleanOrgMarkup(strings.TrimSpace(title)),
79 }
80 continue
81 }
82
83 if current == nil {
84 continue
85 }
86
87 // Check for date
88 if m := orgDateRe.FindStringSubmatch(line); m != nil && current.date.IsZero() {
89 if d, err := time.Parse("2006-01-02", m[1]); err == nil {
90 current.date = d
91 }
92 continue
93 }
94
95 // Skip org directives and block markers
96 if strings.HasPrefix(line, "#+") || strings.HasPrefix(line, "#+end_") {
97 continue
98 }
99
100 // Collect body lines (skip empty lines before first content)
101 trimmed := strings.TrimSpace(line)
102 if trimmed == "" && len(current.bodyLines) == 0 {
103 continue
104 }
105 current.bodyLines = append(current.bodyLines, line)
106 }
107
108 // Flush last entry
109 if current != nil {
110 if e := current.toEntry(); e != nil {
111 entries = append(entries, *e)
112 }
113 }
114
115 // Filter by since
116 if !since.IsZero() {
117 var filtered []Entry
118 for _, e := range entries {
119 if !e.Date.Before(since) {
120 filtered = append(filtered, e)
121 }
122 }
123 entries = filtered
124 }
125
126 return entries, nil
127}
128
129type tilEntry struct {
130 title string
131 tags []string
132 date time.Time
133 bodyLines []string
134}
135
136// toEntry converts a parsed TIL to a flux Entry.
137// Extracts the first paragraph as the body summary.
138func (t *tilEntry) toEntry() *Entry {
139 if t.title == "" {
140 return nil
141 }
142
143 body := t.extractSummary()
144
145 // Generate a slug-based ID
146 id := "til-" + slugify(t.title)
147
148 return &Entry{
149 ID: id,
150 Kind: KindNote,
151 Title: t.title,
152 URL: "/til.html#" + slugify(t.title),
153 Body: body,
154 Tags: append([]string{"til"}, t.tags...),
155 Date: t.date,
156 Source: "til",
157 }
158}
159
160// extractSummary returns the first paragraph of body content,
161// stripping org markup to plain text.
162func (t *tilEntry) extractSummary() string {
163 var lines []string
164 for _, line := range t.bodyLines {
165 trimmed := strings.TrimSpace(line)
166
167 // Stop at first blank line (end of first paragraph)
168 if trimmed == "" && len(lines) > 0 {
169 break
170 }
171
172 // Stop at org blocks, lists, code
173 if strings.HasPrefix(trimmed, "#+begin_") ||
174 strings.HasPrefix(trimmed, "1.") ||
175 strings.HasPrefix(trimmed, "- ") {
176 break
177 }
178
179 if trimmed != "" {
180 lines = append(lines, cleanOrgMarkup(trimmed))
181 }
182 }
183
184 return strings.Join(lines, " ")
185}
186
187// cleanOrgMarkup strips org-mode formatting to plain text.
188func cleanOrgMarkup(s string) string {
189 s = orgLinkRe.ReplaceAllString(s, "$2")
190 s = orgBoldRe.ReplaceAllString(s, "$1")
191 s = orgItalicRe.ReplaceAllString(s, "$1")
192 s = orgCodeRe.ReplaceAllString(s, "$1")
193 return s
194}
195
196// slugify converts a title to a URL-safe slug.
197// Matches the soupault heading-slugs.lua algorithm: non-alphanumeric → hyphens.
198func slugify(s string) string {
199 s = strings.ToLower(s)
200 s = strings.Map(func(r rune) rune {
201 if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
202 return r
203 }
204 // All non-alphanumeric → hyphen (matches soupault)
205 return '-'
206 }, s)
207 // Collapse multiple dashes
208 for strings.Contains(s, "--") {
209 s = strings.ReplaceAll(s, "--", "-")
210 }
211 s = strings.Trim(s, "-")
212 return s
213}