From ef7a021d89a512760427bb4080271ed930b160be Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:28:29 -0400 Subject: [PATCH] Count import changes against the server's export The import dialog compared the file with the editor's in-memory items, which keep row key order and secret values the export does not, so an unchanged export reported updated items. The export also left out the Settings system item, so a fresh install's export reported one deletion. The dialog now compares the file with a fresh export using a key-order independent comparison, and the export includes the system items. --- api/src/routes/config.js | 2 +- api/test/http-integration.test.js | 15 ++++++++++++++- ui/admin/index.html | 2 +- ui/js/admin-drag.js | 2 +- ui/js/admin-list.js | 4 ++-- ui/js/admin-save-logic.js | 19 +++++++++++++++++++ ui/js/admin-settings.js | 2 +- ui/js/admin.js | 22 ++++++---------------- ui/js/import-foreign.js | 2 +- ui/test/admin-save-logic.test.mjs | 25 +++++++++++++++++++++++++ 10 files changed, 71 insertions(+), 24 deletions(-) diff --git a/api/src/routes/config.js b/api/src/routes/config.js index 3707c37d..52d76809 100644 --- a/api/src/routes/config.js +++ b/api/src/routes/config.js @@ -230,7 +230,7 @@ on('POST', '/api/config', async (req, res) => { }); on('GET', '/api/config/export', (_, res) => { - const safe = scrubSecrets(loadConfig()); + const safe = ensureSystemItems(scrubSecrets(loadConfig())); delete safe._rev; const d = JSON.stringify(safe, null, 2); res.writeHead(200, { diff --git a/api/test/http-integration.test.js b/api/test/http-integration.test.js index 4e7a4fe2..019add3e 100644 --- a/api/test/http-integration.test.js +++ b/api/test/http-integration.test.js @@ -35,7 +35,7 @@ const { unfinishedUpload } = require('../test-support/unfinished-upload'); require('../src/routes'); // registers auth/config/health/badges/... + OPTIONS require('../src/widget-data'); // registers /api/widget-data/:id (pulls in widgets) const { dispatch, on } = require('../src/router'); -const { saveConfig, loadConfig } = require('../src/config'); +const { saveConfig, loadConfig, loadConfigForUpdate } = require('../src/config'); const { hashPassword, makeToken, verifyToken, clearAttempts, RENEW_AFTER_MS } = require('../src/auth'); /* Routes that fail on purpose, to prove the router turns a handler error into a @@ -407,6 +407,19 @@ test('GET /api/config/export is downloadable and free of secrets', async () => { assert.equal(r.body.settings?.auth?.passwordHash, undefined); }); +test('GET /api/config/export lists the same items as GET /api/config', async () => { + const cfg = loadConfigForUpdate(); + cfg.items = []; + saveConfig(cfg, { keepRev: true }); + const exp = await req('GET', '/api/config/export', { cookie: validCookie }); + const live = await req('GET', '/api/config', { cookie: validCookie }); + assert.deepEqual( + exp.body.items.map(i => i.id), + live.body.items.map(i => i.id), + ); + assert.ok(exp.body.items.some(i => i.system === 'settings')); +}); + test('POST /api/config drops unknown settings and preserves the stored auth secret', async () => { const r = await req('POST', '/api/config', { cookie: validCookie, diff --git a/ui/admin/index.html b/ui/admin/index.html index 6228512d..3497c55b 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -301,6 +301,6 @@

General

- + diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index 3b98413c..666cbb63 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -11,7 +11,7 @@ import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; import { state } from '/js/admin-state.js?v=af772a1b'; -import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e'; +import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; import { qa } from '/js/utils.js?v=14b18b8c'; /** @type {(before: unknown) => void} */ diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 1e854410..c3de0fde 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -12,9 +12,9 @@ reads and writes them too. */ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e'; +import { snapshotItems } from '/js/admin-save-logic.js?v=5613a9fa'; import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=b2422767'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=d9f5ad98'; import { paintIcon } from '/js/admin-shared.js?v=36dc5cc1'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=14b18b8c'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; diff --git a/ui/js/admin-save-logic.js b/ui/js/admin-save-logic.js index f6e44662..048b4543 100644 --- a/ui/js/admin-save-logic.js +++ b/ui/js/admin-save-logic.js @@ -122,6 +122,25 @@ function sameData(a, b) { ); } +/** What restoring `incoming` over `current` does to the item list. Compare + against the server's export, not the editor's items: the editor keeps row + shapes and secrets the export does not. + @param {any[]} current @param {any[]} incoming + @returns {{ added: number, updated: number, deleted: number }} */ +export function importCounts(current, incoming) { + const cur = new Map(current.map(i => [i?.id, i])); + const inc = new Map(incoming.map(i => [i?.id, i])); + let added = 0, + updated = 0, + deleted = 0; + for (const [id, it] of inc) { + if (!cur.has(id)) added++; + else if (!sameData(cur.get(id), it)) updated++; + } + for (const id of cur.keys()) if (!inc.has(id)) deleted++; + return { added, updated, deleted }; +} + /** The settings a backup restores. The server owns `auth` and drops it from every write. @param {unknown} settings @returns {Record | null} */ diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 8e7f53b1..651d7bea 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -13,7 +13,7 @@ import { import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=14b18b8c'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { serialWrites } from '/js/admin-save-logic.js?v=5811620e'; +import { serialWrites } from '/js/admin-save-logic.js?v=5613a9fa'; import { renderColorControl } from '/js/admin-color-control.js?v=e80f4a68'; import { BACKDROP } from '/js/background.js?v=0f4ca726'; import { diff --git a/ui/js/admin.js b/ui/js/admin.js index 3eaf7152..aa0a7c9b 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,12 +1,13 @@ import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=dd5f148f'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=71f8a57a'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=5bdad01b'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=7b21e1a0'; import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; import { afterImport, buildAppItem, claimFolderChildren, + importCounts, newItemId, restoreBody, restoresSettings, @@ -14,14 +15,14 @@ import { serialWrites, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=5811620e'; +} from '/js/admin-save-logic.js?v=5613a9fa'; import { loadSettings, savedWallpaperUrl, settingsDirty, showBgFields, showWallpaperFile, -} from '/js/admin-settings.js?v=d69636fc'; +} from '/js/admin-settings.js?v=65546081'; import { apiGet, apiPost, @@ -55,7 +56,7 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=6ecd782f'; +} from '/js/import-foreign.js?v=3b635c16'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { @@ -1162,18 +1163,7 @@ el('imp').onchange = async e => { try { const d = JSON.parse(await f.text()); if (!d || !Array.isArray(d.items)) throw new ShownError(t('toast.importNotStackyard')); - const cur = new Map(state.items.map(i => [i.id, i])); - const inc = new Map(d.items.map(i => [i.id, i])); - let added = 0, - updated = 0, - deleted = 0; - for (const [id, it] of inc) { - if (!cur.has(id)) added++; - else if (JSON.stringify(cur.get(id)) !== JSON.stringify(it)) updated++; - } - for (const id of cur.keys()) { - if (!inc.has(id)) deleted++; - } + const { added, updated, deleted } = importCounts((await apiGet('/api/config/export')).items, d.items); const settingsToo = restoresSettings(d, await apiGet('/api/config')); if (added + updated + deleted === 0 && !settingsToo) { toast(t('toast.importNoChange')); diff --git a/ui/js/import-foreign.js b/ui/js/import-foreign.js index 423cba87..8e9faecf 100644 --- a/ui/js/import-foreign.js +++ b/ui/js/import-foreign.js @@ -3,7 +3,7 @@ only: a widget in the source becomes a plain app tile, never a Stackyard widget. Keep this module free of the DOM and of the network. */ -import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=5811620e'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=5613a9fa'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; export const SKIP = Object.freeze({ diff --git a/ui/test/admin-save-logic.test.mjs b/ui/test/admin-save-logic.test.mjs index c9bd87e2..9e6d7484 100644 --- a/ui/test/admin-save-logic.test.mjs +++ b/ui/test/admin-save-logic.test.mjs @@ -9,6 +9,7 @@ import { randomSuffix, snapshotItems, afterImport, + importCounts, restoreBody, restoresSettings, revertingSaves, @@ -638,3 +639,27 @@ test('a backup whose uploaded wallpaper is missing keeps the live background', ( assert.deepEqual(restoreBody(file, { settings: {} }, true).settings, { theme: 'dark' }); assert.equal(restoreBody(file, live).settings.background.url, '/icons/wallpaper/gone.jpg'); }); + +test('importCounts reports no change for the same items in another key order', () => { + const live = [ + { id: 'settings', type: 'app', system: 'settings' }, + { id: 'a', type: 'app', monitoring: { activity: { headers: [{ key: 'q', value: '1', secret: false }] } } }, + ]; + const file = [ + { system: 'settings', type: 'app', id: 'settings' }, + { type: 'app', id: 'a', monitoring: { activity: { headers: [{ secret: false, key: 'q', value: '1' }] } } }, + ]; + assert.deepEqual(importCounts(live, file), { added: 0, updated: 0, deleted: 0 }); +}); + +test('importCounts counts added, updated and deleted items', () => { + const live = [ + { id: 'a', type: 'app', label: 'A' }, + { id: 'b', type: 'app' }, + ]; + const file = [ + { id: 'a', type: 'app', label: 'A2' }, + { id: 'c', type: 'app' }, + ]; + assert.deepEqual(importCounts(live, file), { added: 1, updated: 1, deleted: 1 }); +});