Recently Written · git

sbm-extension

sbm bookmarks for Firefox and Chrome: fuzzy search, add the page, sync with bm and the sbm app

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

Log | Files | Refs


background.js (5839 bytes)

1 // The background of the add-on. It owns the bookmark file, which it keeps in
2 // storage.local as the text of an sbm file, and it syncs that file with an
3 // sbm-sync server (https://github.com/equwal/sbm-sync). The popup and the
4 // options page ask it through messages, so all changes happen here, one at a
5 // time.
6 "use strict";
7 
8 // Chrome runs this file as a service worker; Firefox loads the libraries
9 // through the manifest.
10 if (typeof importScripts === "function") importScripts("lib/tsv.js", "lib/fuzzy.js");
11 
12 const api = globalThis.browser ?? globalThis.chrome;
13 const DEFAULT_SERVER = "https://sbm.subread.space";
14 const SYNC_MINUTES = 30;
15 
16 // One change at a time: each job starts when the one before it ends.
17 let queue = Promise.resolve();
18 function serial(job) {
19   const run = queue.then(job);
20   queue = run.catch(() => {});
21   return run;
22 }
23 
24 async function state() {
25   const s = await api.storage.local.get(["text", "version", "server", "token", "email", "synced", "error"]);
26   return { text: "", version: "", server: DEFAULT_SERVER, ...s };
27 }
28 
29 /**
30  * Send the file with the name of the version that the server gave last
31  * time. The server merges, and the merged file comes back.
32  */
33 async function syncNow() {
34   const s = await state();
35   if (!s.token) return s;
36   let r;
37   try {
38     r = await fetch(s.server + "/api/sync?base=" + encodeURIComponent(s.version), {
39       method: "POST",
40       headers: { Authorization: "Bearer " + s.token, "Content-Type": "text/plain; charset=utf-8" },
41       body: s.text,
42     });
43   } catch (e) {
44     await api.storage.local.set({ error: "Cannot reach " + new URL(s.server).host + "." });
45     return state();
46   }
47   const body = await r.text();
48   if (r.status === 401) {
49     await api.storage.local.remove(["token", "version"]);
50     await api.storage.local.set({ error: "Sign in again: see the options." });
51   } else if (!r.ok) {
52     await api.storage.local.set({ error: body.trim() || "The server answered " + r.status + "." });
53   } else {
54     const version = r.headers.get("Sbm-Version");
55     if (!version) throw new Error("The server gave no version.");
56     await api.storage.local.set({ text: body, version, synced: Date.now(), error: "" });
57   }
58   return state();
59 }
60 
61 async function signIn(server, email, password) {
62   server = server.trim().replace(/\/+$/, "");
63   if (!server.includes("://")) server = "https://" + server;
64   const r = await fetch(server + "/api/login", {
65     method: "POST",
66     headers: { "Content-Type": "application/x-www-form-urlencoded" },
67     body: new URLSearchParams({ email: email.trim(), password }),
68   });
69   const body = await r.text();
70   if (r.status === 401) throw new Error("Wrong email or password.");
71   if (!r.ok) throw new Error(body.trim() || "The server answered " + r.status + ".");
72   // A new account starts from no version: the first sync is a union.
73   await api.storage.local.set({ server, email: email.trim(), token: body.trim(), version: "", error: "" });
74   return syncNow();
75 }
76 
77 async function signOut() {
78   const s = await state();
79   await api.storage.local.remove(["token", "version", "synced", "error"]);
80   if (s.token) {
81     fetch(s.server + "/api/logout", { method: "POST", headers: { Authorization: "Bearer " + s.token } })
82       .catch(() => {}); // the token stays valid on the server; nothing else is lost
83   }
84   return state();
85 }
86 
87 /** Add a bookmark at the end of the file. With the same page there already, return that bookmark. */
88 async function add(b) {
89   const s = await state();
90   const existing = Tsv.find(Tsv.parse(s.text), b.url);
91   if (existing) return { existing };
92   await api.storage.local.set({ text: s.text + Tsv.appendix(s.text, Tsv.format(b)) });
93   if (s.token) await syncNow();
94   return { added: true };
95 }
96 
97 api.runtime.onMessage.addListener((msg, sender, reply) => {
98   const jobs = {
99     sync: () => syncNow(),
100     signIn: () => signIn(msg.server, msg.email, msg.password),
101     signOut: () => signOut(),
102     add: () => add(msg.bookmark),
103   };
104   const job = jobs[msg.type];
105   if (!job) return false;
106   serial(job).then(
107     (value) => reply({ value }),
108     (e) => reply({ error: e.message }),
109   );
110   return true; // reply comes later
111 });
112 
113 // ---- the address bar: type "bm", a space, then words ----
114 
115 function escapeXml(s) {
116   return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]);
117 }
118 
119 async function matches(text) {
120   const all = Tsv.parse((await state()).text).reverse(); // newest first
121   return Fuzzy.filter(all, text, (b) => b.desc + " " + b.tags.join(" ") + " " + b.url);
122 }
123 
124 api.omnibox.setDefaultSuggestion({ description: "Search your sbm bookmarks" });
125 
126 api.omnibox.onInputChanged.addListener(async (text, suggest) => {
127   // Chrome shows the description as XML; Firefox shows it as text.
128   const xml = typeof browser === "undefined";
129   suggest((await matches(text)).slice(0, 6).map((b) => {
130     const d = (b.desc || b.url) + " - " + b.url;
131     return { content: b.url, description: xml ? escapeXml(d) : d };
132   }));
133 });
134 
135 api.omnibox.onInputEntered.addListener(async (text, disposition) => {
136   // A picked suggestion gives its URL; typed text opens the best match, or
137   // else an address or a web search, as in bm.
138   const best = (await matches(text))[0];
139   const known = Tsv.find(Tsv.parse((await state()).text), text);
140   const url = known ? known.url : best ? best.url : Tsv.target(text);
141   if (disposition === "currentTab") api.tabs.update({ url });
142   else api.tabs.create({ url, active: disposition === "newForegroundTab" });
143 });
144 
145 // ---- sync on start and every half hour ----
146 
147 api.runtime.onStartup.addListener(() => serial(syncNow));
148 api.runtime.onInstalled.addListener(() => {
149   api.alarms.create("sync", { periodInMinutes: SYNC_MINUTES });
150   serial(syncNow);
151 });
152 api.alarms.onAlarm.addListener((a) => {
153   if (a.name === "sync") serial(syncNow);
154 });