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 }