diff --git a/TESTING.md b/TESTING.md index b67bf09c..fd7ae998 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4340,6 +4340,15 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati `system shell and overview match the concept` (the mapping inserts the row) and `library notice matches the concept notice` with its mutation check (system-concept.spec), and the D129 tests of workspace-system.spec. +- **D166** (one-time upgrade notice, #527): the concept's README puts the notice in the + new shell, while its `app.js` draws it in System › Sources. Production does both: the + Sources `.notice` is kept and compared as drawn, and `#older-images-notice`, styled as + `#library-banner`, shows the same notice above every other page until dismissed. The + shell hides on Sources, so they never show together. Dismissal is the legacy key + `pm_update_notice_dismissed`, shared with the admin modal and across tabs. Browser + contexts start dismissed (`tests/browser/fixtures.mjs`, `storageState`); a spec about + the notice sets `test.use({ olderImagesNotice: true })`. Pinned by + workspace-older-images-notice.spec. - **D130** (Appearance, #317): the concept keeps "Thumbnail quality, size limit" in Appearance (README) but draws no rows for them. Appearance gains a second `section.surface`, "Thumbnails", after "Browsing & display", built from the concept's diff --git a/tests/browser/fixtures.mjs b/tests/browser/fixtures.mjs index 357f8c19..c89996e8 100644 --- a/tests/browser/fixtures.mjs +++ b/tests/browser/fixtures.mjs @@ -8,6 +8,9 @@ // browser that exits abnormally prints one `pm-browser-exit|` line, and a test // that fails on a closed or crashed target gets that log attached as // `browser-stderr`. +// The one-time older-images notice (#527) shows above every workspace page and as +// a modal on the admin page until dismissed, so every context starts with it +// dismissed. A spec about the notice sets `test.use({ olderImagesNotice: true })`. import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs'; import { attachBrowserLogs, createBrowserLogRecorder } from '../../tools/vendor/browser-logs.mjs'; @@ -44,6 +47,11 @@ export const test = base.extend({ if (crash) throw crash; }, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }], baseURL: async ({ workerServer }, use) => use(workerServer.url), + olderImagesNotice: [false, { option: true }], + storageState: async ({ baseURL, olderImagesNotice }, use) => use(olderImagesNotice ? undefined : { + cookies: [], + origins: [{ origin: new URL(baseURL).origin, localStorage: [{ name: 'pm_update_notice_dismissed', value: '3.2.0' }] }], + }), }); export { expect }; diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index f616b274..a8b59d2e 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -685,6 +685,8 @@ async function watchedSources(request) { for (const theme of THEMES) for (const [width, height] of SIZES) for (const notice of [true, false]) { const variant = notice ? 'with' : 'without'; + test.describe(() => { + test.use({ olderImagesNotice: notice }); test(`sources matches the concept ${variant} the notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { await page.setViewportSize({ width, height }); const server = await watchedSources(request); @@ -695,8 +697,11 @@ for (const theme of THEMES) for (const [width, height] of SIZES) for (const noti expect(diffInventory(mapSourcesConcept(concept, server), production)).toEqual([]); expect(production.pageScroll).toBe(false); }); + }); } +test.describe(() => { +test.use({ olderImagesNotice: true }); test('sources comparison detects mutations', async ({ page, request }) => { await page.setViewportSize({ width: 1600, height: 1100 }); const server = await watchedSources(request); @@ -739,6 +744,7 @@ test('sources comparison detects mutations', async ({ page, request }) => { await undoToken(); expect(diffInventory(expected, await measure())).toEqual([]); }); +}); /** * Runs in the page: the open "Add image source" dialog (the concept's `simpleInput`, app.js 1138 and 1497): its title, diff --git a/tests/browser/workspace-older-images-notice.spec.mjs b/tests/browser/workspace-older-images-notice.spec.mjs new file mode 100644 index 00000000..14568230 --- /dev/null +++ b/tests/browser/workspace-older-images-notice.spec.mjs @@ -0,0 +1,153 @@ +// The one-time older-images notice in the workspace shell (#527, D166). Its dismissal is the legacy admin page's key, +// so dismissing it in any place dismisses it everywhere. +import { test, expect } from './fixtures.mjs'; + +const WORKSPACE = '/prompt_manager/workspace'; +const ADMIN = '/prompt_manager/admin'; +const NOTICE_KEY = 'pm_update_notice_dismissed'; + +const shellNotice = page => page.locator('#older-images-notice > .notice'); +const sourcesNotice = page => page.locator('#workspace-main .settings-layout > .notice'); +const dialog = page => page.locator('dialog#modal[open]'); +const adminModal = page => page.locator('#updateNoticeModal'); +const stored = page => page.evaluate(key => localStorage.getItem(key), NOTICE_KEY); + +async function openRoute(page, route) { + await page.goto(`${WORKSPACE}#/${route}`); + await expect(page.locator('#workspace-main h1').first()).toBeVisible(); +} + +// Every spec on this worker's server shares its job list. +test.beforeEach(async ({ request }) => { + await expect(await request.post('/__test__/jobs/restart')).toBeOK(); +}); + +test.describe('with the notice not dismissed', () => { + test.use({ olderImagesNotice: true }); + + for (const route of ['library', 'images', 'tags', 'activity', 'system/overview']) { + test(`shows above the ${route} page with Later and Review scan`, async ({ page }) => { + await openRoute(page, route); + await expect(shellNotice(page).locator('h2')).toHaveText('Bring older images into your prompt history'); + await expect(shellNotice(page).locator('.row-actions > button')).toHaveText(['Later', 'Review scan']); + await expect(page.locator('#older-images-notice')).toBeVisible(); + }); + } + + test('Later dismisses it for good without a write or a scan', async ({ page, request }) => { + const writes = []; + page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); }); + await openRoute(page, 'library'); + await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click(); + await expect(shellNotice(page)).toHaveCount(0); + await expect(page.locator('#older-images-notice')).toBeHidden(); + await expect(page.locator('#workspace-main')).toBeFocused(); + expect(await stored(page)).toBe('3.2.0'); + await expect(dialog(page)).toHaveCount(0); + await page.reload(); + await expect(page.locator('#workspace-main h1').first()).toBeVisible(); + await expect(shellNotice(page)).toHaveCount(0); + expect(writes).toEqual([]); + expect((await (await request.get('/prompt_manager/jobs')).json()).jobs).toEqual([]); + }); + + test('Review scan dismisses it and opens the review of every source', async ({ page }) => { + await openRoute(page, 'library'); + await shellNotice(page).getByRole('button', { name: 'Review scan', exact: true }).click(); + await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan'); + await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']); + await expect(shellNotice(page)).toHaveCount(0); + expect(await stored(page)).toBe('3.2.0'); + await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.locator('#workspace-main')).toBeFocused(); + }); + + test('Review scan keeps the notice and says so when the sources cannot be read', async ({ page }) => { + await openRoute(page, 'library'); + await page.route('**/prompt_manager/settings', route => route.fulfill({ status: 500, json: { success: false } })); + await shellNotice(page).getByRole('button', { name: 'Review scan', exact: true }).click(); + await expect(page.locator('#workspace-toast')).toHaveText('Could not read the image sources. Try again.'); + await expect(shellNotice(page)).toHaveCount(1); + await expect(shellNotice(page).getByRole('button', { name: 'Review scan', exact: true })).not.toHaveAttribute('aria-disabled', 'true'); + await expect(dialog(page)).toHaveCount(0); + expect(await stored(page)).toBeNull(); + }); + + test('System > Sources shows one notice, its own, and dismissing it there clears the shell', async ({ page }) => { + await openRoute(page, 'system/overview'); + await expect(shellNotice(page)).toHaveCount(1); + await page.goto(`${WORKSPACE}#/system/sources`); + await expect(page.locator('#workspace-main .settings-layout > .notice:visible')).toHaveCount(1); + await expect(shellNotice(page)).toHaveCount(0); + await sourcesNotice(page).getByRole('button', { name: 'Later', exact: true }).click(); + await expect(sourcesNotice(page)).toHaveCount(0); + await page.goto(`${WORKSPACE}#/library`); + await expect(page.locator('#workspace-main h1').first()).toBeVisible(); + await expect(shellNotice(page)).toHaveCount(0); + }); + + test('dismissing in the shell clears the copy System has already mounted', async ({ page }) => { + await openRoute(page, 'system/sources'); + await expect(sourcesNotice(page)).toHaveCount(1); + await page.goto(`${WORKSPACE}#/system/overview`); + await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click(); + await page.goto(`${WORKSPACE}#/system/sources`); + await expect(page.locator('#workspace-main #source-list')).toBeVisible(); + await expect(sourcesNotice(page)).toHaveCount(0); + }); + + test('dismissing in another tab clears it here', async ({ page, context }) => { + await openRoute(page, 'library'); + await expect(shellNotice(page)).toHaveCount(1); + const other = await context.newPage(); + await openRoute(other, 'images'); + await shellNotice(other).getByRole('button', { name: 'Later', exact: true }).click(); + await expect(shellNotice(page)).toHaveCount(0); + await other.close(); + }); + + test('the admin modal still works: Later without the checkbox keeps the notice everywhere', async ({ page }) => { + await page.goto(ADMIN); + await expect(adminModal(page)).toBeVisible(); + await page.locator('#updateNoticeDismiss').click(); + await expect(adminModal(page)).toBeHidden(); + expect(await stored(page)).toBeNull(); + await openRoute(page, 'library'); + await expect(shellNotice(page)).toHaveCount(1); + }); + + test('dismissing on the admin page dismisses it in the workspace', async ({ page }) => { + await page.goto(ADMIN); + await page.locator('#dismissUpdateNotice').check(); + await page.locator('#updateNoticeDismiss').click(); + await expect(adminModal(page)).toBeHidden(); + await openRoute(page, 'library'); + await expect(shellNotice(page)).toHaveCount(0); + }); + + test('dismissing in the workspace dismisses it on the admin page', async ({ page }) => { + await openRoute(page, 'library'); + await shellNotice(page).getByRole('button', { name: 'Later', exact: true }).click(); + await page.goto(ADMIN); + await page.waitForLoadState('load'); + await expect(adminModal(page)).toBeHidden(); + }); + + test('the admin Scan still dismisses it and opens the scan modal', async ({ page }) => { + await page.goto(ADMIN); + await page.locator('#updateNoticeScan').click(); + await expect(adminModal(page)).toBeHidden(); + expect(await stored(page)).toBe('3.2.0'); + }); +}); + +test('specs start with the notice dismissed, on every page', async ({ page, browser, baseURL }) => { + await openRoute(page, 'library'); + await expect(shellNotice(page)).toHaveCount(0); + const context = await browser.newContext({ baseURL }); + const other = await context.newPage(); + await other.goto(ADMIN); + await other.waitForLoadState('load'); + await expect(adminModal(other)).toBeHidden(); + await context.close(); +}); diff --git a/tests/browser/workspace-system-sources.spec.mjs b/tests/browser/workspace-system-sources.spec.mjs index 74097aa3..cd1ac570 100644 --- a/tests/browser/workspace-system-sources.spec.mjs +++ b/tests/browser/workspace-system-sources.spec.mjs @@ -41,6 +41,9 @@ async function serverJson(request, url) { return response.json(); } +// `openSources` dismisses the notice itself unless a test asks for it. +test.use({ olderImagesNotice: true }); + /** Open Sources with the update notice dismissed (or shown, `notice: true`) once the source list has loaded. */ async function openSources(page, { notice = false } = {}) { if (!notice) await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index 6581fe85..98ca5e6a 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -897,6 +897,8 @@ test('reset restores defaults without touching other keys', async ({ page }) => // System section, found by role and accessible name (a `term` by its text, a disclosure by its summary). Collapsed // `details` are opened first, so a control inside one counts once it is shown. tests/vendor/legacy-coverage.test.mjs // checks the rest of each row. +test.describe(() => { +test.use({ olderImagesNotice: true }); test('every covered legacy System capability has its native control', async ({ page, request }) => { // Download is offered for a model that is not on disk ("Download again" otherwise): the selected default's file is // missing here. @@ -907,6 +909,7 @@ test('every covered legacy System capability has its native control', async ({ p await request.post('/__test__/tagging/reset', { data: {} }); } }); +}); async function findControls(page) { const covered = readCoverageTable().filter(row => row.Status === 'covered') diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 97f89b06..9fbab6d4 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -514,6 +514,8 @@ const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Int 'Backup & export', 'Diagnostics & logs']; const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics']; +test.describe(() => { +test.use({ olderImagesNotice: true }); test('System is native: the concept shell, section navigation, sections and the Save row', async ({ page }) => { const mutations = []; page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); }); @@ -592,6 +594,7 @@ test('System is native: the concept shell, section navigation, sections and the await expect(main.locator('.settings-layout')).toHaveCount(1); expect(mutations).toEqual([]); }); +}); test('Overview opens native System from its System card (Q12)', async ({ page }) => { await page.goto(`${workspace}#/overview`); diff --git a/tests/js/workspace-system-sources.test.js b/tests/js/workspace-system-sources.test.js index 81719d44..31f4ce83 100644 --- a/tests/js/workspace-system-sources.test.js +++ b/tests/js/workspace-system-sources.test.js @@ -174,3 +174,19 @@ test('D158: "Save prompts from other workflows" drafts save_foreign_runs, disabl assert.deepEqual(foreignRunsState({ value: true, monitoring: false, stopping: true }), { disabled: false, checked: true, note: FOREIGN_RUNS.off }); }); + +test('a failed notice write still dismisses for this page and tells watchers (#527)', async () => { + const { noticeDismissed, dismissNotice } = await sourcesModule; + const denied = () => { throw new Error('SecurityError'); }; + assert.equal(noticeDismissed(denied), false); + assert.equal(dismissNotice(denied), false); + assert.equal(noticeDismissed(denied), true); +}); + +test('sourceCount reads the effective sources, null when the answer does not say (#527)', async () => { + const { sourceCount } = await import('../../web/js/workspace/older-images-notice.mjs'); + assert.equal(sourceCount({ sources: { effective: [{}, {}] } }), 2); + assert.equal(sourceCount({ sources: { effective: [] } }), 0); + assert.equal(sourceCount({ sources: {} }), null); + assert.equal(sourceCount(null), null); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 5cd0a1ac..07788e50 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -407,6 +407,12 @@ "functions": 98, "statements": 98 }, + "workspace/older-images-notice.mjs": { + "lines": 80, + "branches": 70, + "functions": 80, + "statements": 80 + }, "workspace/library-location.mjs": { "lines": 94, "branches": 91, diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index ed5daea8..88aaae47 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -23,6 +23,7 @@ import { createPromptSender } from './prompt-sender.mjs'; import { mountSystem } from './system.mjs'; import { createRestoreReload, onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs'; import { mountLibraryBanner } from './library-location.mjs'; +import { mountOlderImagesNotice } from './older-images-notice.mjs'; import { detectJxlSupport } from './thumbnail-support.mjs'; const { createApiClient, resolveUrl } = window.ApiClient; @@ -85,6 +86,8 @@ function mountWorkspace() { activity.load(); // D129: a node-folder library is offered a move (or "Keep it here") above every page. mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope }); + // D166: the one-time upgrade notice, above every page until dismissed; System > Sources shows its own. + const olderImages = mountOlderImagesNotice(document.getElementById('older-images-notice'), { api, activity, scope, main }); const sidebar = document.querySelector('aside.sidebar'); // The route key a nav link carries in `data-route`: a saved view's is `saved/`, a collection's `collection/`. const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` @@ -259,6 +262,7 @@ function mountWorkspace() { function renderNow(route) { leaveGuard = null; + olderImages.showFor(route); // A shared page dialog never stays open over the next route. closeWorkspaceDialog(); if (route.id === 'system' && system && currentKey === 'system') { diff --git a/web/js/workspace/older-images-notice.mjs b/web/js/workspace/older-images-notice.mjs new file mode 100644 index 00000000..4d29a7b5 --- /dev/null +++ b/web/js/workspace/older-images-notice.mjs @@ -0,0 +1,129 @@ +/** + * The one-time upgrade notice "Bring older images into your prompt history" (#527, D166). + * + * Its dismissal is the legacy admin page's own `pm_update_notice_dismissed`, so dismissing it anywhere dismisses it + * everywhere in this browser. The shell shows it above every page; System > Sources keeps the concept's copy in the + * page, so the shell hides on that section and the two never show together. Storage that cannot be written still + * dismisses it for this page. + */ +import { createButton } from '../shared/toolkit/core.js'; +import { openMetadataScan } from './activity-scan.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +export const NOTICE_KEY = 'pm_update_notice_dismissed'; +export const NOTICE_VERSION = '3.2.0'; +export const SOURCES_UNREADABLE = 'Could not read the image sources. Try again.'; + +const SETTINGS_URL = '/prompt_manager/settings'; +const defaultStorage = () => window.localStorage; +const changes = new EventTarget(); +// The storages this page dismissed the notice in without being able to write it. +const dismissedHere = new WeakSet(); + +/** Whether this browser dismissed the 3.2.0 notice; storage that cannot be read shows it. */ +export function noticeDismissed(getStorage = defaultStorage) { + if (dismissedHere.has(getStorage)) return true; + try { + return getStorage().getItem(NOTICE_KEY) === NOTICE_VERSION; + } catch { + return false; + } +} + +/** Dismiss the notice everywhere on this page. Returns whether it was saved for the next visit. */ +export function dismissNotice(getStorage = defaultStorage) { + let saved = true; + try { + getStorage().setItem(NOTICE_KEY, NOTICE_VERSION); + } catch { + dismissedHere.add(getStorage); + saved = false; + } + changes.dispatchEvent(new Event('dismissed')); + return saved; +} + +/** Calls `onDismissed` when the notice is dismissed on this page or in another tab, until `scope` ends. */ +export function watchNoticeDismissal({ scope, storage = defaultStorage, onDismissed }) { + changes.addEventListener('dismissed', onDismissed); + scope.cleanup(() => changes.removeEventListener('dismissed', onDismissed)); + scope.listen(window, 'storage', event => { + if (event.key === NOTICE_KEY && noticeDismissed(storage)) onDismissed(); + }); +} + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** The concept's `.notice` with Later and Review scan. */ +export function noticeNode({ scope, onLater, onReview }) { + const notice = el('div', undefined, 'notice'); + const text = el('div'); + text.append(el('h2', 'Bring older images into your prompt history'), + el('p', 'The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.')); + const actions = el('div', undefined, 'row-actions'); + const later = createButton('Later'); + const review = createButton('Review scan', 'ca-primary'); + scope.listen(later, 'click', onLater); + scope.listen(review, 'click', () => onReview(review)); + actions.append(later, review); + notice.append(text, actions); + return notice; +} + +/** How many output folders a scan reads, from GET /settings; null when the answer does not say. */ +export function sourceCount(settings) { + const count = settings?.sources?.effective?.length; + return Number.isSafeInteger(count) ? count : null; +} + +/** + * Mount the shell's notice in `container`, hidden until the first `showFor(route)`. The pressed button leaves with the + * notice, so focus moves to `main`. If the sources cannot be read, Review scan says so and the notice stays. + */ +export function mountOlderImagesNotice(container, { api, activity, scope, main, storage = defaultStorage, notify = showWorkspaceToast }) { + let onSources = true; + let reading = false; + const render = () => { + const show = !onSources && !noticeDismissed(storage); + container.hidden = !show; + if (!show) notice.remove(); + else if (!notice.isConnected) container.append(notice); + }; + const notice = noticeNode({ scope, + onLater: () => { + dismissNotice(storage); + main.focus({ preventScroll: true }); + }, + onReview: async button => { + if (reading) return; + reading = true; + button.setAttribute('aria-disabled', 'true'); + let sources = null; + try { + sources = sourceCount(await api.get(SETTINGS_URL, { signal: scope.signal, cache: 'no-store' })); + } catch { + if (scope.signal.aborted) return; + } + reading = false; + button.removeAttribute('aria-disabled'); + if (sources === null) { + notify(SOURCES_UNREADABLE); + return; + } + dismissNotice(storage); + main.focus({ preventScroll: true }); + openMetadataScan({ scope, store: activity, sources, trigger: main }); + } }); + watchNoticeDismissal({ scope, storage, onDismissed: render }); + return { + showFor(route) { + onSources = route.id === 'system' && route.section === 'sources'; + render(); + }, + }; +} diff --git a/web/js/workspace/system-sources.mjs b/web/js/workspace/system-sources.mjs index 6afb437e..cd71d330 100644 --- a/web/js/workspace/system-sources.mjs +++ b/web/js/workspace/system-sources.mjs @@ -2,8 +2,9 @@ * System › Sources & scans (#249 System S4 Task 11): the concept's `renderSystem` "sources" body (app.js 679–681). * * - The optional `.notice` "Bring older images into your prompt history" shows until this browser dismissed the 3.2.0 - * update notice (`pm_update_notice_dismissed`, the legacy admin page's own key). Later dismisses it without scanning; - * Review scan dismisses it, as the legacy page's Scan does, and reviews every source. + * update notice (older-images-notice.mjs, the legacy admin page's own key). Later dismisses it without scanning; + * Review scan dismisses it, as the legacy page's Scan does, and reviews every source. D166: dismissing it in the shell + * or another tab removes it here too, and the shell does not show it on this section. * - "Image sources" lists the server's sources (GET /settings, S4 Task 9) in `#source-list`, one `.operation` each: * its label, " · watching | watching until restart | manual scan | restart pending" and "Scan changes" * (watched) or "Scan folder", which opens the shared "Review metadata scan" (D55) for that one source ("1 output @@ -38,14 +39,15 @@ import { settingRow, operationRow } from './system-controls.mjs'; import { HOST_UNAVAILABLE } from './system-appearance.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { noun } from './plural.mjs'; +import { dismissNotice, noticeDismissed, noticeNode, watchNoticeDismissal } from './older-images-notice.mjs'; + +export { NOTICE_KEY, NOTICE_VERSION, dismissNotice, noticeDismissed } from './older-images-notice.mjs'; const SETTINGS_URL = '/prompt_manager/settings'; const VALIDATE_URL = '/prompt_manager/settings/validate-source'; const RESCAN_URL = '/prompt_manager/gallery/rescan'; const ROOTS_KEY = 'gallery_root_paths'; -export const NOTICE_KEY = 'pm_update_notice_dismissed'; -export const NOTICE_VERSION = '3.2.0'; export const WATCH_COPY = Object.freeze({ watching: 'watching', stopping: 'watching until restart', manual: 'manual scan', restart_pending: 'restart pending' }); // The states whose observer runs now: their scan only has to catch up on changes. @@ -87,25 +89,6 @@ const addedLabels = new Map(); const defaultStorage = () => window.localStorage; -/** Whether this browser dismissed the 3.2.0 notice; storage that cannot be read shows it. */ -export function noticeDismissed(getStorage = defaultStorage) { - try { - return getStorage().getItem(NOTICE_KEY) === NOTICE_VERSION; - } catch { - return false; - } -} - -/** Dismiss the notice for this browser; false when storage refused (it stays dismissed for this view). */ -export function dismissNotice(getStorage = defaultStorage) { - try { - getStorage().setItem(NOTICE_KEY, NOTICE_VERSION); - return true; - } catch { - return false; - } -} - const sameRoots = (a, b) => Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((path, index) => path === b[index]); /** The roots to draft: the saved list itself when they are equal, so the draft is clean again. */ @@ -168,22 +151,6 @@ function el(tag, text, className) { return node; } -/** The concept's `.notice` with Later and Review scan. */ -function noticeNode({ scope, onLater, onReview }) { - const notice = el('div', undefined, 'notice'); - const text = el('div'); - text.append(el('h2', 'Bring older images into your prompt history'), - el('p', 'The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.')); - const actions = el('div', undefined, 'row-actions'); - const later = createButton('Later'); - const review = createButton('Review scan', 'ca-primary'); - scope.listen(later, 'click', onLater); - scope.listen(review, 'click', onReview); - actions.append(later, review); - notice.append(text, actions); - return notice; -} - /** * "Add image source" (the concept's `simpleInput`, D127): the server checks the folder; a refused one keeps the * dialog open with the server's reason under the field. Enter in the field adds, as the Add button does. @@ -263,6 +230,7 @@ export function mountSources(container, { api, activity, scope, drafts, notify = notice.remove(); scanAll(addButton); } }); + watchNoticeDismissal({ scope, storage, onDismissed: () => notice.remove() }); nodes.push(notice); } diff --git a/web/workspace.html b/web/workspace.html index 41dd5139..5de9998c 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -52,6 +52,8 @@ + +