main
1package flux
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "strings"
9 "time"
10)
11
12// BookmarkSource parses a bookmarks org file and extracts entries.
13type BookmarkSource struct {
14 File string // path to bookmarks.org
15}
16
17func (b *BookmarkSource) Name() string { return "bookmarks" }
18
19// Fetch parses the org file and returns bookmark entries.
20func (b *BookmarkSource) Fetch(ctx context.Context, since time.Time) ([]Entry, error) {
21 f, err := os.Open(b.File)
22 if err != nil {
23 if os.IsNotExist(err) {
24 return nil, nil
25 }
26 return nil, fmt.Errorf("opening %s: %w", b.File, err)
27 }
28 defer f.Close()
29
30 var entries []Entry
31 var current *bookmarkEntry
32
33 scanner := bufio.NewScanner(f)
34 for scanner.Scan() {
35 line := scanner.Text()
36
37 // Check for heading with org link: * [[url][title]] :tags:
38 if strings.HasPrefix(line, "* ") && !strings.HasPrefix(line, "** ") {
39 // Flush previous
40 if current != nil {
41 if e := current.toEntry(); e != nil {
42 entries = append(entries, *e)
43 }
44 }
45
46 current = parseBookmarkHeading(line)
47 continue
48 }
49
50 if current == nil {
51 continue
52 }
53
54 // Check for date
55 if m := orgDateRe.FindStringSubmatch(line); m != nil && current.date.IsZero() {
56 if d, err := time.Parse("2006-01-02", m[1]); err == nil {
57 current.date = d
58 }
59 continue
60 }
61
62 // Skip org directives
63 if strings.HasPrefix(line, "#+") {
64 continue
65 }
66
67 // Collect body lines
68 trimmed := strings.TrimSpace(line)
69 if trimmed == "" && len(current.bodyLines) == 0 {
70 continue
71 }
72 current.bodyLines = append(current.bodyLines, line)
73 }
74
75 // Flush last
76 if current != nil {
77 if e := current.toEntry(); e != nil {
78 entries = append(entries, *e)
79 }
80 }
81
82 // Filter by since
83 if !since.IsZero() {
84 var filtered []Entry
85 for _, e := range entries {
86 if !e.Date.Before(since) {
87 filtered = append(filtered, e)
88 }
89 }
90 entries = filtered
91 }
92
93 return entries, nil
94}
95
96type bookmarkEntry struct {
97 title string
98 url string
99 tags []string
100 date time.Time
101 bodyLines []string
102}
103
104// parseBookmarkHeading parses: * [[url][title]] :tag1:tag2:
105// Also handles: * [[url][title]] (no tags)
106// Also handles: * Title (no link, no tags)
107func parseBookmarkHeading(line string) *bookmarkEntry {
108 be := &bookmarkEntry{}
109
110 // Strip leading "* "
111 rest := strings.TrimPrefix(line, "* ")
112
113 // Extract tags from end: :tag1:tag2:
114 if m := headingRe.FindStringSubmatch(line); m != nil {
115 rest = strings.TrimSpace(m[1])
116 be.tags = strings.Split(m[2], ":")
117 } else if headingNoTagsRe.MatchString(line) {
118 rest = strings.TrimPrefix(line, "* ")
119 rest = strings.TrimSpace(rest)
120 }
121
122 // Extract org link: [[url][title]]
123 if m := orgLinkRe.FindStringSubmatch(rest); m != nil {
124 be.url = m[1]
125 be.title = m[2]
126 } else {
127 // Plain text heading
128 be.title = cleanOrgMarkup(rest)
129 }
130
131 return be
132}
133
134func (b *bookmarkEntry) toEntry() *Entry {
135 if b.title == "" {
136 return nil
137 }
138
139 body := b.extractSummary()
140 id := "bookmark-" + slugify(b.title)
141
142 return &Entry{
143 ID: id,
144 Kind: KindBookmark,
145 Title: b.title,
146 URL: b.url,
147 Body: body,
148 Tags: b.tags,
149 Date: b.date,
150 Source: "bookmarks",
151 }
152}
153
154func (b *bookmarkEntry) extractSummary() string {
155 var lines []string
156 for _, line := range b.bodyLines {
157 trimmed := strings.TrimSpace(line)
158 if trimmed == "" && len(lines) > 0 {
159 break
160 }
161 if strings.HasPrefix(trimmed, "#+begin_") ||
162 strings.HasPrefix(trimmed, "1.") ||
163 strings.HasPrefix(trimmed, "- ") {
164 break
165 }
166 if trimmed != "" {
167 lines = append(lines, cleanOrgMarkup(trimmed))
168 }
169 }
170 return strings.Join(lines, " ")
171}