fabio revised this gist . Go to revision
1 file changed, 170 insertions
ntfy-browser-import-export.js(file created)
| @@ -0,0 +1,170 @@ | |||
| 1 | + | /** | |
| 2 | + | * ntfy exporter | |
| 3 | + | * | |
| 4 | + | * This saves the ntfy state in your current browser session to a JSON file that | |
| 5 | + | * can be imported by another browser session or (only partly tested) the Android app. | |
| 6 | + | * | |
| 7 | + | * Save the following one-liner to your bookmarks to run it on the fly: | |
| 8 | + | ||
| 9 | + | javascript:(async()=>{async function dumpStore(dbName,storeName){const db=await new Promise((resolve,reject)=>{const r=indexedDB.open(dbName);r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});const tx=db.transaction(storeName,"readonly");const store=tx.objectStore(storeName);const all=await new Promise((resolve,reject)=>{const r=store.getAll();r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});db.close();return all;}const [subscriptions,users,notifications,settings]=await Promise.all([dumpStore("ntfy","subscriptions"),dumpStore("ntfy","users"),dumpStore("ntfy","notifications"),dumpStore("ntfy","prefs")]);const data={subscriptions,users,notifications,settings};const json=JSON.stringify(data,null,2);const blob=new Blob([json],{type:"application/json"});const url=URL.createObjectURL(blob);const a=document.createElement("a");a.href=url;a.download=`ntfy-backup-${location.host}-${new Date().toISOString().replace(/[:.]/g,"-")}.json`;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);})().catch(e=>alert(e&&e.message?e.message:String(e))); | |
| 10 | + | ||
| 11 | + | * The full implementation follows | |
| 12 | + | */ | |
| 13 | + | ||
| 14 | + | async function dumpStore(dbName, storeName) { | |
| 15 | + | const db = await new Promise((resolve, reject) => { | |
| 16 | + | const r = indexedDB.open(dbName); | |
| 17 | + | r.onsuccess = () => resolve(r.result); | |
| 18 | + | r.onerror = () => reject(r.error); | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | const tx = db.transaction(storeName, "readonly"); | |
| 22 | + | const store = tx.objectStore(storeName); | |
| 23 | + | ||
| 24 | + | const all = await new Promise((resolve, reject) => { | |
| 25 | + | const r = store.getAll(); | |
| 26 | + | r.onsuccess = () => resolve(r.result); | |
| 27 | + | r.onerror = () => reject(r.error); | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | db.close(); | |
| 31 | + | return all; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | Promise.all( | |
| 35 | + | [ | |
| 36 | + | dumpStore("ntfy", "subscriptions"), | |
| 37 | + | dumpStore("ntfy", "users"), | |
| 38 | + | dumpStore("ntfy", "notifications"), | |
| 39 | + | dumpStore("ntfy", "prefs"), | |
| 40 | + | ] | |
| 41 | + | ).then(([subscriptions, users, notifications, settings]) => { | |
| 42 | + | const data = { | |
| 43 | + | subscriptions, | |
| 44 | + | users, | |
| 45 | + | notifications, | |
| 46 | + | settings, | |
| 47 | + | }; | |
| 48 | + | ||
| 49 | + | const json = JSON.stringify(data, null, 2); | |
| 50 | + | const blob = new Blob([json], { type: "application/json" }); | |
| 51 | + | const url = URL.createObjectURL(blob); | |
| 52 | + | ||
| 53 | + | const a = document.createElement("a"); | |
| 54 | + | a.href = url; | |
| 55 | + | a.download = `ntfy-backup-${location.host}-${new Date() | |
| 56 | + | .toISOString() | |
| 57 | + | .replace(/[:.]/g, "-")}.json`; | |
| 58 | + | document.body.appendChild(a); | |
| 59 | + | a.click(); | |
| 60 | + | a.remove(); | |
| 61 | + | ||
| 62 | + | URL.revokeObjectURL(url); | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | /** | |
| 66 | + | * ntfy importer | |
| 67 | + | * | |
| 68 | + | * A backup file generated through the script above can be restored into | |
| 69 | + | * another browser session. Create a bookmark with the following JS: | |
| 70 | + | ||
| 71 | + | javascript:(async()=>{const DB_NAME="ntfy";const openDb=()=>new Promise((res,rej)=>{const r=indexedDB.open(DB_NAME);r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);r.onblocked=()=>rej(new Error("IndexedDB open blocked (close other tabs using this origin)."));});const pickFile=()=>new Promise((res,rej)=>{const i=document.createElement("input");i.type="file";i.accept="application/json";i.onchange=()=>{const f=i.files&&i.files[0];f?res(f):rej(new Error("No file selected"));};i.click();});const readJson=f=>new Promise((res,rej)=>{const fr=new FileReader();fr.onload=()=>{try{res(JSON.parse(fr.result));}catch(e){rej(e);}};fr.onerror=()=>rej(fr.error);fr.readAsText(f);});const clearAndImport=(db,storeName,rows)=>new Promise((res,rej)=>{const tx=db.transaction(storeName,"readwrite");const store=tx.objectStore(storeName);const c=store.clear();c.onerror=()=>rej(c.error);c.onsuccess=()=>{for(const row of (rows||[])) store.put(row);};tx.oncomplete=()=>res();tx.onerror=()=>rej(tx.error);tx.onabort=()=>rej(tx.error||new Error("Transaction aborted"));});try{const file=await pickFile();const data=await readJson(file);const db=await openDb();const stores=Array.from(db.objectStoreNames);const mapping=[["subscriptions",data.subscriptions],["users",data.users],["notifications",data.notifications],["prefs",data.settings]];for(const [storeName,rows] of mapping){if(!stores.includes(storeName)){console.warn("Skipping missing store:",storeName);continue;}await clearAndImport(db,storeName,rows);console.log(`Imported ${(rows||[]).length} into ${storeName}`);}db.close();location.reload();}catch(e){console.error(e);alert(e&&e.message?e.message:String(e));}})(); | |
| 72 | + | ||
| 73 | + | * Upon click it will open a file dialog - prompt a previously created backup. | |
| 74 | + | * The full implementation follows. | |
| 75 | + | */ | |
| 76 | + | ||
| 77 | + | // Run on the ntfy Web UI origin (same host) in the NEW browser/session. | |
| 78 | + | // 1) paste this into DevTools Console | |
| 79 | + | // 2) pick the JSON export file when prompted | |
| 80 | + | (async () => { | |
| 81 | + | const DB_NAME = "ntfy"; | |
| 82 | + | ||
| 83 | + | function openDb() { | |
| 84 | + | return new Promise((resolve, reject) => { | |
| 85 | + | const r = indexedDB.open(DB_NAME); | |
| 86 | + | r.onsuccess = () => resolve(r.result); | |
| 87 | + | r.onerror = () => reject(r.error); | |
| 88 | + | r.onblocked = () => reject(new Error("IndexedDB open blocked (close other tabs using this origin).")); | |
| 89 | + | // No onupgradeneeded: we assume the DB already exists with the right schema. | |
| 90 | + | }); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | function pickFile() { | |
| 94 | + | return new Promise((resolve, reject) => { | |
| 95 | + | const input = document.createElement("input"); | |
| 96 | + | input.type = "file"; | |
| 97 | + | input.accept = "application/json"; | |
| 98 | + | input.onchange = () => { | |
| 99 | + | const f = input.files && input.files[0]; | |
| 100 | + | if (!f) reject(new Error("No file selected")); | |
| 101 | + | else resolve(f); | |
| 102 | + | }; | |
| 103 | + | input.click(); | |
| 104 | + | }); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | function readJson(file) { | |
| 108 | + | return new Promise((resolve, reject) => { | |
| 109 | + | const fr = new FileReader(); | |
| 110 | + | fr.onload = () => { | |
| 111 | + | try { resolve(JSON.parse(fr.result)); } | |
| 112 | + | catch (e) { reject(e); } | |
| 113 | + | }; | |
| 114 | + | fr.onerror = () => reject(fr.error); | |
| 115 | + | fr.readAsText(file); | |
| 116 | + | }); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | async function clearAndImportStore(db, storeName, rows) { | |
| 120 | + | return new Promise((resolve, reject) => { | |
| 121 | + | const tx = db.transaction(storeName, "readwrite"); | |
| 122 | + | const store = tx.objectStore(storeName); | |
| 123 | + | ||
| 124 | + | // Clear then put all rows back. | |
| 125 | + | const clearReq = store.clear(); | |
| 126 | + | clearReq.onerror = () => reject(clearReq.error); | |
| 127 | + | ||
| 128 | + | clearReq.onsuccess = () => { | |
| 129 | + | // Use put() so existing keys are overwritten if any remain. | |
| 130 | + | for (const row of (rows || [])) store.put(row); | |
| 131 | + | }; | |
| 132 | + | ||
| 133 | + | tx.oncomplete = () => resolve(); | |
| 134 | + | tx.onerror = () => reject(tx.error); | |
| 135 | + | tx.onabort = () => reject(tx.error || new Error("Transaction aborted")); | |
| 136 | + | }); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | const file = await pickFile(); | |
| 140 | + | const data = await readJson(file); | |
| 141 | + | ||
| 142 | + | const db = await openDb(); | |
| 143 | + | const stores = Array.from(db.objectStoreNames); | |
| 144 | + | console.log("Found stores:", stores); | |
| 145 | + | ||
| 146 | + | // The export you created uses these top-level keys: | |
| 147 | + | const mapping = [ | |
| 148 | + | ["subscriptions", data.subscriptions], | |
| 149 | + | ["users", data.users], | |
| 150 | + | ["notifications", data.notifications], | |
| 151 | + | ["prefs", data.settings], // export called it "settings", store is "prefs" | |
| 152 | + | ]; | |
| 153 | + | ||
| 154 | + | for (const [storeName, rows] of mapping) { | |
| 155 | + | if (!stores.includes(storeName)) { | |
| 156 | + | console.warn(`Skipping missing store: ${storeName}`); | |
| 157 | + | continue; | |
| 158 | + | } | |
| 159 | + | await clearAndImportStore(db, storeName, rows); | |
| 160 | + | console.log(`Imported ${rows ? rows.length : 0} records into ${storeName}`); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | db.close(); | |
| 164 | + | ||
| 165 | + | // Optional: reload so UI picks up changes immediately. | |
| 166 | + | location.reload(); | |
| 167 | + | })().catch(e => { | |
| 168 | + | console.error(e); | |
| 169 | + | alert(e && e.message ? e.message : String(e)); | |
| 170 | + | }); | |
Newer
Older