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.
This commit is contained in:
committed by
GitHub
parent
7e9231f0e6
commit
c2e9eaaa7b
+17
-1
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storageMode === 'browser' && (
|
||||
<div className="banner info" style={{gap:8}}>
|
||||
<Icon name="folder" size={14}/>
|
||||
<span>
|
||||
Data saved in <strong>browser storage</strong> — persists until you clear site data.{' '}
|
||||
Use <em>Import/Export</em> to back up to a file.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsConnect && (
|
||||
<div className="banner info">
|
||||
<Icon name="folder" size={14}/>
|
||||
@@ -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 (
|
||||
<header className="header">
|
||||
<div className="brand">
|
||||
|
||||
+160
@@ -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('<!-- SECTION: INTEGRITY -->'),
|
||||
}));
|
||||
} 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,
|
||||
|
||||
+186
-3
@@ -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('<!-- SECTION: INTEGRITY -->')
|
||||
}));
|
||||
} 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", {
|
||||
|
||||
Reference in New Issue
Block a user