From c2e9eaaa7b1a1520842a1d4f2fbd4af1de745fbf Mon Sep 17 00:00:00 2001 From: Alexander Derevianko <38904816+LichHunter@users.noreply.github.com> Date: Thu, 7 May 2026 16:19:20 +0200 Subject: [PATCH] Add browser storage fallback for Firefox/Safari (#1) IndexedDB-based storage backend with Storage Persistence API for browsers without File System Access API support. Requests persistent storage on first save to protect data from automatic eviction. Includes backup rotation (last 10 saves) and appropriate UI banner. --- web/app.jsx | 18 +++- web/storage.jsx | 160 +++++++++++++++++++++++++++++++ webapp/index-style-v2.html | 189 ++++++++++++++++++++++++++++++++++++- 3 files changed, 363 insertions(+), 4 deletions(-) diff --git a/web/app.jsx b/web/app.jsx index 425be96..869ccf1 100644 --- a/web/app.jsx +++ b/web/app.jsx @@ -87,6 +87,12 @@ function App() { if (mode === 'local') { setIsLoading(false); return; } + if (mode === 'browser') { + if (cancelled) return; + await applyStorageData(mode, () => cancelled); + return; + } + if (mode === 'direct') { // Try silent reconnect — works if permission is still active from a previous session. const ok = await Storage.tryAutoConnect(); @@ -528,6 +534,16 @@ function App() { )} + {storageMode === 'browser' && ( +
+ + + Data saved in browser storage — persists until you clear site data.{' '} + Use Import/Export to back up to a file. + +
+ )} + {needsConnect && (
@@ -758,7 +774,7 @@ function ViewChips({ filters, setFilters }) { // ----- Header ----- function Header({ view, setView, saveState, saveLabel, storageMode, searchValue, onSearch, onOpenImportExport }) { - const modeIcon = storageMode === 'api' ? '⚡' : storageMode === 'direct' ? '📁' : '💾'; + const modeIcon = storageMode === 'api' ? '⚡' : storageMode === 'direct' ? '📁' : storageMode === 'browser' ? '🌐' : '💾'; return (
diff --git a/web/storage.jsx b/web/storage.jsx index a48e459..4020543 100644 --- a/web/storage.jsx +++ b/web/storage.jsx @@ -351,6 +351,159 @@ const DirectBackend = { }, }; +// ---- Browser Storage Backend (IndexedDB + Storage Persistence API) ---- +// Fallback for browsers without File System Access API (Firefox, Safari) +const BrowserStorageBackend = { + _DB_NAME: 'pb-browser-storage', + _STORE: 'data', + _CONTENT_KEY: 'backlog', + _BACKUPS_KEY: 'backups', + _MAX_BACKUPS: 10, + _persistenceRequested: false, + _persistenceGranted: null, + + async detect() { + return typeof indexedDB !== 'undefined'; + }, + + _openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(this._DB_NAME, 1); + req.onupgradeneeded = e => e.target.result.createObjectStore(this._STORE); + req.onsuccess = e => resolve(e.target.result); + req.onerror = () => reject(req.error); + }); + }, + + async _get(key) { + const db = await this._openDB(); + return new Promise(resolve => { + const tx = db.transaction(this._STORE, 'readonly'); + const get = tx.objectStore(this._STORE).get(key); + get.onsuccess = () => resolve(get.result); + get.onerror = () => resolve(null); + }); + }, + + async _set(key, value) { + const db = await this._openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this._STORE, 'readwrite'); + tx.objectStore(this._STORE).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + }, + + async load() { + try { + const content = await this._get(this._CONTENT_KEY) || ''; + return { content, checksum: '' }; + } catch { + return { content: '', checksum: '' }; + } + }, + + async save(content) { + // Request persistence on first save (triggered by user action = valid gesture) + if (!this._persistenceRequested) { + this._persistenceRequested = true; + this._requestPersistence(); + } + + try { + // Create backup before saving + const oldContent = await this._get(this._CONTENT_KEY); + if (oldContent) { + await this._addBackup(oldContent); + } + + await this._set(this._CONTENT_KEY, content); + return { ok: true }; + } catch (e) { + return { ok: false, error: e.message }; + } + }, + + async _requestPersistence() { + if (navigator.storage?.persist) { + try { + const alreadyPersisted = await navigator.storage.persisted(); + if (alreadyPersisted) { + this._persistenceGranted = true; + return; + } + // This will prompt the user in Firefox + this._persistenceGranted = await navigator.storage.persist(); + } catch { + this._persistenceGranted = false; + } + } + }, + + async _addBackup(content) { + try { + const backups = (await this._get(this._BACKUPS_KEY)) || []; + const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + backups.unshift({ + name: `backlog_${ts}.md`, + content, + timestamp: new Date().toISOString(), + size: new TextEncoder().encode(content).length, + }); + // Keep only last N backups + if (backups.length > this._MAX_BACKUPS) { + backups.length = this._MAX_BACKUPS; + } + await this._set(this._BACKUPS_KEY, backups); + } catch { /* backup failure is non-fatal */ } + }, + + async listBackups() { + try { + const backups = (await this._get(this._BACKUPS_KEY)) || []; + return backups.map(b => ({ + name: b.name, + size: b.size, + timestamp: b.timestamp, + valid: b.content?.includes(''), + })); + } catch { + return []; + } + }, + + async restoreBackup(name) { + try { + const backups = (await this._get(this._BACKUPS_KEY)) || []; + const backup = backups.find(b => b.name === name); + if (!backup) return { ok: false, error: 'Backup not found' }; + await this._set(this._CONTENT_KEY, backup.content); + return { ok: true }; + } catch (e) { + return { ok: false, error: e.message }; + } + }, + + async getHealthInfo() { + try { + const content = await this._get(this._CONTENT_KEY) || ''; + const estimate = await navigator.storage?.estimate?.() || {}; + const persisted = await navigator.storage?.persisted?.() || false; + return { + masterSize: new TextEncoder().encode(content).length, + statsSize: 0, + browserStorage: true, + persisted, + quota: estimate.quota, + usage: estimate.usage, + }; + } catch { + return { masterSize: 0, statsSize: 0, browserStorage: true, persisted: false }; + } + }, +}; + // ---- Storage — detect backend and delegate ---- const Storage = { backend: null, @@ -367,6 +520,11 @@ const Storage = { this.mode = 'direct'; return 'direct'; } + if (await BrowserStorageBackend.detect()) { + this.backend = BrowserStorageBackend; + this.mode = 'browser'; + return 'browser'; + } this.mode = 'local'; return 'local'; }, @@ -500,6 +658,7 @@ async function buildDataFromStorage(parsed, backups, storageMode, sizeInfo = {}) const historySize = new TextEncoder().encode(JSON.stringify(history)).length; const modeLabel = storageMode === 'api' ? 'API server' : storageMode === 'direct' ? 'Direct (File System API)' + : storageMode === 'browser' ? 'Browser storage (IndexedDB)' : 'localStorage only'; return { @@ -537,6 +696,7 @@ Object.assign(window, { Parser, ApiBackend, DirectBackend, + BrowserStorageBackend, Storage, SyncPoller, buildDataFromStorage, diff --git a/webapp/index-style-v2.html b/webapp/index-style-v2.html index 76b7420..db7b7af 100644 --- a/webapp/index-style-v2.html +++ b/webapp/index-style-v2.html @@ -2697,6 +2697,170 @@ const DirectBackend = { } }; +// ---- Browser Storage Backend (IndexedDB + Storage Persistence API) ---- +// Fallback for browsers without File System Access API (Firefox, Safari) +const BrowserStorageBackend = { + _DB_NAME: 'pb-browser-storage', + _STORE: 'data', + _CONTENT_KEY: 'backlog', + _BACKUPS_KEY: 'backups', + _MAX_BACKUPS: 10, + _persistenceRequested: false, + _persistenceGranted: null, + async detect() { + return typeof indexedDB !== 'undefined'; + }, + _openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(this._DB_NAME, 1); + req.onupgradeneeded = e => e.target.result.createObjectStore(this._STORE); + req.onsuccess = e => resolve(e.target.result); + req.onerror = () => reject(req.error); + }); + }, + async _get(key) { + const db = await this._openDB(); + return new Promise(resolve => { + const tx = db.transaction(this._STORE, 'readonly'); + const get = tx.objectStore(this._STORE).get(key); + get.onsuccess = () => resolve(get.result); + get.onerror = () => resolve(null); + }); + }, + async _set(key, value) { + const db = await this._openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this._STORE, 'readwrite'); + tx.objectStore(this._STORE).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + }, + async load() { + try { + const content = (await this._get(this._CONTENT_KEY)) || ''; + return { + content, + checksum: '' + }; + } catch { + return { + content: '', + checksum: '' + }; + } + }, + async save(content) { + // Request persistence on first save (triggered by user action = valid gesture) + if (!this._persistenceRequested) { + this._persistenceRequested = true; + this._requestPersistence(); + } + try { + // Create backup before saving + const oldContent = await this._get(this._CONTENT_KEY); + if (oldContent) { + await this._addBackup(oldContent); + } + await this._set(this._CONTENT_KEY, content); + return { + ok: true + }; + } catch (e) { + return { + ok: false, + error: e.message + }; + } + }, + async _requestPersistence() { + if (navigator.storage?.persist) { + try { + const alreadyPersisted = await navigator.storage.persisted(); + if (alreadyPersisted) { + this._persistenceGranted = true; + return; + } + // This will prompt the user in Firefox + this._persistenceGranted = await navigator.storage.persist(); + } catch { + this._persistenceGranted = false; + } + } + }, + async _addBackup(content) { + try { + const backups = (await this._get(this._BACKUPS_KEY)) || []; + const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + backups.unshift({ + name: `backlog_${ts}.md`, + content, + timestamp: new Date().toISOString(), + size: new TextEncoder().encode(content).length + }); + // Keep only last N backups + if (backups.length > this._MAX_BACKUPS) { + backups.length = this._MAX_BACKUPS; + } + await this._set(this._BACKUPS_KEY, backups); + } catch {/* backup failure is non-fatal */} + }, + async listBackups() { + try { + const backups = (await this._get(this._BACKUPS_KEY)) || []; + return backups.map(b => ({ + name: b.name, + size: b.size, + timestamp: b.timestamp, + valid: b.content?.includes('') + })); + } catch { + return []; + } + }, + async restoreBackup(name) { + try { + const backups = (await this._get(this._BACKUPS_KEY)) || []; + const backup = backups.find(b => b.name === name); + if (!backup) return { + ok: false, + error: 'Backup not found' + }; + await this._set(this._CONTENT_KEY, backup.content); + return { + ok: true + }; + } catch (e) { + return { + ok: false, + error: e.message + }; + } + }, + async getHealthInfo() { + try { + const content = (await this._get(this._CONTENT_KEY)) || ''; + const estimate = (await navigator.storage?.estimate?.()) || {}; + const persisted = (await navigator.storage?.persisted?.()) || false; + return { + masterSize: new TextEncoder().encode(content).length, + statsSize: 0, + browserStorage: true, + persisted, + quota: estimate.quota, + usage: estimate.usage + }; + } catch { + return { + masterSize: 0, + statsSize: 0, + browserStorage: true, + persisted: false + }; + } + } +}; + // ---- Storage — detect backend and delegate ---- const Storage = { backend: null, @@ -2714,6 +2878,11 @@ const Storage = { this.mode = 'direct'; return 'direct'; } + if (await BrowserStorageBackend.detect()) { + this.backend = BrowserStorageBackend; + this.mode = 'browser'; + return 'browser'; + } this.mode = 'local'; return 'local'; }, @@ -2882,7 +3051,7 @@ async function buildDataFromStorage(parsed, backups, storageMode, sizeInfo = {}) const historyOldest = history.length > 0 ? history[history.length - 1].timestamp : null; const backupDirSize = backups.reduce((s, b) => s + (b.size || 0), 0); const historySize = new TextEncoder().encode(JSON.stringify(history)).length; - const modeLabel = storageMode === 'api' ? 'API server' : storageMode === 'direct' ? 'Direct (File System API)' : 'localStorage only'; + const modeLabel = storageMode === 'api' ? 'API server' : storageMode === 'direct' ? 'Direct (File System API)' : storageMode === 'browser' ? 'Browser storage (IndexedDB)' : 'localStorage only'; return { entries, history, @@ -2922,6 +3091,7 @@ Object.assign(window, { Parser, ApiBackend, DirectBackend, + BrowserStorageBackend, Storage, SyncPoller, buildDataFromStorage @@ -5424,6 +5594,11 @@ function App() { setIsLoading(false); return; } + if (mode === 'browser') { + if (cancelled) return; + await applyStorageData(mode, () => cancelled); + return; + } if (mode === 'direct') { // Try silent reconnect — works if permission is still active from a previous session. const ok = await Storage.tryAutoConnect(); @@ -5977,7 +6152,15 @@ function App() { size: 14 }), /*#__PURE__*/React.createElement("span", null, "This browser can't access local files directly \u2014 ", /*#__PURE__*/React.createElement("strong", null, "changes won't be saved."), ' ', "Open in Chrome or Edge to save without a server, or run", ' ', /*#__PURE__*/React.createElement("code", { className: "mono" - }, "python3 server/server.py"), " for any browser.")), needsConnect && /*#__PURE__*/React.createElement("div", { + }, "python3 server/server.py"), " for any browser.")), storageMode === 'browser' && /*#__PURE__*/React.createElement("div", { + className: "banner info", + style: { + gap: 8 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "folder", + size: 14 + }), /*#__PURE__*/React.createElement("span", null, "Data saved in ", /*#__PURE__*/React.createElement("strong", null, "browser storage"), " \u2014 persists until you clear site data.", ' ', "Use ", /*#__PURE__*/React.createElement("em", null, "Import/Export"), " to back up to a file.")), needsConnect && /*#__PURE__*/React.createElement("div", { className: "banner info" }, /*#__PURE__*/React.createElement(Icon, { name: "folder", @@ -6276,7 +6459,7 @@ function Header({ onSearch, onOpenImportExport }) { - const modeIcon = storageMode === 'api' ? '⚡' : storageMode === 'direct' ? '📁' : '💾'; + const modeIcon = storageMode === 'api' ? '⚡' : storageMode === 'direct' ? '📁' : storageMode === 'browser' ? '🌐' : '💾'; return /*#__PURE__*/React.createElement("header", { className: "header" }, /*#__PURE__*/React.createElement("div", {