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}