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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 });