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}