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 (7129 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", "lib/plan.js");
11 
12 const api = globalThis.browser ?? globalThis.chrome;
13 const DEFAULT_SERVER = "https://sbmsync.com";
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 /** The plan of the account and the payments that the server offers, or null. */
88 async function account() {
89   const s = await state();
90   if (!s.token) return null;
91   const r = await fetch(s.server + "/api/account", { headers: { Authorization: "Bearer " + s.token } });
92   // A server from before this API has no such address. Its account page
93   // has the payments.
94   if (r.status === 404) return null;
95   if (!r.ok) throw new Error((await r.text()).trim() || "The server answered " + r.status + ".");
96   return r.json();
97 }
98 
99 /** Ask the server for a page of Stripe, and open that page in a new tab. */
100 async function billing(path, form) {
101   const s = await state();
102   const r = await fetch(s.server + path, {
103     method: "POST",
104     headers: { Authorization: "Bearer " + s.token, "Content-Type": "application/x-www-form-urlencoded" },
105     body: new URLSearchParams(form),
106   });
107   const body = (await r.text()).trim();
108   if (!r.ok) throw new Error(body || "The server answered " + r.status + ".");
109   const url = Plan.web(body, ["https:"]);
110   if (!url) throw new Error("The server gave no https address.");
111   await api.tabs.create({ url });
112 }
113 
114 /** Add a bookmark at the end of the file. With the same page there already, return that bookmark. */
115 async function add(b) {
116   const s = await state();
117   const existing = Tsv.find(Tsv.parse(s.text), b.url);
118   if (existing) return { existing };
119   await api.storage.local.set({ text: s.text + Tsv.appendix(s.text, Tsv.format(b)) });
120   if (s.token) await syncNow();
121   return { added: true };
122 }
123 
124 api.runtime.onMessage.addListener((msg, sender, reply) => {
125   const jobs = {
126     sync: () => syncNow(),
127     signIn: () => signIn(msg.server, msg.email, msg.password),
128     signOut: () => signOut(),
129     add: () => add(msg.bookmark),
130     account: () => account(),
131     checkout: () => billing("/api/checkout", { plan: msg.plan }),
132     portal: () => billing("/api/portal", {}),
133   };
134   const job = jobs[msg.type];
135   if (!job) return false;
136   serial(job).then(
137     (value) => reply({ value }),
138     (e) => reply({ error: e.message }),
139   );
140   return true; // reply comes later
141 });
142 
143 // ---- the address bar: type "bm", a space, then words ----
144 
145 function escapeXml(s) {
146   return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]);
147 }
148 
149 async function matches(text) {
150   const all = Tsv.parse((await state()).text).reverse(); // newest first
151   return Fuzzy.filter(all, text, (b) => b.desc + " " + b.tags.join(" ") + " " + b.url);
152 }
153 
154 api.omnibox.setDefaultSuggestion({ description: "Search your sbm bookmarks" });
155 
156 api.omnibox.onInputChanged.addListener(async (text, suggest) => {
157   // Chrome shows the description as XML; Firefox shows it as text.
158   const xml = typeof browser === "undefined";
159   suggest((await matches(text)).slice(0, 6).map((b) => {
160     const d = (b.desc || b.url) + " - " + b.url;
161     return { content: b.url, description: xml ? escapeXml(d) : d };
162   }));
163 });
164 
165 api.omnibox.onInputEntered.addListener(async (text, disposition) => {
166   // A picked suggestion gives its URL; typed text opens the best match, or
167   // else an address or a web search, as in bm.
168   const best = (await matches(text))[0];
169   const known = Tsv.find(Tsv.parse((await state()).text), text);
170   const url = known ? known.url : best ? best.url : Tsv.target(text);
171   if (disposition === "currentTab") api.tabs.update({ url });
172   else api.tabs.create({ url, active: disposition === "newForegroundTab" });
173 });
174 
175 // ---- sync on start and every half hour ----
176 
177 api.runtime.onStartup.addListener(() => serial(syncNow));
178 api.runtime.onInstalled.addListener(() => {
179   api.alarms.create("sync", { periodInMinutes: SYNC_MINUTES });
180   serial(syncNow);
181 });
182 api.alarms.onAlarm.addListener((a) => {
183   if (a.name === "sync") serial(syncNow);
184 });