From dc8651636516aaa9d801ef8cba7d8cedfbb15de5 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Fri, 23 Oct 2026 05:20:25 -0700 Subject: [PATCH] feat(system): share browser display defaults The workspace reads and writes the legacy gallery's gallerySettings object through preferences.mjs: workspaceLayout and workspaceDensity join the shared keys, a write re-reads the stored object and writes only the changed keys, denied storage or a full quota keeps the change for this tab and says so, and storage events report changes from other pages. The legacy gallery Save keeps keys it does not edit, and the viewer reads its video defaults through the shared module. --- tests/js/gallery-settings.test.js | 47 +++++ tests/js/workspace-preferences.test.js | 179 +++++++++++++++++++ tools/vendor/frontend-coverage-baseline.json | 12 +- web/js/gallery-settings.js | 12 +- web/js/gallery.js | 5 +- web/js/workspace/media-viewer.mjs | 6 +- web/js/workspace/preferences.mjs | 106 +++++++++++ 7 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 tests/js/workspace-preferences.test.js create mode 100644 web/js/workspace/preferences.mjs diff --git a/tests/js/gallery-settings.test.js b/tests/js/gallery-settings.test.js index c3681a29..a8fbf658 100644 --- a/tests/js/gallery-settings.test.js +++ b/tests/js/gallery-settings.test.js @@ -61,3 +61,50 @@ test("infinite scrolling is off by default and must be a boolean", () => { assert.equal(normalizeGallerySettings({ infiniteScroll: true }).infiniteScroll, true); assert.equal(normalizeGallerySettings({ infiniteScroll: "yes" }).infiniteScroll, false); }); + +test("the workspace keys have their own defaults and validation", () => { + const { WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES } = require("../../web/js/gallery-settings.js"); + assert.equal(DEFAULT_SETTINGS.workspaceLayout, "grid"); + assert.equal(DEFAULT_SETTINGS.workspaceDensity, "comfortable"); + assert.deepEqual(WORKSPACE_LAYOUTS, ["grid", "list", "masonry"]); + assert.deepEqual(WORKSPACE_DENSITIES, ["comfortable", "large", "compact"]); + assert.equal(normalizeGallerySettings({ workspaceLayout: "masonry" }).workspaceLayout, "masonry"); + assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "grid"); + assert.equal(normalizeGallerySettings({ workspaceDensity: "large" }).workspaceDensity, "large"); + assert.equal(normalizeGallerySettings({ workspaceDensity: 3 }).workspaceDensity, "comfortable"); + // The legacy gallery's own view mode is separate: it has no masonry. + assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid"); +}); + +test("a legacy gallery Save keeps the workspace keys and every other stored key", () => { + const fs = require("node:fs"); + const path = require("node:path"); + const vm = require("node:vm"); + const settings = require("../../web/js/gallery-settings.js"); + const storage = new Map([["gallerySettings", JSON.stringify({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1 })]]); + const controls = { + lazyLoadingToggle: { checked: true }, imageQualitySelect: { value: "high" }, defaultViewModeSelect: { value: "list" }, + defaultLimitSelect: { value: "200" }, gridColumnsSelect: { value: "6" }, showImageInfoToggle: { checked: false }, + autoLoadMetadataToggle: { checked: true }, cacheMetadataToggle: { checked: true }, showFilePathsToggle: { checked: false }, + sidebarCollapsedByDefaultToggle: { checked: true }, infiniteScrollToggle: { checked: false }, debugModeToggle: { checked: false }, + apiTimeoutInput: { value: "30" }, checkThumbnailsAtStartup: { checked: true }, videoAutoplayToggle: { checked: true }, + videoMuteToggle: { checked: false }, videoLoopToggle: { checked: true }, + }; + const context = vm.createContext({ + document: { addEventListener() {}, getElementById: id => controls[id] }, + window: {}, console: { debug() {}, warn() {}, error() {} }, GallerySettings: settings, + localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) }, + }); + const file = path.join(__dirname, "../../web/js/gallery.js"); + const Manager = vm.runInContext(`${fs.readFileSync(file, "utf8")}\nGalleryManager;`, context, { filename: file }); + const manager = Object.create(Manager.prototype); + Object.assign(manager, { showNotification() {}, hideSettings() {}, applySettings() {} }); + manager.saveSettings(); + const saved = JSON.parse(storage.get("gallerySettings")); + assert.equal(saved.workspaceLayout, "masonry"); + assert.equal(saved.workspaceDensity, "compact"); + assert.equal(saved.someOtherTool, 1); + assert.equal(saved.defaultViewMode, "list"); + assert.equal(saved.showImageInfo, false); + assert.equal(saved.videoMute, false); +}); diff --git a/tests/js/workspace-preferences.test.js b/tests/js/workspace-preferences.test.js new file mode 100644 index 00000000..bf7f4d94 --- /dev/null +++ b/tests/js/workspace-preferences.test.js @@ -0,0 +1,179 @@ +// Browser display defaults (#249 System S3 Task 7): the workspace reads and writes the legacy gallery's own +// `gallerySettings` object (Q3, "One display configuration"), writes only the keys it changed, never consumes the +// legacy paging keys, and says when this browser refused to keep a change. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import gallerySettings from '../../web/js/gallery-settings.js'; + +const module = await import('../../web/js/workspace/preferences.mjs').catch(error => { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + return {}; +}); +globalThis.GallerySettings = gallerySettings; +const KEY = 'gallerySettings'; + +/** A localStorage stand-in holding `text` under the settings key; `setItem` can be made to throw. */ +function storage(text = null, { quota = false } = {}) { + const writes = []; + const store = { + text, + writes, + getItem(key) { assert.equal(key, KEY); return store.text; }, + setItem(key, value) { + assert.equal(key, KEY); + writes.push(value); + if (quota) { const error = new Error('Quota exceeded'); error.name = 'QuotaExceededError'; throw error; } + store.text = value; + }, + removeItem() { assert.fail('Preferences never remove the shared settings'); }, + }; + return store; +} +const denied = () => { const error = new Error('Denied'); error.name = 'SecurityError'; throw error; }; + +test('normalizes new workspace keys and keeps legacy keys', () => { + assert.equal(typeof module.readPreferences, 'function', 'preferences.mjs exports readPreferences'); + module.resetTabPreferences(); + const legacy = { defaultViewMode: 'list', gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'sig', extra: 1 }; + const store = storage(JSON.stringify({ ...legacy, workspaceLayout: 'masonry', workspaceDensity: 'compact', showFilePaths: false })); + const { values, persisted } = module.readPreferences(() => store); + assert.equal(persisted, true); + assert.equal(values.workspaceLayout, 'masonry'); + assert.equal(values.workspaceDensity, 'compact'); + assert.equal(values.showFilePaths, false); + // Invalid workspace values fall back to the defaults, as every legacy key does. + store.text = JSON.stringify({ workspaceLayout: 'mosaic', workspaceDensity: 7, videoMute: 'no' }); + const fallback = module.readPreferences(() => store).values; + assert.equal(fallback.workspaceLayout, 'grid'); + assert.equal(fallback.workspaceDensity, 'comfortable'); + assert.equal(fallback.videoMute, true); + // The legacy normalizer knows the new keys and still keeps unknown ones. + const normalized = gallerySettings.normalizeGallerySettings({ ...legacy, workspaceLayout: 'list' }); + assert.equal(normalized.workspaceLayout, 'list'); + assert.equal(normalized.workspaceDensity, 'comfortable'); + assert.equal(normalized.extra, 1); + assert.equal(normalized.thumbnailPromptDismissed, 'sig'); + assert.deepEqual(gallerySettings.WORKSPACE_LAYOUTS, ['grid', 'list', 'masonry']); + assert.deepEqual(gallerySettings.WORKSPACE_DENSITIES, ['comfortable', 'large', 'compact']); +}); + +test('write touches only dirty keys', () => { + module.resetTabPreferences(); + const stored = { defaultViewMode: 'list', gridColumns: 5, thumbnailsGenerated: true, extra: { kept: true }, videoMute: false }; + const store = storage(JSON.stringify(stored)); + const result = module.writePreferences({ workspaceLayout: 'masonry', showImageInfo: false }, () => store); + assert.deepEqual(result, { persisted: true, conflicts: [] }); + assert.equal(store.writes.length, 1); + // Every other key, legacy or unknown, is written back exactly as it was stored: no defaults are filled in. + assert.deepEqual(JSON.parse(store.text), { ...stored, workspaceLayout: 'masonry', showImageInfo: false }); + // It re-reads the stored object, so a key another page wrote after this one read is kept. + store.text = JSON.stringify({ ...JSON.parse(store.text), gridColumns: 9 }); + module.writePreferences({ videoLoop: false }, () => store); + assert.equal(JSON.parse(store.text).gridColumns, 9); + assert.equal(JSON.parse(store.text).workspaceLayout, 'masonry'); + // An empty or malformed stored value starts from an empty object, never the defaults. + for (const text of [null, '{broken', '[]', '42']) { + const fresh = storage(text); + assert.equal(module.writePreferences({ cacheMetadata: false }, () => fresh).persisted, true); + assert.deepEqual(JSON.parse(fresh.text), { cacheMetadata: false }); + } + // Only workspace keys with valid values are accepted; nothing is written for a bad change. + for (const bad of [{ gridColumns: 4 }, { defaultLimit: 50 }, { workspaceLayout: 'mosaic' }, { videoMute: 'yes' }, { imageQuality: 'ultra' }]) { + const untouched = storage(JSON.stringify(stored)); + assert.throws(() => module.writePreferences(bad, () => untouched), TypeError); + assert.equal(untouched.writes.length, 0); + } + // A key changed elsewhere since this tab read it (its baseline) is reported, and the explicit save still wins. + const elsewhere = storage(JSON.stringify({ videoLoop: false })); + const outcome = module.writePreferences({ videoLoop: true, videoMute: false }, () => elsewhere, { baseline: { videoLoop: true, videoMute: true } }); + assert.deepEqual(outcome, { persisted: true, conflicts: ['videoLoop'] }); + assert.equal(JSON.parse(elsewhere.text).videoLoop, true); +}); + +test('denied storage reports tab-only', () => { + module.resetTabPreferences(); + const read = module.readPreferences(denied); + assert.equal(read.persisted, false); + assert.equal(read.values.workspaceLayout, 'grid'); + const result = module.writePreferences({ workspaceLayout: 'list', videoAutoplay: true }, denied); + assert.deepEqual(result, { persisted: false, conflicts: [] }); + // The change is kept for this tab: every reader in it (a new Images visit, the viewer) sees it. + const tab = module.readPreferences(denied); + assert.equal(tab.persisted, false); + assert.equal(tab.values.workspaceLayout, 'list'); + assert.equal(tab.values.videoAutoplay, true); + // A getItem that throws is denied storage too. + const throwing = { getItem: denied, setItem: denied }; + assert.equal(module.readPreferences(() => throwing).values.workspaceLayout, 'list'); + assert.equal(module.writePreferences({ workspaceDensity: 'large' }, () => throwing).persisted, false); + // Once storage accepts a write, the tab copy of that key gives way to the stored value. + const working = storage(JSON.stringify({ workspaceLayout: 'masonry' })); + assert.equal(module.writePreferences({ workspaceLayout: 'masonry' }, () => working).persisted, true); + assert.equal(module.readPreferences(() => working).values.workspaceLayout, 'masonry'); + module.resetTabPreferences(); + assert.equal(module.readPreferences(denied).values.videoAutoplay, false); +}); + +test('quota error keeps previous bytes', () => { + module.resetTabPreferences(); + const before = JSON.stringify({ gridColumns: 6, workspaceDensity: 'large' }); + const store = storage(before, { quota: true }); + const result = module.writePreferences({ workspaceDensity: 'compact' }, () => store); + assert.deepEqual(result, { persisted: false, conflicts: [] }); + assert.equal(store.writes.length, 1); + assert.equal(store.text, before); + // This tab keeps the change it could not store. + assert.equal(module.readPreferences(() => store).values.workspaceDensity, 'compact'); + module.resetTabPreferences(); + assert.equal(module.readPreferences(() => store).values.workspaceDensity, 'large'); +}); + +/** A window stand-in that records its storage listeners. */ +function fakeWindow() { + const listeners = new Set(); + return { + listeners, + addEventListener(type, listener) { assert.equal(type, 'storage'); listeners.add(listener); }, + removeEventListener(type, listener) { assert.equal(type, 'storage'); listeners.delete(listener); }, + fire(event) { for (const listener of [...listeners]) listener(event); }, + }; +} + +test('storage event updates clean values and flags dirty conflicts', () => { + module.resetTabPreferences(); + const store = storage(JSON.stringify({ workspaceLayout: 'list', videoMute: false })); + const win = fakeWindow(); + const seen = []; + const unsubscribe = module.subscribePreferences(win, next => seen.push(next), () => store); + assert.equal(win.listeners.size, 1); + // Another key, or another storage area's event, is not ours. + win.fire({ key: 'pm_update_notice_dismissed', storageArea: store }); + win.fire({ key: KEY, storageArea: { other: true } }); + assert.equal(seen.length, 0); + win.fire({ key: KEY, storageArea: store }); + win.fire({ key: null, storageArea: store }); // localStorage.clear() elsewhere + assert.equal(seen.length, 2); + assert.equal(seen[0].values.workspaceLayout, 'list'); + unsubscribe(); + assert.equal(win.listeners.size, 0); + // The reconciliation of the System drafts: clean values follow the other page; a key this tab has a draft for and + // the other page changed is a conflict, and the draft stays. + const baseline = { workspaceLayout: 'grid', videoMute: true, videoLoop: true }; + const next = { workspaceLayout: 'list', videoMute: true, videoLoop: false }; + assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { videoMute: false, videoLoop: false }, next }), { conflicts: [] }); + assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { workspaceLayout: 'masonry' }, next }), { conflicts: ['workspaceLayout'] }); + assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { workspaceLayout: 'list' }, next }), { conflicts: [] }); +}); + +test('legacy paging keys are never consumed by the workspace', () => { + module.resetTabPreferences(); + const store = storage(JSON.stringify({ defaultLimit: 500, gridColumns: 12, infiniteScroll: true, lazyLoading: false, + defaultViewMode: 'list', debugMode: true, apiTimeout: 9, thumbnailsGenerated: true, checkThumbnailsAtStartup: false })); + const { values } = module.readPreferences(() => store); + assert.deepEqual(Object.keys(values).sort(), [...module.WORKSPACE_PREFERENCE_KEYS].sort()); + for (const key of ['defaultLimit', 'gridColumns', 'infiniteScroll', 'lazyLoading', 'defaultViewMode', 'debugMode', 'apiTimeout', + 'thumbnailsGenerated', 'checkThumbnailsAtStartup', 'thumbnailPromptDismissed']) assert.equal(Object.hasOwn(values, key), false, key); + // The display defaults are the gallery's own defaults for these keys. + const defaults = module.preferenceDefaults(); + for (const key of module.WORKSPACE_PREFERENCE_KEYS) assert.equal(defaults[key], gallerySettings.DEFAULT_SETTINGS[key], key); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 88dfa16e..a8492511 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -37,15 +37,15 @@ }, "gallery-settings.js": { "lines": 98, - "branches": 90, + "branches": 94, "functions": 98, "statements": 98 }, "gallery.js": { - "lines": 40, + "lines": 41, "branches": 63, "functions": 44, - "statements": 40 + "statements": 41 }, "html-escape.js": { "lines": 98, @@ -401,6 +401,12 @@ "functions": 98, "statements": 98 }, + "workspace/preferences.mjs": { + "lines": 98, + "branches": 94, + "functions": 91, + "statements": 98 + }, "workspace/prompt-blocks.mjs": { "lines": 90, "branches": 92, diff --git a/web/js/gallery-settings.js b/web/js/gallery-settings.js index 4c21dd62..3ea591d0 100644 --- a/web/js/gallery-settings.js +++ b/web/js/gallery-settings.js @@ -4,6 +4,9 @@ * * Settings are per browser (localStorage key "gallerySettings"). Anything * stored can be stale or hand-edited, so every value is checked before use. + * The workspace shares this object ("One display configuration", #249 System + * S3): it adds workspaceLayout and workspaceDensity, and every key either page + * does not know is kept. * * Loadable in the browser (window.GallerySettings) and in Node for tests. */ @@ -11,6 +14,9 @@ "use strict"; const VIEW_MODES = ["grid", "list"]; + // The workspace Images defaults; the legacy gallery's defaultViewMode has no masonry. + const WORKSPACE_LAYOUTS = ["grid", "list", "masonry"]; + const WORKSPACE_DENSITIES = ["comfortable", "large", "compact"]; const LIMIT_OPTIONS = [50, 100, 200, 500]; const MIN_COLUMNS = 2; const MAX_COLUMNS = 12; @@ -34,6 +40,8 @@ videoAutoplay: false, videoMute: true, videoLoop: true, + workspaceLayout: "grid", + workspaceDensity: "comfortable", }); function toInt(value) { @@ -72,6 +80,8 @@ videoAutoplay: bool(merged.videoAutoplay, DEFAULT_SETTINGS.videoAutoplay), videoMute: bool(merged.videoMute, DEFAULT_SETTINGS.videoMute), videoLoop: bool(merged.videoLoop, DEFAULT_SETTINGS.videoLoop), + workspaceLayout: WORKSPACE_LAYOUTS.includes(merged.workspaceLayout) ? merged.workspaceLayout : DEFAULT_SETTINGS.workspaceLayout, + workspaceDensity: WORKSPACE_DENSITIES.includes(merged.workspaceDensity) ? merged.workspaceDensity : DEFAULT_SETTINGS.workspaceDensity, }; } @@ -81,7 +91,7 @@ return `repeat(${n}, minmax(0, 1fr))`; } - const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, normalizeGallerySettings, gridColumnsStyle }; + const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, normalizeGallerySettings, gridColumnsStyle }; if (typeof module !== "undefined" && module.exports) module.exports = api; else root.GallerySettings = api; })(typeof window !== "undefined" ? window : globalThis); diff --git a/web/js/gallery.js b/web/js/gallery.js index ee9f24bd..cad3c2ca 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -1768,7 +1768,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } saveSettings() { + // The workspace shares this object (its layout and density, #249 System S3), so a Save starts from what is + // stored and keeps every key this dialog does not edit. const settings = { + ...this.getSettings(), lazyLoading: document.getElementById('lazyLoadingToggle').checked, imageQuality: document.getElementById('imageQualitySelect').value, defaultViewMode: document.getElementById('defaultViewModeSelect').value, @@ -1782,8 +1785,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; infiniteScroll: document.getElementById('infiniteScrollToggle').checked, debugMode: document.getElementById('debugModeToggle').checked, apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value), - thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this - thumbnailPromptDismissed: this.getSettings().thumbnailPromptDismissed, checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked, // Video settings videoAutoplay: document.getElementById('videoAutoplayToggle').checked, diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 4427dd64..c4e4869f 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -2,14 +2,14 @@ import {createScope, createButton} from '../shared/toolkit/core.js'; import {createViewerSession} from './viewer-state.mjs'; import {rootedFileAdapter} from './inspection-state.mjs'; import {mountFileInspector} from './file-inspector.mjs'; +import {readPreferences} from './preferences.mjs'; let viewerId = 0; // Upper bound on waiting for a closed Viewer's in-flight image view before destroying it. const PENDING_VIEW_DESTROY_MS = 10000; +/** The video defaults, read through the shared display defaults (preferences.mjs, #249 System S3). */ export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) { - let stored; - try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; } - const {videoAutoplay,videoMute,videoLoop} = normalize(stored); + const {videoAutoplay,videoMute,videoLoop} = readPreferences(getStorage,normalize).values; return {videoAutoplay,videoMute,videoLoop}; } function element(tag, className, text) { diff --git a/web/js/workspace/preferences.mjs b/web/js/workspace/preferences.mjs new file mode 100644 index 00000000..96cb095c --- /dev/null +++ b/web/js/workspace/preferences.mjs @@ -0,0 +1,106 @@ +/** + * Browser display defaults (#249 System S3 Task 7). The workspace keeps them in the legacy gallery's own + * `gallerySettings` object (Q3, "One display configuration"), so the gallery, the workspace and the viewer read one + * configuration: the new `workspaceLayout` and `workspaceDensity` beside the legacy keys they share (metadata, paths, + * card details, viewer details, image quality and the video defaults). The legacy paging keys (`defaultLimit`, + * `gridColumns`, `infiniteScroll`, …) belong to the legacy gallery and are never read here (README continuous-browsing + * ruling). + * + * - `readPreferences(getStorage, normalize)` → `{values, persisted}`: the workspace keys, normalized by `gallery-settings.js`; + * `persisted` is false when this browser refused its storage. + * - `writePreferences(dirty, getStorage, {baseline})` → `{persisted, conflicts}`: re-reads the stored object and writes + * back only the dirty keys, every other key byte-for-byte as stored. A write the browser refused (denied storage, a + * full quota) leaves the stored bytes as they were and keeps the change for this tab only, so every reader in this + * tab still sees it. `conflicts` lists the dirty keys another page changed since `baseline` was read; the explicit + * save still wins. + * - `subscribePreferences(win, onChange, getStorage)` → unsubscribe: `onChange(readPreferences())` after another page + * changed the settings (a `storage` event). + * - `reconcilePreferences({baseline, drafts, next})` → `{conflicts}`: which drafts such a change overtook. + * + * Every storage access is in try/catch: a private window, blocked site data or a malformed value never breaks a page. + */ +export const PREFERENCES_KEY = 'gallerySettings'; +export const WORKSPACE_PREFERENCE_KEYS = Object.freeze(['workspaceLayout', 'workspaceDensity', 'autoLoadMetadata', 'cacheMetadata', + 'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault']); + +// The changes this tab could not store, overlaid on every read until storage accepts them. +const tabOnly = new Map(); +const defaultStorage = () => globalThis.localStorage; +const normalizer = () => globalThis.GallerySettings.normalizeGallerySettings; +const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value); + +/** Forget what this tab kept without storage (tests, and nothing else). */ +export function resetTabPreferences() { + tabOnly.clear(); +} + +const pick = settings => Object.fromEntries(WORKSPACE_PREFERENCE_KEYS.map(key => [key, settings[key]])); +/** The gallery's defaults for the workspace keys. */ +export function preferenceDefaults() { + return pick(normalizer()(null)); +} + +/** The stored object (`{}` when absent or malformed), or null when storage is unavailable. */ +function storedObject(storage) { + let text; + try { text = storage.getItem(PREFERENCES_KEY); } catch { return null; } + try { + const parsed = text ? JSON.parse(text) : null; + return isObject(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function storageOf(getStorage) { + try { return getStorage() ?? null; } catch { return null; } +} + +/** `normalize` is gallery-settings.js's `normalizeGallerySettings` (the page's `window.GallerySettings` by default). */ +export function readPreferences(getStorage = defaultStorage, normalize = normalizer()) { + const storage = storageOf(getStorage); + const stored = storage ? storedObject(storage) : null; + const values = pick(normalize({ ...(stored ?? {}), ...Object.fromEntries(tabOnly) })); + return { values, persisted: stored !== null }; +} + +/** A change is only a workspace key with a value the gallery's normalizer keeps as given. */ +function validate(dirty) { + if (!isObject(dirty)) throw new TypeError('Preferences need an object of changes'); + for (const [key, value] of Object.entries(dirty)) { + if (!WORKSPACE_PREFERENCE_KEYS.includes(key)) throw new TypeError(`Not a workspace display preference: ${key}`); + if (normalizer()({ [key]: value })[key] !== value) throw new TypeError(`Invalid value for ${key}`); + } +} + +export function writePreferences(dirty, getStorage = defaultStorage, { baseline = null } = {}) { + validate(dirty); + const keep = () => { for (const [key, value] of Object.entries(dirty)) tabOnly.set(key, value); }; + const storage = storageOf(getStorage); + const stored = storage ? storedObject(storage) : null; + if (stored === null) { keep(); return { persisted: false, conflicts: [] }; } + const current = pick(normalizer()(stored)); + const conflicts = baseline ? Object.keys(dirty).filter(key => Object.hasOwn(baseline, key) && current[key] !== baseline[key]) : []; + try { + storage.setItem(PREFERENCES_KEY, JSON.stringify({ ...stored, ...dirty })); + } catch { + keep(); + return { persisted: false, conflicts }; + } + for (const key of Object.keys(dirty)) tabOnly.delete(key); + return { persisted: true, conflicts }; +} + +export function subscribePreferences(win, onChange, getStorage = defaultStorage) { + const listener = event => { + if (event.key !== null && event.key !== PREFERENCES_KEY) return; + if (event.storageArea && event.storageArea !== storageOf(getStorage)) return; + onChange(readPreferences(getStorage)); + }; + win.addEventListener('storage', listener); + return () => win.removeEventListener('storage', listener); +} + +export function reconcilePreferences({ baseline, drafts, next }) { + return { conflicts: Object.keys(drafts).filter(key => next[key] !== baseline[key] && next[key] !== drafts[key]) }; +}