Recently Written · git

sbm-webpublish

Publish an sbm bookmark file as a list of links on a web site

git clone https://github.com/equwal/sbm-webpublish

Log | Files | Refs


links.go (10755 bytes)

1 package main
2 
3 // The links file is an sbm bookmark file. Each line is one link:
4 //
5 //	URL<tab>description<tab>tag tag tag<tab>feed
6 //
7 // The feed is the address of the RSS or Atom feed of the site. It is
8 // optional: a line without a feed has only the three fields of bm. bm reads
9 // only the first three fields, and it keeps the full line of each other link
10 // when it deletes a link. bm --merge does not copy the feed.
11 //
12 // The parse, import and duplicate rules are the rules of bm and sbm-sync,
13 // so a file from bm works here, and the file here works in bm.
14 //
15 // A feed needs the time of each link, and a bm line has no time. So the
16 // admin page writes a date line above the links that it adds:
17 //
18 //	# 2026-09-25T08:00:00Z
19 //
20 // bm ignores each line that starts with "#", so the file stays an sbm file.
21 
22 import (
23 	"html"
24 	"regexp"
25 	"slices"
26 	"strings"
27 	"time"
28 )
29 
30 var (
31 	scheme   = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`)
32 	notTag   = regexp.MustCompile(`[^a-z0-9]+`)
33 	imported = regexp.MustCompile(`^(https?|ftp|file)://`)
34 )
35 
36 // Link is one link of the file. Feed is the address of the feed of the site,
37 // or "". Added is the time of the last date line above the link, or zero
38 // when there is no date line above it.
39 type Link struct {
40 	URL, Desc, Feed string
41 	Tags            []string
42 	Added           time.Time
43 }
44 
45 // Title gives the text of the link.
46 func (l Link) Title() string {
47 	if strings.TrimSpace(l.Desc) == "" {
48 		return l.URL
49 	}
50 	return l.Desc
51 }
52 
53 // Host gives the host of the URL.
54 func (l Link) Host() string {
55 	u := scheme.ReplaceAllString(l.URL, "")
56 	if i := strings.IndexAny(u, "/?#"); i >= 0 {
57 		u = u[:i]
58 	}
59 	return u
60 }
61 
62 // Address gives the URL without its scheme and without a slash at the end.
63 // Pages show it after the link, because many descriptions do not name the
64 // page, and many links have the same host.
65 func (l Link) Address() string {
66 	return strings.TrimSuffix(scheme.ReplaceAllString(l.URL, ""), "/")
67 }
68 
69 // Href gives the address for the link on the page (see href).
70 func (l Link) Href() string {
71 	return href(l.URL)
72 }
73 
74 // FeedHref gives the address for the feed of the link on the page (see href).
75 func (l Link) FeedHref() string {
76 	return href(l.Feed)
77 }
78 
79 // href gives the address u for a page. Only http and https addresses come
80 // out, so that a line of the file cannot run script in the page. An address
81 // without a scheme gets https. Other schemes, and "", give "".
82 func href(u string) string {
83 	s := strings.ToLower(u)
84 	switch {
85 	case strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://"):
86 		return u
87 	case u != "" && !strings.Contains(u, ":"):
88 		return "https://" + u
89 	}
90 	return ""
91 }
92 
93 // lines splits a file into lines without their ends. CRLF counts as LF.
94 func lines(text string) []string {
95 	text = strings.ReplaceAll(text, "\r\n", "\n")
96 	if text == "" {
97 		return nil
98 	}
99 	return strings.Split(strings.TrimSuffix(text, "\n"), "\n")
100 }
101 
102 // join is the reverse of lines: each line ends with LF.
103 func join(ls []string) string {
104 	if len(ls) == 0 {
105 		return ""
106 	}
107 	return strings.Join(ls, "\n") + "\n"
108 }
109 
110 // norm gives the form of a URL that bm uses to find duplicates: two URLs are
111 // the same link when they differ only in scheme, a leading "www.", trailing
112 // slashes or the case of the host.
113 func norm(u string) string {
114 	u = scheme.ReplaceAllString(u, "")
115 	host, rest := u, ""
116 	if i := strings.IndexByte(u, '/'); i >= 0 {
117 		host, rest = u[:i], u[i:]
118 	}
119 	host = strings.TrimPrefix(strings.ToLower(host), "www.")
120 	return host + strings.TrimRight(rest, "/")
121 }
122 
123 // key gives the normal form of the URL of a line. Comments and empty lines
124 // give "".
125 func key(line string) string {
126 	if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") {
127 		return ""
128 	}
129 	u, _, _ := strings.Cut(line, "\t")
130 	if !strings.Contains(line, "\t") {
131 		u = strings.Fields(line)[0]
132 	}
133 	return norm(strings.TrimSpace(u))
134 }
135 
136 // parseLine gives the link of a line, and false for a comment or an empty
137 // line. A line without a tab has the old format "URL description". Its
138 // first word is the URL.
139 func parseLine(line string) (Link, bool) {
140 	if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") {
141 		return Link{}, false
142 	}
143 	fields := strings.Split(line, "\t")
144 	if len(fields) == 1 {
145 		words := strings.Fields(line)
146 		return Link{URL: words[0], Desc: strings.Join(words[1:], " ")}, true
147 	}
148 	l := Link{URL: strings.TrimSpace(fields[0]), Desc: fields[1]}
149 	if len(fields) > 2 {
150 		l.Tags = strings.Fields(fields[2])
151 	}
152 	if len(fields) > 3 {
153 		l.Feed = strings.TrimSpace(fields[3])
154 	}
155 	return l, l.URL != ""
156 }
157 
158 // dateLine gives the date line of t.
159 func dateLine(t time.Time) string {
160 	return "# " + t.UTC().Format(time.RFC3339)
161 }
162 
163 // dateOf gives the time of a date line, and false for all other lines.
164 func dateOf(line string) (time.Time, bool) {
165 	s, ok := strings.CutPrefix(line, "#")
166 	if !ok {
167 		return time.Time{}, false
168 	}
169 	t, err := time.Parse(time.RFC3339, strings.TrimSpace(s))
170 	return t, err == nil
171 }
172 
173 // parse gives the links of a file, in the order of the file.
174 func parse(text string) []Link {
175 	var out []Link
176 	var added time.Time
177 	for _, s := range lines(text) {
178 		if t, ok := dateOf(s); ok {
179 			added = t
180 		} else if l, ok := parseLine(s); ok {
181 			l.Added = added
182 			out = append(out, l)
183 		}
184 	}
185 	return out
186 }
187 
188 // line gives the file line of a link, as bm writes it: the URL without
189 // white space, the description without tabs and line breaks, and the tags
190 // with one space between them. A feed, without white space, is a fourth
191 // field. It gives "" when the URL is empty or starts with "#".
192 func line(url, desc, tags, feed string) string {
193 	u := squeeze(url)
194 	if u == "" || strings.HasPrefix(u, "#") {
195 		return ""
196 	}
197 	d := strings.TrimSpace(strings.NewReplacer("\t", " ", "\r", " ", "\n", " ").Replace(desc))
198 	s := u + "\t" + d + "\t" + strings.Join(strings.Fields(tags), " ")
199 	if f := squeeze(feed); f != "" {
200 		s += "\t" + f
201 	}
202 	return s
203 }
204 
205 // squeeze gives s without white space.
206 func squeeze(s string) string {
207 	return strings.Join(strings.Fields(s), "")
208 }
209 
210 // fields gives the URL, description, tags and feed of a line. A line without
211 // a tab has the old format "URL description".
212 func fields(row string) []string {
213 	if !strings.Contains(row, "\t") {
214 		words := strings.Fields(row)
215 		return []string{words[0], strings.Join(words[1:], " "), "", ""}
216 	}
217 	return append(strings.SplitN(row, "\t", 5), "", "", "")[:4]
218 }
219 
220 // importRows gives the link lines of an imported file: the Netscape HTML
221 // that browsers export, or an sbm file.
222 func importRows(text string) []string {
223 	text = strings.TrimPrefix(text, string(rune(0xFEFF)))
224 	if strings.HasPrefix(strings.TrimSpace(text), "<") {
225 		return fromHTML(text)
226 	}
227 	return lines(text)
228 }
229 
230 // fromHTML gives the link lines of the Netscape HTML that browsers export,
231 // as bm-import does: <H3> opens a folder, </DL> closes it, and the folders
232 // of a link become its tags. Only http, https, ftp and file URLs stay.
233 func fromHTML(text string) []string {
234 	var folders, out []string
235 	for _, s := range lines(text) {
236 		upper := upperASCII(s)
237 		if at := strings.Index(upper, "<H3"); at >= 0 {
238 			name := s[at:]
239 			name = name[strings.IndexByte(name, '>')+1:]
240 			if end := strings.Index(upperASCII(name), "</H3"); end >= 0 {
241 				name = name[:end]
242 			}
243 			folders = append(folders, folderTag(htmlText(name)))
244 		}
245 		if at := strings.Index(upper, `HREF="`); at >= 0 {
246 			u, rest, ok := strings.Cut(s[at+len(`HREF="`):], `"`)
247 			name := rest[strings.IndexByte(rest, '>')+1:]
248 			if end := strings.Index(upperASCII(name), "</A"); end >= 0 {
249 				name = name[:end]
250 			}
251 			var tags []string
252 			for _, f := range folders {
253 				if f != "" && !slices.Contains(tags, f) {
254 					tags = append(tags, f)
255 				}
256 			}
257 			if u = htmlText(u); ok && imported.MatchString(u) {
258 				out = append(out, u+"\t"+htmlText(name)+"\t"+strings.Join(tags, " "))
259 			}
260 		}
261 		if strings.Contains(upper, "</DL>") && len(folders) > 0 {
262 			folders = folders[:len(folders)-1]
263 		}
264 	}
265 	return out
266 }
267 
268 // upperASCII gives s with the letters a to z in upper case. Each byte keeps
269 // its index, so an index in the result is an index in s.
270 func upperASCII(s string) string {
271 	b := []byte(s)
272 	for i, c := range b {
273 		if 'a' <= c && c <= 'z' {
274 			b[i] = c - 'a' + 'A'
275 		}
276 	}
277 	return string(b)
278 }
279 
280 // htmlText gives the text of HTML without its entities, with one space in
281 // place of each run of white space.
282 func htmlText(s string) string {
283 	return strings.Join(strings.Fields(html.UnescapeString(s)), " ")
284 }
285 
286 // folderTag gives the tag of a folder name: "Dev Tools" is dev-tools.
287 func folderTag(name string) string {
288 	return strings.Trim(notTag.ReplaceAllString(strings.ToLower(name), "-"), "-")
289 }
290 
291 // merge adds to text the rows whose URL is not in text and not in a row
292 // before them, as bm --merge does. A row whose URL is a link already gives
293 // its feed to that link, when the link has no feed. merge gives the new text,
294 // the number of rows that it added, the number that it skipped as
295 // duplicates, and the number of links that got a feed.
296 func merge(text string, rows []string) (string, int, int, int) {
297 	ls := lines(text)
298 	at := map[string]int{}
299 	for i, s := range ls {
300 		if k := key(s); k != "" {
301 			if _, ok := at[k]; !ok {
302 				at[k] = i
303 			}
304 		}
305 	}
306 	added, skipped, feeds := 0, 0, 0
307 	for _, row := range rows {
308 		k := key(row)
309 		if k == "" {
310 			continue
311 		}
312 		f := fields(row)
313 		i, ok := at[k]
314 		if !ok {
315 			at[k] = len(ls)
316 			ls = append(ls, line(f[0], f[1], f[2], f[3]))
317 			added++
318 			continue
319 		}
320 		skipped++
321 		if l, _ := parseLine(ls[i]); l.Feed == "" && squeeze(f[3]) != "" {
322 			ls[i] = line(l.URL, l.Desc, strings.Join(l.Tags, " "), f[3])
323 			feeds++
324 		}
325 	}
326 	return join(ls), added, skipped, feeds
327 }
328 
329 // addDated adds rows to text as merge does, below a date line of t. It
330 // writes the date line only when it adds a row.
331 func addDated(text string, rows []string, t time.Time) (string, int, int, int) {
332 	out, added, skipped, feeds := merge(join(append(lines(text), dateLine(t))), rows)
333 	switch {
334 	case added > 0:
335 		return out, added, skipped, feeds
336 	case feeds > 0:
337 		out, _, _, _ = merge(text, rows)
338 		return out, 0, skipped, feeds
339 	}
340 	return text, 0, skipped, 0
341 }
342 
343 // setFeed gives text with feed as the feed of the links whose URL has the
344 // normal form k. An empty feed removes the feed.
345 func setFeed(text, k, feed string) string {
346 	ls := lines(text)
347 	for i, s := range ls {
348 		if l, ok := parseLine(s); ok && k != "" && key(s) == k {
349 			ls[i] = line(l.URL, l.Desc, strings.Join(l.Tags, " "), feed)
350 		}
351 	}
352 	return join(ls)
353 }
354 
355 // remove gives text without the lines whose URL has the normal form k.
356 func remove(text, k string) string {
357 	var out []string
358 	for _, s := range lines(text) {
359 		if k == "" || key(s) != k {
360 			out = append(out, s)
361 		}
362 	}
363 	return join(out)
364 }