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", {