Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

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

Log | Files | Refs


bookmarks.go (22639 bytes)

1 package main
2 
3 // The web pages for the bookmarks of an account. Each change goes through
4 // Store.sync, as the file of a device does, so the changes that other
5 // devices made since a page opened stay.
6 
7 import (
8 	_ "embed"
9 	"errors"
10 	"fmt"
11 	"html"
12 	"io"
13 	"io/fs"
14 	"net/http"
15 	"net/url"
16 	"regexp"
17 	"slices"
18 	"strconv"
19 	"strings"
20 	"unicode"
21 	"unicode/utf8"
22 )
23 
24 const (
25 	maxRows   = 1000     // bookmarks on one page
26 	maxForm   = 64 << 10 // bytes in the form of one bookmark
27 	maxImport = 32 << 20 // bytes in an imported file: browsers put icons in it
28 )
29 
30 var (
31 	errNoURL   = errors.New("type the URL of the bookmark")
32 	errHashURL = errors.New("a URL cannot start with #, because bm reads the line as a comment")
33 	// errChanged tells that a page shows a line that the file does not have
34 	// now.
35 	errChanged = errors.New("the bookmark changed on another device")
36 )
37 
38 // searchJS is the live search of the bookmark pages.
39 //
40 //go:embed search.js
41 var searchJS string
42 
43 // sentence gives the text of an error as a sentence for a page.
44 func sentence(err error) string {
45 	return strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "."
46 }
47 
48 // ---- the bookmark file ----
49 
50 // Bookmark is one bookmark line of the file:
51 //
52 //	URL<tab>description<tab>tag tag tag
53 type Bookmark struct {
54 	URL, Desc string
55 	Tags      []string
56 	N         int // the index of the line in the file
57 }
58 
59 // Title gives the text of the link to the bookmark.
60 func (b Bookmark) Title() string {
61 	if strings.TrimSpace(b.Desc) == "" {
62 		return b.URL
63 	}
64 	return b.Desc
65 }
66 
67 // Host gives the host of the URL, to show under the title.
68 func (b Bookmark) Host() string {
69 	u := scheme.ReplaceAllString(b.URL, "")
70 	if i := strings.IndexAny(u, "/?#"); i >= 0 {
71 		u = u[:i]
72 	}
73 	return u
74 }
75 
76 // Link gives the address of the link to the bookmark. Only http and https
77 // links are made, so that a line of the file cannot run script in the page.
78 // An address without a scheme gets https. Other schemes give "": the page
79 // shows them as text.
80 func (b Bookmark) Link() string {
81 	l := strings.ToLower(b.URL)
82 	switch {
83 	case strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "http://"):
84 		return b.URL
85 	case !strings.Contains(b.URL, ":"):
86 		return "https://" + b.URL
87 	}
88 	return ""
89 }
90 
91 // parseLine gives the bookmark of a line, and false for a comment or an
92 // empty line. A line without a tab has the old format "URL description |
93 // tags". Until bm-migrate converts it, its first word is the URL, as in the
94 // add-on.
95 func parseLine(line string) (Bookmark, bool) {
96 	if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") {
97 		return Bookmark{}, false
98 	}
99 	fields := strings.Split(line, "\t")
100 	if len(fields) == 1 {
101 		words := strings.Fields(line)
102 		return Bookmark{URL: words[0], Desc: strings.Join(words[1:], " ")}, true
103 	}
104 	b := Bookmark{URL: strings.TrimSpace(fields[0]), Desc: fields[1]}
105 	if len(fields) > 2 {
106 		b.Tags = strings.FieldsFunc(fields[2], func(r rune) bool { return r == ' ' })
107 	}
108 	return b, b.URL != ""
109 }
110 
111 // parse gives the bookmarks of a file, in the order of the file.
112 func parse(text string) []Bookmark {
113 	var out []Bookmark
114 	for i, l := range lines(text) {
115 		if b, ok := parseLine(l); ok {
116 			b.N = i
117 			out = append(out, b)
118 		}
119 	}
120 	return out
121 }
122 
123 // entry is the text in the fields of a bookmark form.
124 type entry struct{ URL, Desc, Tags string }
125 
126 // line gives the line of the bookmark, as bm and the add-on write it: the URL
127 // without white space, the description without tabs and line breaks, and the
128 // tags with one space between them.
129 func (e entry) line() (string, error) {
130 	u := strings.Join(strings.Fields(e.URL), "")
131 	switch {
132 	case u == "":
133 		return "", errNoURL
134 	case strings.HasPrefix(u, "#"):
135 		return "", errHashURL
136 	}
137 	desc := strings.TrimSpace(strings.NewReplacer("\t", " ", "\r", " ", "\n", " ").Replace(e.Desc))
138 	return u + "\t" + desc + "\t" + strings.Join(strings.Fields(e.Tags), " "), nil
139 }
140 
141 // ---- search ----
142 
143 // score gives the score of query in text, higher for a better match, or -1
144 // when a word of the query does not occur in text. This is the search of the
145 // add-on (lib/fuzzy.js), as in fzf: each word of the query must occur in the
146 // text, with its letters in order but not always together. Case does not
147 // matter.
148 func score(query, text string) int {
149 	hay := strings.ToLower(text)
150 	total := 0
151 	for _, word := range strings.FieldsFunc(strings.ToLower(query), func(r rune) bool { return r == ' ' || r == '\t' }) {
152 		s := scoreWord(word, hay)
153 		if s < 0 {
154 			return -1
155 		}
156 		total += s
157 	}
158 	return total
159 }
160 
161 // scoreWord gives the score of one word in hay. The whole word in one place
162 // scores highest, most at the start of a word.
163 func scoreWord(word, hay string) int {
164 	if at := strings.Index(hay, word); at >= 0 {
165 		s := 100 + utf8.RuneCountInString(word)
166 		if startsWord(hay, at) {
167 			s += 20
168 		}
169 		return s
170 	}
171 	s, end := 0, -1 // end: the byte after the last letter found
172 	for _, c := range word {
173 		from := max(end, 0)
174 		i := strings.IndexRune(hay[from:], c)
175 		if i < 0 {
176 			return -1
177 		}
178 		i += from
179 		s++
180 		if i == end {
181 			s += 5
182 		}
183 		if startsWord(hay, i) {
184 			s += 3
185 		}
186 		_, n := utf8.DecodeRuneInString(hay[i:])
187 		end = i + n
188 	}
189 	return s
190 }
191 
192 // startsWord tells if the letter at byte i of hay starts a word: no letter
193 // or digit is before it.
194 func startsWord(hay string, i int) bool {
195 	if i == 0 {
196 		return true
197 	}
198 	r, _ := utf8.DecodeLastRuneInString(hay[:i])
199 	return !unicode.IsLetter(r) && !unicode.IsNumber(r)
200 }
201 
202 // search gives the bookmarks that match query, best first. Bookmarks with the
203 // same score keep their order.
204 func search(bs []Bookmark, query string) []Bookmark {
205 	type hit struct {
206 		b     Bookmark
207 		score int
208 	}
209 	var hits []hit
210 	for _, b := range bs {
211 		if s := score(query, b.Desc+" "+strings.Join(b.Tags, " ")+" "+b.URL); s >= 0 {
212 			hits = append(hits, hit{b, s})
213 		}
214 	}
215 	slices.SortStableFunc(hits, func(x, y hit) int { return y.score - x.score })
216 	out := make([]Bookmark, len(hits))
217 	for i, h := range hits {
218 		out[i] = h.b
219 	}
220 	return out
221 }
222 
223 // orders are the sort orders of the list page. bm also has "used", but the
224 // counts of use stay on each device.
225 var orders = map[string]func(x, y Bookmark) int{
226 	"recent": func(x, y Bookmark) int { return y.N - x.N },
227 	"url":    func(x, y Bookmark) int { return strings.Compare(norm(x.URL), norm(y.URL)) },
228 	"desc": func(x, y Bookmark) int {
229 		return strings.Compare(strings.ToLower(x.Title()), strings.ToLower(y.Title()))
230 	},
231 	"tag": func(x, y Bookmark) int { return strings.Compare(strings.Join(x.Tags, " "), strings.Join(y.Tags, " ")) },
232 }
233 
234 // target gives the address for text that no bookmark matches, as bm does: one
235 // word with "://" or a dot is an address, and other text is a web search.
236 func target(text string) string {
237 	t := strings.TrimSpace(text)
238 	if !strings.Contains(t, " ") {
239 		if strings.Contains(t, "://") {
240 			return t
241 		}
242 		if strings.Contains(t, ".") {
243 			return "https://" + t
244 		}
245 	}
246 	return "https://duckduckgo.com/?q=" + url.QueryEscape(t)
247 }
248 
249 // ---- import ----
250 
251 var (
252 	notTag   = regexp.MustCompile(`[^a-z0-9]+`)
253 	imported = regexp.MustCompile(`^(https?|ftp|file)://`)
254 )
255 
256 // importRows gives the bookmark lines of an imported file: the Netscape HTML
257 // that browsers export, or a file of bookmark lines.
258 func importRows(text string) []string {
259 	text = strings.TrimPrefix(text, string(rune(0xFEFF)))
260 	if strings.HasPrefix(strings.TrimSpace(text), "<") {
261 		return fromHTML(text)
262 	}
263 	return lines(text)
264 }
265 
266 // fromHTML gives the bookmark lines of the Netscape HTML that browsers
267 // export, as bm-import does: <H3> opens a folder, </DL> closes it, and the
268 // folders of a bookmark become its tags. Only http, https, ftp and file URLs
269 // stay.
270 func fromHTML(text string) []string {
271 	var folders, out []string
272 	for _, line := range lines(text) {
273 		upper := upperASCII(line)
274 		if at := strings.Index(upper, "<H3"); at >= 0 {
275 			name := line[at:]
276 			name = name[strings.IndexByte(name, '>')+1:]
277 			if end := strings.Index(upperASCII(name), "</H3"); end >= 0 {
278 				name = name[:end]
279 			}
280 			folders = append(folders, folderTag(htmlText(name)))
281 		}
282 		if at := strings.Index(upper, `HREF="`); at >= 0 {
283 			u, rest, ok := strings.Cut(line[at+len(`HREF="`):], `"`)
284 			name := rest[strings.IndexByte(rest, '>')+1:]
285 			if end := strings.Index(upperASCII(name), "</A"); end >= 0 {
286 				name = name[:end]
287 			}
288 			var tags []string
289 			for _, f := range folders {
290 				if f != "" && !slices.Contains(tags, f) {
291 					tags = append(tags, f)
292 				}
293 			}
294 			if u = htmlText(u); ok && imported.MatchString(u) {
295 				out = append(out, u+"\t"+htmlText(name)+"\t"+strings.Join(tags, " "))
296 			}
297 		}
298 		if strings.Contains(upper, "</DL>") && len(folders) > 0 {
299 			folders = folders[:len(folders)-1]
300 		}
301 	}
302 	return out
303 }
304 
305 // upperASCII gives s with the letters a to z in upper case. Each byte keeps
306 // its index, so an index in the result is an index in s.
307 func upperASCII(s string) string {
308 	b := []byte(s)
309 	for i, c := range b {
310 		if 'a' <= c && c <= 'z' {
311 			b[i] = c - 'a' + 'A'
312 		}
313 	}
314 	return string(b)
315 }
316 
317 // htmlText gives the text of HTML without its entities, with one space in
318 // place of each run of white space.
319 func htmlText(s string) string {
320 	return strings.Join(strings.Fields(html.UnescapeString(s)), " ")
321 }
322 
323 // folderTag gives the tag of a folder name, as bm-import makes it: "Dev
324 // Tools" is dev-tools.
325 func folderTag(name string) string {
326 	return strings.Trim(notTag.ReplaceAllString(strings.ToLower(name), "-"), "-")
327 }
328 
329 // mergeRows gives the rows whose URL is not in text and not in a row before
330 // them, as bm --merge does, with the number of rows that it skipped for that
331 // reason. Empty rows and comments go.
332 func mergeRows(text string, rows []string) ([]string, int) {
333 	seen := map[string]bool{}
334 	for _, l := range lines(text) {
335 		seen[key(l)] = true
336 	}
337 	var add []string
338 	skipped := 0
339 	for _, row := range rows {
340 		k := key(row)
341 		switch {
342 		case k == "":
343 		case seen[k]:
344 			skipped++
345 		default:
346 			seen[k] = true
347 			f := append(strings.SplitN(row, "\t", 4), "", "")
348 			add = append(add, f[0]+"\t"+f[1]+"\t"+f[2])
349 		}
350 	}
351 	return add, skipped
352 }
353 
354 // ---- pages ----
355 
356 // bookmarksPage is the data of the pages for the bookmarks of an account.
357 type bookmarksPage struct {
358 	page
359 	Query, Tag, Sort, Done string
360 	Open                   string     // the form of the list page to open: "add" or "import"
361 	Version                string     // the version of the file that the page shows
362 	Rows                   []Bookmark // the bookmarks to show, at most maxRows
363 	Found, Total           int        // the bookmarks that match, and all bookmarks
364 	Tags                   []tagCount
365 	Target                 string // where the query goes when no bookmark matches
366 	Form                   entry  // the fields of the form of a bookmark
367 	N                      int    // the line of the bookmark on the edit page
368 	Text                   string // the whole file, for the file page
369 }
370 
371 type tagCount struct {
372 	Name string
373 	N    int
374 }
375 
376 // done gives the message of the list page after a change.
377 var done = map[string]string{
378 	"added":   "Bookmark added.",
379 	"saved":   "Bookmark saved.",
380 	"deleted": "Bookmark deleted.",
381 	"file":    "File saved.",
382 	"merged":  "File saved, and merged with the changes of your other devices.",
383 	"changed": "That bookmark changed on another device. Look at it again.",
384 }
385 
386 // owner gives the signed-in account when it can use its bookmarks on this
387 // site: as for sync, its email is confirmed and sync is not paused. Else
388 // owner sends the browser to sign in, or to the account page, which tells
389 // why, and gives nil.
390 func (s *server) owner(w http.ResponseWriter, r *http.Request) *Account {
391 	u := s.user(r)
392 	if u == nil {
393 		http.Redirect(w, r, "/login", http.StatusSeeOther)
394 		return nil
395 	}
396 	if a := s.store.view(u); !s.verified(a) || !s.active(a) {
397 		http.Redirect(w, r, "/account", http.StatusSeeOther)
398 		return nil
399 	}
400 	return u
401 }
402 
403 // listing gives the list page for the bookmarks in text that match the query
404 // and the tag, in the order.
405 func (s *server) listing(text, version, query, tag, order string) bookmarksPage {
406 	p := bookmarksPage{page: s.page("Your bookmarks"), Query: query, Tag: tag, Version: version}
407 	all := parse(text)
408 	count := map[string]int{}
409 	var rows []Bookmark
410 	for _, b := range all {
411 		for _, t := range b.Tags {
412 			count[t]++
413 		}
414 		if tag == "" || slices.Contains(b.Tags, tag) {
415 			rows = append(rows, b)
416 		}
417 	}
418 	for name, n := range count {
419 		p.Tags = append(p.Tags, tagCount{name, n})
420 	}
421 	slices.SortFunc(p.Tags, func(x, y tagCount) int { return strings.Compare(x.Name, y.Name) })
422 	if orders[order] == nil {
423 		order = "recent"
424 	}
425 	p.Sort = order
426 	slices.SortStableFunc(rows, orders[order])
427 	if strings.TrimSpace(query) != "" {
428 		if rows = search(rows, query); len(rows) == 0 {
429 			p.Target = target(query)
430 		}
431 	}
432 	p.Total, p.Found = len(all), len(rows)
433 	p.Rows = rows[:min(len(rows), maxRows)]
434 	return p
435 }
436 
437 // bookmarks shows the bookmarks that match the query ?q= and the tag ?tag=,
438 // in the order ?sort=.
439 func (s *server) bookmarks(w http.ResponseWriter, r *http.Request) {
440 	u := s.owner(w, r)
441 	if u == nil {
442 		return
443 	}
444 	text, version, err := s.store.read(u, "")
445 	if err != nil {
446 		s.fail(w, err)
447 		return
448 	}
449 	q := r.URL.Query()
450 	p := s.listing(text, version, q.Get("q"), q.Get("tag"), q.Get("sort"))
451 	p.Done = done[q.Get("done")]
452 	if q.Get("done") == "imported" {
453 		added, _ := strconv.Atoi(q.Get("added"))
454 		known, _ := strconv.Atoi(q.Get("known"))
455 		p.Done = fmt.Sprintf("Bookmarks imported: %d. Skipped because they are bookmarks already: %d.", added, known)
456 	}
457 	s.render(w, http.StatusOK, "bookmarks", p)
458 }
459 
460 // refuse shows the list page again, with the problem of its add or import
461 // form.
462 func (s *server) refuse(w http.ResponseWriter, u *Account, status int, form, problem string, e entry) {
463 	text, version, err := s.store.read(u, "")
464 	if err != nil {
465 		s.fail(w, err)
466 		return
467 	}
468 	p := s.listing(text, version, "", "", "")
469 	p.Error, p.Open, p.Form = problem, form, e
470 	s.render(w, status, "bookmarks", p)
471 }
472 
473 // addBookmark adds a bookmark at the end of the file, where bm adds them. As
474 // bm does, it refuses a URL that is a bookmark already.
475 func (s *server) addBookmark(w http.ResponseWriter, r *http.Request) {
476 	if !s.sameSite(w, r) {
477 		return
478 	}
479 	u := s.owner(w, r)
480 	if u == nil {
481 		return
482 	}
483 	r.Body = http.MaxBytesReader(w, r.Body, maxForm)
484 	e := entry{URL: r.PostFormValue("url"), Desc: r.PostFormValue("desc"), Tags: r.PostFormValue("tags")}
485 	line, err := e.line()
486 	if err != nil {
487 		s.refuse(w, u, http.StatusBadRequest, "add", sentence(err), e)
488 		return
489 	}
490 	text, version, err := s.store.read(u, "")
491 	if err != nil {
492 		s.fail(w, err)
493 		return
494 	}
495 	for _, b := range parse(text) {
496 		if norm(b.URL) == key(line) {
497 			s.refuse(w, u, http.StatusConflict, "add", "This URL is a bookmark already: "+b.Title()+".", e)
498 			return
499 		}
500 	}
501 	if _, _, err := s.store.sync(u, version, join(append(lines(text), line))); err != nil {
502 		s.fail(w, err)
503 		return
504 	}
505 	http.Redirect(w, r, "/bookmarks?done=added", http.StatusSeeOther)
506 }
507 
508 // lineAt gives line n of version v of the file of the account, and n as a
509 // number. It gives errChanged when the version is gone, or when the line is
510 // not a bookmark.
511 func (s *server) lineAt(u *Account, v, n string) (string, int, error) {
512 	i, err := strconv.Atoi(n)
513 	if err != nil || !hashName.MatchString(v) {
514 		return "", 0, errChanged
515 	}
516 	text, _, err := s.store.read(u, v)
517 	if errors.Is(err, fs.ErrNotExist) {
518 		return "", 0, errChanged
519 	} else if err != nil {
520 		return "", 0, err
521 	}
522 	ls := lines(text)
523 	if i < 0 || i >= len(ls) {
524 		return "", 0, errChanged
525 	}
526 	if _, ok := parseLine(ls[i]); !ok {
527 		return "", 0, errChanged
528 	}
529 	return ls[i], i, nil
530 }
531 
532 // editBookmark shows the form for line ?n= of version ?v= of the file.
533 func (s *server) editBookmark(w http.ResponseWriter, r *http.Request) {
534 	u := s.owner(w, r)
535 	if u == nil {
536 		return
537 	}
538 	q := r.URL.Query()
539 	line, n, err := s.lineAt(u, q.Get("v"), q.Get("n"))
540 	if errors.Is(err, errChanged) {
541 		http.Redirect(w, r, "/bookmarks?done=changed", http.StatusSeeOther)
542 		return
543 	} else if err != nil {
544 		s.fail(w, err)
545 		return
546 	}
547 	b, _ := parseLine(line)
548 	s.render(w, http.StatusOK, "edit", bookmarksPage{page: s.page("Edit a bookmark"), Version: q.Get("v"), N: n,
549 		Form: entry{URL: b.URL, Desc: b.Desc, Tags: strings.Join(b.Tags, " ")}})
550 }
551 
552 // saveBookmark changes or deletes the bookmark of the edit page in the
553 // current file. When the current file does not have the line of the page,
554 // another device changed it, and nothing changes.
555 func (s *server) saveBookmark(w http.ResponseWriter, r *http.Request) {
556 	if !s.sameSite(w, r) {
557 		return
558 	}
559 	u := s.owner(w, r)
560 	if u == nil {
561 		return
562 	}
563 	r.Body = http.MaxBytesReader(w, r.Body, maxForm)
564 	v := r.PostFormValue("v")
565 	old, n, err := s.lineAt(u, v, r.PostFormValue("n"))
566 	if errors.Is(err, errChanged) {
567 		http.Redirect(w, r, "/bookmarks?done=changed", http.StatusSeeOther)
568 		return
569 	} else if err != nil {
570 		s.fail(w, err)
571 		return
572 	}
573 	e := entry{URL: r.PostFormValue("url"), Desc: r.PostFormValue("desc"), Tags: r.PostFormValue("tags")}
574 	problem := func(status int, text string) {
575 		p := bookmarksPage{page: s.page("Edit a bookmark"), Version: v, N: n, Form: e}
576 		p.Error = text
577 		s.render(w, status, "edit", p)
578 	}
579 	line, msg := "", "deleted"
580 	if r.PostFormValue("do") != "delete" {
581 		if line, err = e.line(); err != nil {
582 			problem(http.StatusBadRequest, sentence(err))
583 			return
584 		}
585 		msg = "saved"
586 	}
587 	text, version, err := s.store.read(u, "")
588 	if err != nil {
589 		s.fail(w, err)
590 		return
591 	}
592 	if b, _ := parseLine(old); line != "" && key(line) != norm(b.URL) {
593 		for _, other := range parse(text) {
594 			if norm(other.URL) == key(line) {
595 				problem(http.StatusConflict, "Another bookmark has this URL: "+other.Title()+".")
596 				return
597 			}
598 		}
599 	}
600 	var out []string
601 	found := false
602 	for _, l := range lines(text) {
603 		if l != old {
604 			out = append(out, l)
605 		} else if !found {
606 			found = true
607 			if line != "" {
608 				out = append(out, line)
609 			}
610 		}
611 	}
612 	if !found {
613 		http.Redirect(w, r, "/bookmarks?done=changed", http.StatusSeeOther)
614 		return
615 	}
616 	if _, _, err := s.store.sync(u, version, join(out)); err != nil {
617 		s.fail(w, err)
618 		return
619 	}
620 	http.Redirect(w, r, "/bookmarks?done="+msg, http.StatusSeeOther)
621 }
622 
623 // bookmarkFile shows the whole file in a text box, as bm -e opens it in an
624 // editor.
625 func (s *server) bookmarkFile(w http.ResponseWriter, r *http.Request) {
626 	u := s.owner(w, r)
627 	if u == nil {
628 		return
629 	}
630 	text, version, err := s.store.read(u, "")
631 	if err != nil {
632 		s.fail(w, err)
633 		return
634 	}
635 	s.render(w, http.StatusOK, "file", bookmarksPage{page: s.page("Edit the file"), Version: version, Text: text})
636 }
637 
638 // saveFile takes the whole file of the file page. As for the file of a
639 // device, the server merges the changes since the version that the page
640 // showed.
641 func (s *server) saveFile(w http.ResponseWriter, r *http.Request) {
642 	if !s.sameSite(w, r) {
643 		return
644 	}
645 	u := s.owner(w, r)
646 	if u == nil {
647 		return
648 	}
649 	// A form sends a tab as %09, so the form can be three times as large as
650 	// the file.
651 	r.Body = http.MaxBytesReader(w, r.Body, 3*maxFile+maxForm)
652 	if err := r.ParseForm(); err != nil {
653 		var tooBig *http.MaxBytesError
654 		if errors.As(err, &tooBig) {
655 			http.Error(w, "the bookmark file is too large", http.StatusRequestEntityTooLarge)
656 		} else {
657 			http.Error(w, "cannot read the form", http.StatusBadRequest)
658 		}
659 		return
660 	}
661 	base, text := r.PostFormValue("base"), join(lines(r.PostFormValue("text")))
662 	if base != "" && !hashName.MatchString(base) {
663 		http.Error(w, "base is not a version name", http.StatusBadRequest)
664 		return
665 	}
666 	if len(text) > maxFile {
667 		p := bookmarksPage{page: s.page("Edit the file"), Version: base, Text: text}
668 		p.Error = "The file is larger than 4 MB. Nothing changed."
669 		s.render(w, http.StatusRequestEntityTooLarge, "file", p)
670 		return
671 	}
672 	merged, _, err := s.store.sync(u, base, text)
673 	if err != nil {
674 		s.fail(w, err)
675 		return
676 	}
677 	msg := "file"
678 	if merged != text {
679 		msg = "merged"
680 	}
681 	http.Redirect(w, r, "/bookmarks?done="+msg, http.StatusSeeOther)
682 }
683 
684 // importFile adds the bookmarks of a file, as bm-import and bm --merge do. A
685 // URL that is a bookmark already is skipped.
686 func (s *server) importFile(w http.ResponseWriter, r *http.Request) {
687 	if !s.sameSite(w, r) {
688 		return
689 	}
690 	u := s.owner(w, r)
691 	if u == nil {
692 		return
693 	}
694 	r.Body = http.MaxBytesReader(w, r.Body, maxImport)
695 	f, _, err := r.FormFile("file")
696 	if err != nil {
697 		s.refuse(w, u, http.StatusBadRequest, "import", "Choose a file of 32 MB or less.", entry{})
698 		return
699 	}
700 	defer f.Close()
701 	b, err := io.ReadAll(f)
702 	if err != nil {
703 		s.fail(w, err)
704 		return
705 	}
706 	text, version, err := s.store.read(u, "")
707 	if err != nil {
708 		s.fail(w, err)
709 		return
710 	}
711 	add, skipped := mergeRows(text, importRows(string(b)))
712 	merged := join(append(lines(text), add...))
713 	if len(merged) > maxFile {
714 		s.refuse(w, u, http.StatusRequestEntityTooLarge, "import",
715 			"With these bookmarks, the file would be larger than 4 MB. Nothing changed.", entry{})
716 		return
717 	}
718 	if len(add) > 0 {
719 		if _, _, err := s.store.sync(u, version, merged); err != nil {
720 			s.fail(w, err)
721 			return
722 		}
723 	}
724 	http.Redirect(w, r, fmt.Sprintf("/bookmarks?done=imported&added=%d&known=%d", len(add), skipped), http.StatusSeeOther)
725 }
726 
727 // download gives the current file. It works also when sync is paused,
728 // because the bookmarks belong to their owner.
729 func (s *server) download(w http.ResponseWriter, r *http.Request) {
730 	u := s.user(r)
731 	if u == nil {
732 		http.Redirect(w, r, "/login", http.StatusSeeOther)
733 		return
734 	}
735 	text, _, err := s.store.read(u, "")
736 	if err != nil {
737 		s.fail(w, err)
738 		return
739 	}
740 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
741 	w.Header().Set("Content-Disposition", `attachment; filename="bookmarks.txt"`)
742 	io.WriteString(w, text)
743 }
744 
745 // script gives the live search of the bookmark pages.
746 func (s *server) script(w http.ResponseWriter, r *http.Request) {
747 	w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
748 	io.WriteString(w, searchJS)
749 }
750 
751 // openSearch describes the search of the bookmarks in the OpenSearch 1.1
752 // format, so that a browser can search them from its address bar.
753 func (s *server) openSearch(w http.ResponseWriter, r *http.Request) {
754 	w.Header().Set("Content-Type", "application/opensearchdescription+xml")
755 	fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
756 <OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
757 <ShortName>sbm</ShortName>
758 <Description>Search your sbm bookmarks</Description>
759 <InputEncoding>UTF-8</InputEncoding>
760 <Url type="text/html" method="get" template="%s/bookmarks?q={searchTerms}"/>
761 </OpenSearchDescription>
762 `, html.EscapeString(s.site))
763 }