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 }