feat(collections): browse a collection from the sidebar
The sidebar gains the concept's COLLECTIONS group (D136): one row per collection with its folder icon, name and listed-member count, without the PROPOSED badge; the label shows once a collection exists. A row opens #/collection/<id>, the Images page filtered to that collection (D137): the collection's name as h1, Collection as breadcrumb and title, Edit to rename or delete it through the saved views' Edit dialog with collection nouns, and a note counting members the sources do not list now. An unknown or deleted collection shows a page that says so with a link to Images. A saved view on a deleted collection lists nothing and says so (MC6, D140). The list is read again after a restore and the counts 300 ms after a change. Part of #331
This commit is contained in:
@@ -47,6 +47,7 @@ from tests.browser.workspace_library_editor_fixture import (
|
||||
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
|
||||
from tests.browser.workspace_tagging_fixture import register_tagging_commands
|
||||
from tests.browser.workspace_review_fixture import register_review_commands
|
||||
from tests.browser.workspace_collections_fixture import register_collections_commands
|
||||
from tests.browser.workspace_diagnostics_fixture import (
|
||||
register_diagnostics_commands,
|
||||
repoint_logs,
|
||||
@@ -227,6 +228,7 @@ def create_app(root: Path) -> web.Application:
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
register_tagging_commands(routes, api, root, model)
|
||||
register_review_commands(routes, api, root)
|
||||
register_collections_commands(routes, api, root)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
register_activity_commands(app, routes, api)
|
||||
register_system_commands(app, routes, api, root)
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
// Collections (#331): the sidebar COLLECTIONS group (D136, concept index.html 28–31, app.js 336–339), the collection
|
||||
// page `#/collection/<id>` with its Edit (D137, concept render a1186–1189) and saved views on a collection (D140).
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const API = '/prompt_manager/collections';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const heading = page => main(page).getByRole('heading', { level: 1 });
|
||||
const breadcrumb = page => page.locator('#workspace-breadcrumb');
|
||||
const description = page => main(page).locator('.heading p.muted');
|
||||
const missingNote = page => main(page).locator('.heading p.small');
|
||||
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
|
||||
const imageCards = page => page.locator('article[data-image-key]');
|
||||
const collectionsNav = page => page.getByRole('navigation', { name: 'Collections', exact: true });
|
||||
const collectionsLabel = page => page.locator('aside.sidebar .sidebar-label', { hasText: 'COLLECTIONS' });
|
||||
const rows = page => collectionsNav(page).locator('a[data-route^="collection/"]');
|
||||
const row = (page, name) => rows(page).filter({ hasText: name });
|
||||
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
|
||||
const editButton = page => main(page).locator('.heading-actions > button').first();
|
||||
const editDialog = page => page.getByRole('dialog', { name: 'Edit collection', exact: true });
|
||||
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete collection', exact: true });
|
||||
const nameField = page => editDialog(page).getByRole('textbox', { name: 'Collection name', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
|
||||
async function seed(request) {
|
||||
const response = await request.post('/__test__/collections/seed');
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function listed(request) {
|
||||
const response = await request.get(API);
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).collections;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.collectionErrors = [];
|
||||
page.on('pageerror', error => page.collectionErrors.push(error.message));
|
||||
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
|
||||
page.on('dialog', dialog => { page.collectionErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.collectionErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('a collection row opens its page', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
// D136: name order, the listed-member count, no "PROPOSED" badge.
|
||||
await expect(collectionsLabel(page)).toBeVisible();
|
||||
await expect(collectionsLabel(page)).toHaveText('COLLECTIONS');
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
await expect(rows(page).nth(0)).toContainText('Experiments');
|
||||
await expect(rows(page).nth(1)).toContainText('Landscapes');
|
||||
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
|
||||
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('12');
|
||||
await expect(row(page, 'Landscapes')).toHaveAttribute('href', `#/collection/${id}`);
|
||||
await expect(row(page, 'Landscapes').locator('svg')).toHaveCount(1);
|
||||
await expect(page.locator('aside.sidebar .new-label')).toHaveCount(0);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
await expect(breadcrumb(page)).toHaveText('Collection');
|
||||
await expect(page).toHaveTitle('Collection · Prompt Manager');
|
||||
await expect(row(page, 'Landscapes')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(description(page)).toHaveText('Your generations, their prompts, and the next idea.');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
for (const path of manifest.files.Landscapes) {
|
||||
await expect(page.locator(`article[data-image-key$=":${path}"]`)).toHaveCount(1);
|
||||
}
|
||||
// MC3: the member whose file is gone is not listed and is counted in the note.
|
||||
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
|
||||
|
||||
// Every Images filter narrows the members.
|
||||
await search(page).fill('landscape_03');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await search(page).fill('');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
|
||||
await row(page, 'Experiments').click();
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await expect(missingNote(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('without collections the group shows no label and no rows', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(collectionsNav(page)).toHaveCount(1);
|
||||
await expect(rows(page)).toHaveCount(0);
|
||||
await expect(collectionsLabel(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test('a collection page neither restores nor replaces the Images return state', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await search(page).fill('experiment');
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(search(page)).toHaveValue('');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
await search(page).fill('landscape_01');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(search(page)).toHaveValue('experiment');
|
||||
expect(manifest.collections.Landscapes.count).toBe(12);
|
||||
});
|
||||
|
||||
test('Edit renames the open collection; a taken name stays in the dialog', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
await expect(editButton(page)).toHaveText('Edit');
|
||||
await editButton(page).click();
|
||||
await expect(nameField(page)).toHaveValue('Landscapes');
|
||||
await expect(editDialog(page).locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
|
||||
|
||||
// CUJ 3: names are unique ignoring case.
|
||||
await nameField(page).fill('experiments');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(editDialog(page).getByRole('alert')).toHaveText('A collection named “experiments” already exists.');
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
|
||||
await nameField(page).fill(' Landscape grids ');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('Collection renamed.');
|
||||
await expect(heading(page)).toHaveText('Landscape grids');
|
||||
await expect(breadcrumb(page)).toHaveText('Collection');
|
||||
await expect(row(page, 'Landscape grids')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(rows(page).nth(1)).toContainText('Landscape grids');
|
||||
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments', 'Landscape grids']);
|
||||
});
|
||||
|
||||
test('a failed rename keeps the dialog; a failed delete changes nothing', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
let failing = true;
|
||||
await page.route(`**${API}/${id}`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"The collection could not be changed."}' })
|
||||
: route.fallback());
|
||||
await editButton(page).click();
|
||||
await nameField(page).fill('Tests');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The collection could not be renamed. Try again.');
|
||||
await expect(editDialog(page)).toBeVisible();
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await expect(deleteDialog(page).locator('p')).toHaveText(
|
||||
'Delete “Experiments”? Its 8 images stay where they are; only the collection is removed.');
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The collection could not be deleted. Try again.');
|
||||
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
|
||||
expect(await listed(request)).toHaveLength(2);
|
||||
failing = false;
|
||||
});
|
||||
|
||||
test('Delete removes the collection only after confirming, and leaves every file', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await editButton(page).click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
// The stored count: 12 listed members and the missing one.
|
||||
await expect(deleteDialog(page).locator('p')).toHaveText(
|
||||
'Delete “Landscapes”? Its 13 images stay where they are; only the collection is removed.');
|
||||
await deleteDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(deleteDialog(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toHaveLength(2);
|
||||
|
||||
await editButton(page).click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Collection deleted.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments']);
|
||||
});
|
||||
|
||||
test('an unknown collection says it no longer exists, with the way back to Images', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.goto(`${workspace}#/collection/999999`);
|
||||
await expect(heading(page)).toHaveText('Collection');
|
||||
await expect(breadcrumb(page)).toHaveText('Collection');
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(imageCards(page)).toHaveCount(0);
|
||||
await main(page).getByRole('link', { name: 'Open Images', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
});
|
||||
|
||||
test('a failed list says so and Retry reads it again', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
let failing = true;
|
||||
await page.route(`**${API}`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
|
||||
: route.fallback());
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(description(page)).toHaveText('Collections could not be loaded. Your saved data has not changed.');
|
||||
await expect(description(page)).toHaveAttribute('role', 'alert');
|
||||
await expect(rows(page)).toHaveCount(0);
|
||||
failing = false;
|
||||
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('a saved view keeps its collection, and says so once the collection is deleted (MC6)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const view = manifest.saved_view;
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await savedNav(page).locator(`a[data-route="saved/${view.id}"]`).click();
|
||||
await expect(heading(page)).toHaveText(view.name);
|
||||
await expect(breadcrumb(page)).toHaveText(view.name);
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
// A saved view is not the collection page: no missing note.
|
||||
await expect(missingNote(page)).toHaveCount(0);
|
||||
|
||||
// Save view on a collection page stores the collection.
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
await search(page).fill('landscape_1');
|
||||
await expect(imageCards(page)).toHaveCount(3);
|
||||
await main(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
await save.getByRole('textbox', { name: 'View name', exact: true }).fill('Grid seed');
|
||||
await save.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
const views = (await (await request.get('/prompt_manager/saved-views')).json()).views;
|
||||
const saved = views.find(item => item.name === 'Grid seed');
|
||||
expect(saved.state).toMatchObject({ q: 'landscape_1', favorites: false, collection: String(manifest.collections.Landscapes.id) });
|
||||
|
||||
await expect(await request.delete(`${API}/${manifest.collections.Experiments.id}`)).toBeOK();
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText(view.name);
|
||||
await expect(main(page).locator('.empty')).toHaveText('The collection this view uses was deleted.');
|
||||
await expect(main(page).locator('.empty')).toBeVisible();
|
||||
await expect(imageCards(page)).toHaveCount(0);
|
||||
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
|
||||
});
|
||||
|
||||
test('a collection deleted in another tab shows the deleted-collection page', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
await expect(await request.delete(`${API}/${id}`)).toBeOK();
|
||||
await row(page, 'Experiments').click();
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a finished restore reads the collections again', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
try {
|
||||
const backup = await request.get('/prompt_manager/backup');
|
||||
await expect(backup).toBeOK();
|
||||
const bytes = await backup.body();
|
||||
const [experiments] = await listed(request);
|
||||
await expect(await request.delete(`${API}/${experiments.id}`)).toBeOK();
|
||||
await page.goto(`${workspace}#/system/backups`);
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await page.getByRole('button', { name: 'Review restore…', exact: true }).click();
|
||||
await (await chooser).setFiles({ name: 'prompts_backup_20261027_093000.db', mimeType: 'application/octet-stream', buffer: bytes });
|
||||
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText(/^Database restore finished · /);
|
||||
// The list was read again without a reload.
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
|
||||
} finally {
|
||||
await request.post('/__test__/jobs/restart');
|
||||
await request.post('/__test__/system/reset');
|
||||
}
|
||||
});
|
||||
|
||||
test('renaming a collection deleted in another tab says so and shows the deleted-collection page', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await editButton(page).click();
|
||||
await expect(await request.delete(`${API}/${id}`)).toBeOK();
|
||||
await nameField(page).fill('Tests');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('That collection no longer exists.');
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
});
|
||||
@@ -73,7 +73,8 @@ test('Save view saves the Images toolbar and Refine filters, and its sidebar row
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
|
||||
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
// D136: the COLLECTIONS label stays hidden while no collection is listed.
|
||||
await expect(page.locator('aside.sidebar .sidebar-label:not([hidden])')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
|
||||
await search(page).fill('dogs');
|
||||
|
||||
@@ -548,12 +548,16 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
|
||||
'nav#saved-nav', 'div.sidebar-bottom']);
|
||||
'nav#saved-nav', 'div.sidebar-label', 'nav#collections-nav', 'div.sidebar-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
// D49: SAVED VIEWS without the concept's PROPOSED badge. D136: COLLECTIONS without it either, its label hidden
|
||||
// and its nav empty while no collection exists.
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS', 'COLLECTIONS']);
|
||||
await expect(sidebar.locator('.sidebar-label').nth(2)).toBeHidden();
|
||||
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
|
||||
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
|
||||
await expect(page.locator('nav#collections-nav')).toHaveAttribute('aria-label', 'Collections');
|
||||
await expect(page.locator('nav#collections-nav > a')).toHaveCount(0);
|
||||
await expect(page.locator('.new-label')).toHaveCount(0);
|
||||
const primary = page.locator('nav#primary-nav');
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
|
||||
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
|
||||
@@ -580,7 +584,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(navCount(page, 'library')).toBeHidden();
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(4);
|
||||
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
|
||||
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Owned, deterministic collections for the workspace collection workflows (#331).
|
||||
|
||||
Twenty-one PNGs under the output source: twelve listed members of
|
||||
"Landscapes", eight of "Experiments" and one loose file in neither. Landscapes
|
||||
also holds one member whose file does not exist (MC3: hidden, counted as
|
||||
missing). One saved view reopens "Experiments" (D140). Members are added
|
||||
through ``ImageCollectionOperations`` with favorites' keys
|
||||
(``_favorite_root_key``, ``_favorite_relative_key``), as the routes add them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from tests.browser.folder_trash import replace_folder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
FOLDER = "collections"
|
||||
LANDSCAPES = "Landscapes"
|
||||
EXPERIMENTS = "Experiments"
|
||||
LANDSCAPE_FILES = tuple(f"landscape_{index:02d}.png" for index in range(1, 13))
|
||||
EXPERIMENT_FILES = tuple(f"experiment_{index:02d}.png" for index in range(1, 9))
|
||||
LOOSE_FILE = "loose.png"
|
||||
MISSING_FILE = "gone.png"
|
||||
SAVED_VIEW = "Experiments view"
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
"media_review_items",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
_IMAGES_STATE = {
|
||||
"q": "",
|
||||
"category": "",
|
||||
"sort": "newest",
|
||||
"source": "all",
|
||||
"folder": "",
|
||||
"media": "all",
|
||||
"tags": [],
|
||||
"favorites": False,
|
||||
}
|
||||
|
||||
|
||||
def _write_png(path: Path, shade: int, stamp: float) -> None:
|
||||
with Image.new("RGB", (64, 48), (shade, 120, 255 - shade)) as image:
|
||||
image.save(path)
|
||||
os.utime(path, (stamp, stamp))
|
||||
|
||||
|
||||
def seed_collections(api: PromptManagerAPI, root: Path) -> dict:
|
||||
"""Replace this server's disposable rows and files with the two collections."""
|
||||
# The API package needs the host stubs the server installs before importing it.
|
||||
from py.api.images import _favorite_relative_key, _favorite_root_key
|
||||
|
||||
db = api.db
|
||||
with db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
replace_folder(root / name)
|
||||
folder = root / "output" / FOLDER
|
||||
folder.mkdir()
|
||||
# Newest first in this order: Landscapes, then Experiments, then the loose file.
|
||||
now = time.time()
|
||||
ordered = (*LANDSCAPE_FILES, *EXPERIMENT_FILES, LOOSE_FILE)
|
||||
for index, name in enumerate(ordered):
|
||||
_write_png(folder / name, (index * 11) % 256, now - 60 - index * 60)
|
||||
root_key = _favorite_root_key(list(api._get_all_output_dirs())[0])
|
||||
members = {
|
||||
LANDSCAPES: (*LANDSCAPE_FILES, MISSING_FILE),
|
||||
EXPERIMENTS: EXPERIMENT_FILES,
|
||||
}
|
||||
ids = {}
|
||||
for name, files in members.items():
|
||||
added = db.add_to_collection(
|
||||
name=name,
|
||||
items=[
|
||||
(root_key, _favorite_relative_key(f"{FOLDER}/{file}")) for file in files
|
||||
],
|
||||
)
|
||||
ids[name] = added["collection"]["id"]
|
||||
view = db.create_saved_view(
|
||||
"images", SAVED_VIEW, {**_IMAGES_STATE, "collection": str(ids[EXPERIMENTS])}
|
||||
)
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"collections": {
|
||||
LANDSCAPES: {"id": ids[LANDSCAPES], "count": 12, "missing": 1},
|
||||
EXPERIMENTS: {"id": ids[EXPERIMENTS], "count": 8, "missing": 0},
|
||||
},
|
||||
"files": {
|
||||
LANDSCAPES: [f"{FOLDER}/{file}" for file in LANDSCAPE_FILES],
|
||||
EXPERIMENTS: [f"{FOLDER}/{file}" for file in EXPERIMENT_FILES],
|
||||
"loose": f"{FOLDER}/{LOOSE_FILE}",
|
||||
},
|
||||
"saved_view": {"id": view["id"], "name": SAVED_VIEW},
|
||||
}
|
||||
|
||||
|
||||
def register_collections_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
|
||||
) -> None:
|
||||
"""Register the fixed seed command; no path or SQL comes from a request."""
|
||||
|
||||
@routes.post("/__test__/collections/seed")
|
||||
async def seed(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if data != {} or request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
manifest = await api._run_in_executor(seed_collections, api, root)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
@@ -174,6 +174,8 @@ _TABLES = (
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -26,6 +26,8 @@ EDITOR_METADATA = {
|
||||
"notes": "Keep the soft light.",
|
||||
}
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -78,6 +78,8 @@ LIMITED_SAMPLERS = 17
|
||||
# More than one queue page (40), so Load more has a second page.
|
||||
MAX_ITEMS = 45
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -44,6 +44,8 @@ MODELS = ("wd14-swinv2", "wd14-vit", "gguf", "hf")
|
||||
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
|
||||
MAX_EXTRA_IMAGES = 160
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -112,6 +112,8 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
"""Replace only the owned browser harness records and media roots."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -79,6 +79,8 @@ UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// Collections (#331 D136–D140): the client store, the route hash, the names, every user-facing string and the
|
||||
// mapping of the routes' error codes.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/collections.mjs');
|
||||
|
||||
const FILE = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'grids/one.png' });
|
||||
|
||||
function apiError(status, code) {
|
||||
return Object.assign(new Error(code || `HTTP ${status}`), { status, code: code || null });
|
||||
}
|
||||
|
||||
function fakeApi() {
|
||||
const calls = [];
|
||||
const pending = [];
|
||||
const api = {
|
||||
calls,
|
||||
get(url, options) {
|
||||
calls.push(['GET', url, options?.cache]);
|
||||
if (url.includes('/membership?')) return api.membershipResult ?? Promise.resolve({ success: true, collection_ids: [2] });
|
||||
return new Promise((resolve, reject) => pending.push({ resolve, reject }));
|
||||
},
|
||||
post(url, body) {
|
||||
calls.push(['POST', url, body]);
|
||||
return api.postResult ?? Promise.resolve({ success: true, collection: { id: 5, name: 'Grids' }, created: true,
|
||||
added: body.items.length, already: 0, not_found: 0 });
|
||||
},
|
||||
patch(url, body) {
|
||||
calls.push(['PATCH', url, body]);
|
||||
return api.patchResult ?? Promise.resolve({ success: true, collection: { id: 2, name: body.name } });
|
||||
},
|
||||
del(url, options) {
|
||||
calls.push(['DELETE', url, options?.body]);
|
||||
return api.deleteResult ?? Promise.resolve(options?.body
|
||||
? { success: true, collection: { id: options.body.collection_id, name: 'Portraits' }, removed: options.body.items.length }
|
||||
: { success: true, deleted: true });
|
||||
},
|
||||
};
|
||||
return { api, pending };
|
||||
}
|
||||
|
||||
const LISTED = { success: true, collections: [
|
||||
{ id: 4, name: 'Experiments', count: 8, missing: 0 },
|
||||
{ id: 2, name: 'Landscapes', count: 12, missing: 1 },
|
||||
] };
|
||||
|
||||
/** A store whose first read answered with `data`; the test's own timer seam replaces the 300 ms count timer. */
|
||||
async function readyStore(data = LISTED) {
|
||||
const { createCollections } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const timers = [];
|
||||
const controller = new AbortController();
|
||||
const store = createCollections(api, controller.signal, {
|
||||
setTimer: (run, delay) => { timers.push({ run, delay, cleared: false }); return timers.length; },
|
||||
clearTimer: id => { if (timers[id - 1]) timers[id - 1].cleared = true; },
|
||||
});
|
||||
store.load();
|
||||
pending.shift().resolve(data);
|
||||
await store.settled();
|
||||
return { store, api, pending, timers, controller };
|
||||
}
|
||||
|
||||
test('the route hash, the name rule and the limits', async () => {
|
||||
const { collectionHash, normalizeCollectionName, MAX_COLLECTION_NAME_LENGTH, MAX_COLLECTION_ITEMS,
|
||||
COLLECTION_COUNT_DELAY_MS } = await source;
|
||||
assert.equal(collectionHash(12), '#/collection/12');
|
||||
assert.throws(() => collectionHash(0), TypeError);
|
||||
assert.throws(() => collectionHash('3'), TypeError);
|
||||
assert.equal(MAX_COLLECTION_NAME_LENGTH, 100);
|
||||
assert.equal(MAX_COLLECTION_ITEMS, 500);
|
||||
assert.equal(COLLECTION_COUNT_DELAY_MS, 300);
|
||||
// Trimmed, each run of whitespace one space, 1 to 100 characters, no control characters (design §1).
|
||||
assert.equal(normalizeCollectionName(' LoRa testing grids '), 'LoRa testing grids');
|
||||
assert.equal(normalizeCollectionName('x'.repeat(100)), 'x'.repeat(100));
|
||||
for (const bad of ['', ' ', 'x'.repeat(101), 'bad\u0007name', 7, null, undefined]) {
|
||||
assert.equal(normalizeCollectionName(bad), null, String(bad));
|
||||
}
|
||||
// A tab or newline is whitespace, collapsed rather than refused.
|
||||
assert.equal(normalizeCollectionName('a\tb\nc'), 'a b c');
|
||||
});
|
||||
|
||||
test('the listing keeps usable rows in the server order, with counts only when they are counts', async () => {
|
||||
const { parseCollections, parseCollection } = await source;
|
||||
assert.deepEqual(parseCollections({ success: true, collections: [
|
||||
{ id: 3, name: 'b', count: 2, missing: 0 },
|
||||
{ id: 0, name: 'bad id' },
|
||||
{ id: 1, name: ' ', count: 1, missing: 0 },
|
||||
{ id: 7, name: 'A', count: null, missing: null },
|
||||
{ id: 9, name: 'c', count: -1, missing: 1.5 },
|
||||
] }), [
|
||||
{ id: 3, name: 'b', count: 2, missing: 0 },
|
||||
{ id: 7, name: 'A', count: null, missing: null },
|
||||
{ id: 9, name: 'c', count: null, missing: null },
|
||||
]);
|
||||
assert.equal(parseCollection(null), null);
|
||||
assert.throws(() => parseCollections({ success: true }), TypeError);
|
||||
});
|
||||
|
||||
test('every user-facing string of the design (§7.4, §8)', async () => {
|
||||
const c = await source;
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 0 }), 'Added 1 image to “X”.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 3, already: 0, notFound: 0 }), 'Added 3 images to “X”.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 1, notFound: 0 }),
|
||||
'Added 1 image to “X”. 1 was already in it.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 2, already: 3, notFound: 1 }),
|
||||
'Added 2 images to “X”. 3 were already in it. 1 could not be found.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 2 }),
|
||||
'Added 1 image to “X”. 2 could not be found.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 0, already: 4, notFound: 0 }),
|
||||
'All selected images were already in “X”.');
|
||||
assert.equal(c.FILES_NOT_FOUND_TOAST, 'None of the selected images could be found. Refresh the images and try again.');
|
||||
assert.equal(c.SOURCE_CHANGED_TOAST, 'The sources changed. Refresh the images and try again.');
|
||||
assert.equal(c.ADD_FAILED_TOAST, 'The images could not be added. Try again.');
|
||||
assert.equal(c.removedMessage('X', 1), 'Removed 1 image from “X”.');
|
||||
assert.equal(c.removedMessage('X', 4), 'Removed 4 images from “X”.');
|
||||
assert.equal(c.REMOVE_FAILED_TOAST, 'The images could not be removed. Try again.');
|
||||
assert.equal(c.COLLECTION_GONE_TOAST, 'That collection no longer exists.');
|
||||
assert.deepEqual([c.COLLECTION_NOUNS.title, c.COLLECTION_NOUNS.label, c.COLLECTION_NOUNS.deleteTitle],
|
||||
['Edit collection', 'Collection name', 'Delete collection']);
|
||||
assert.equal(c.COLLECTION_NOUNS.renamed, 'Collection renamed.');
|
||||
assert.equal(c.RENAMED_TOAST, 'Collection renamed.');
|
||||
assert.equal(c.nameTakenMessage('LoRa'), 'A collection named “LoRa” already exists.');
|
||||
assert.equal(c.RENAME_FAILED_TOAST, 'The collection could not be renamed. Try again.');
|
||||
// The stored count: listed members plus missing ones.
|
||||
assert.equal(c.deleteQuestion({ name: 'X', count: 1, missing: 0 }),
|
||||
'Delete “X”? Its 1 image stays where it is; only the collection is removed.');
|
||||
assert.equal(c.deleteQuestion({ name: 'X', count: 3, missing: 1 }),
|
||||
'Delete “X”? Its 4 images stay where they are; only the collection is removed.');
|
||||
assert.equal(c.deleteQuestion({ name: 'X', count: null, missing: null }),
|
||||
'Delete “X”? Its images stay where they are; only the collection is removed.');
|
||||
assert.equal(c.COLLECTION_NOUNS.deleteText({ name: 'X', count: 2, missing: 0 }), c.deleteQuestion({ name: 'X', count: 2, missing: 0 }));
|
||||
assert.equal(c.DELETED_TOAST, 'Collection deleted.');
|
||||
assert.equal(c.DELETE_FAILED_TOAST, 'The collection could not be deleted. Try again.');
|
||||
assert.equal(c.DELETED_PAGE_TEXT, 'This collection no longer exists.');
|
||||
assert.equal(c.OPEN_IMAGES_LINK, 'Open Images');
|
||||
assert.equal(c.VIEW_COLLECTION_DELETED, 'The collection this view uses was deleted.');
|
||||
assert.equal(c.LIST_FAILED_TEXT, 'Collections could not be loaded. Your saved data has not changed.');
|
||||
assert.equal(c.missingNote(1), '1 image in this collection is not in your current sources.');
|
||||
assert.equal(c.missingNote(5), '5 images in this collection are not in your current sources.');
|
||||
for (const none of [0, null, undefined, -1, 1.5]) assert.equal(c.missingNote(none), '');
|
||||
assert.equal(c.TOO_MANY_TOAST, 'Select at most 500 images.');
|
||||
assert.deepEqual([c.ADD_DIALOG_TITLE, c.ADD_DIALOG_LABEL, c.ADD_DIALOG_ACTION],
|
||||
['Add selected images to collection', 'Collection name', 'Add']);
|
||||
assert.equal(c.COLLECTIONS_UPDATED_TOAST, 'Collections updated.');
|
||||
assert.equal(c.UPDATE_FAILED_TEXT, 'The collections could not be updated. Try again.');
|
||||
assert.equal(c.MEMBERSHIP_LOADING_TEXT, 'Loading collections…');
|
||||
assert.equal(c.MEMBERSHIP_FAILED_TEXT, 'Collections could not be loaded.');
|
||||
assert.equal(c.NO_COLLECTIONS_TEXT, 'No collections yet. Name one below.');
|
||||
});
|
||||
|
||||
test('a write failure maps the routes\' error codes to their strings', async () => {
|
||||
const { writeFailure, renameFailure, isCollectionGone, COLLECTION_GONE_TOAST, SOURCE_CHANGED_TOAST,
|
||||
FILES_NOT_FOUND_TOAST, ADD_FAILED_TOAST, RENAME_FAILED_TOAST } = await source;
|
||||
assert.equal(writeFailure(apiError(404, 'collection-not-found'), ADD_FAILED_TOAST), COLLECTION_GONE_TOAST);
|
||||
assert.equal(writeFailure(apiError(409, 'source-changed'), ADD_FAILED_TOAST), SOURCE_CHANGED_TOAST);
|
||||
assert.equal(writeFailure(apiError(404, 'files-not-found'), ADD_FAILED_TOAST), FILES_NOT_FOUND_TOAST);
|
||||
for (const other of [apiError(500), apiError(400), apiError(404), new TypeError('x'), null]) {
|
||||
assert.equal(writeFailure(other, ADD_FAILED_TOAST), ADD_FAILED_TOAST);
|
||||
}
|
||||
assert.equal(isCollectionGone(apiError(404, 'collection-not-found')), true);
|
||||
assert.equal(isCollectionGone(apiError(404)), false);
|
||||
// The Edit dialog: a taken name stays in the dialog; a gone collection closes it; anything else keeps it open.
|
||||
assert.deepEqual(renameFailure('LoRa')(apiError(409, 'name-taken')), { field: 'A collection named “LoRa” already exists.' });
|
||||
assert.deepEqual(renameFailure('LoRa')(apiError(404, 'collection-not-found')), { toast: COLLECTION_GONE_TOAST, close: true });
|
||||
assert.deepEqual(renameFailure('LoRa')(apiError(500)), { toast: RENAME_FAILED_TOAST });
|
||||
});
|
||||
|
||||
test('the store reports loads, keeps the last list after a failed read, and a newer read supersedes', async () => {
|
||||
const { createCollections, COLLECTIONS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const store = createCollections(api);
|
||||
assert.equal(store.status, 'loading');
|
||||
const seen = [];
|
||||
store.subscribe(next => seen.push([next.status, next.collections.map(item => item.id)]));
|
||||
store.load();
|
||||
const settled = store.settled();
|
||||
pending[0].resolve(LISTED);
|
||||
assert.equal((await settled).status, 'ready');
|
||||
assert.deepEqual(api.calls[0], ['GET', COLLECTIONS_URL, 'no-store']);
|
||||
assert.deepEqual(store.find(2), { id: 2, name: 'Landscapes', count: 12, missing: 1 });
|
||||
assert.equal(store.find(3), null);
|
||||
assert.deepEqual(store.collections.map(item => item.name), ['Experiments', 'Landscapes']);
|
||||
|
||||
store.load();
|
||||
pending[1].reject(new Error('offline'));
|
||||
await store.settled();
|
||||
assert.equal(store.status, 'error');
|
||||
assert.deepEqual(store.collections.map(item => item.id), [4, 2]);
|
||||
|
||||
store.load();
|
||||
store.load();
|
||||
pending[3].resolve({ success: true, collections: [{ id: 8, name: 'New', count: 0, missing: 0 }] });
|
||||
pending[2].resolve(LISTED);
|
||||
await store.settled();
|
||||
assert.deepEqual(store.collections.map(item => item.id), [8]);
|
||||
assert.deepEqual(seen.at(-1), ['ready', [8]]);
|
||||
// A malformed listing is a failed read.
|
||||
store.load();
|
||||
pending[4].resolve({ success: true });
|
||||
await store.settled();
|
||||
assert.equal(store.status, 'error');
|
||||
});
|
||||
|
||||
test('adding by name or id sends only the file identity and re-reads the counts 300 ms after the last change', async () => {
|
||||
const { store, api, pending, timers } = await readyStore();
|
||||
const { COLLECTION_ITEMS_URL } = await source;
|
||||
const result = await store.add({ name: ' Grids ' }, [{ ...FILE, title: 'ignored', favorite: true }]);
|
||||
assert.deepEqual(api.calls.at(-1), ['POST', COLLECTION_ITEMS_URL, { name: 'Grids', items: [FILE] }]);
|
||||
assert.deepEqual(result, { collection: { id: 5, name: 'Grids' }, created: true, added: 1, already: 0, notFound: 0 });
|
||||
// The new collection is listed at once (its count arrives with the re-read), in name order.
|
||||
assert.deepEqual(store.collections.map(item => [item.id, item.count]), [[4, 8], [5, null], [2, 12]]);
|
||||
api.postResult = Promise.resolve({ success: true, collection: { id: 2, name: 'Landscapes' }, created: false, added: 0,
|
||||
already: 1, not_found: 1 });
|
||||
const again = await store.add({ collectionId: 2 }, [FILE, { ...FILE, path: 'b.png' }]);
|
||||
assert.deepEqual(api.calls.at(-1)[2], { collection_id: 2, items: [FILE, { ...FILE, path: 'b.png' }] });
|
||||
assert.deepEqual(again, { collection: { id: 2, name: 'Landscapes' }, created: false, added: 0, already: 1, notFound: 1 });
|
||||
// One trailing re-read for the run of writes.
|
||||
assert.deepEqual(timers.map(timer => [timer.delay, timer.cleared]), [[300, true], [300, false]]);
|
||||
const reads = api.calls.filter(call => call[0] === 'GET').length;
|
||||
timers.at(-1).run();
|
||||
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
|
||||
pending.shift().resolve(LISTED);
|
||||
await store.settled();
|
||||
});
|
||||
|
||||
test('a write is refused before sending when its target or files are not valid', async () => {
|
||||
const { store, api } = await readyStore();
|
||||
const sent = api.calls.length;
|
||||
const many = Array.from({ length: 501 }, (_, index) => ({ ...FILE, path: `${index}.png` }));
|
||||
for (const [target, items] of [
|
||||
[{ name: '' }, [FILE]], [{ name: 'x'.repeat(101) }, [FILE]], [{ collectionId: 0 }, [FILE]], [{}, [FILE]],
|
||||
[{ name: 'a', collectionId: 2 }, [FILE]], [{ collectionId: 2 }, []], [{ collectionId: 2 }, many],
|
||||
[{ collectionId: 2 }, [{ ...FILE, root_index: -1 }]], [{ collectionId: 2 }, [{ ...FILE, path: '' }]],
|
||||
[{ collectionId: 2 }, [{ ...FILE, root_scope: 3 }]], [{ collectionId: 2 }, 'a.png'],
|
||||
]) {
|
||||
await assert.rejects(store.add(target, items), TypeError);
|
||||
}
|
||||
await assert.rejects(store.remove(0, [FILE]), TypeError);
|
||||
await assert.rejects(store.remove(2, []), TypeError);
|
||||
await assert.rejects(store.rename(2, ' '), TypeError);
|
||||
await assert.rejects(store.delete(-1), TypeError);
|
||||
await assert.rejects(store.membership({ root_index: 'x', root_scope: 'a', path: 'p' }), TypeError);
|
||||
assert.equal(api.calls.length, sent);
|
||||
});
|
||||
|
||||
test('a write to a collection that is gone re-reads the list at once and rejects with the error', async () => {
|
||||
const { store, api, pending } = await readyStore();
|
||||
const { isCollectionGone } = await source;
|
||||
api.postResult = Promise.reject(apiError(404, 'collection-not-found'));
|
||||
const reads = api.calls.filter(call => call[0] === 'GET').length;
|
||||
await assert.rejects(store.add({ collectionId: 2 }, [FILE]), error => isCollectionGone(error));
|
||||
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
|
||||
pending.shift().resolve({ success: true, collections: [LISTED.collections[0]] });
|
||||
await store.settled();
|
||||
assert.equal(store.find(2), null);
|
||||
// Another failure re-reads nothing.
|
||||
api.deleteResult = Promise.reject(apiError(500));
|
||||
await assert.rejects(store.remove(4, [FILE]), error => error.status === 500);
|
||||
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
|
||||
});
|
||||
|
||||
test('remove sends the collection and files; membership reads one file\'s collections', async () => {
|
||||
const { store, api, timers } = await readyStore();
|
||||
const { COLLECTION_ITEMS_URL, COLLECTION_MEMBERSHIP_URL } = await source;
|
||||
assert.deepEqual(await store.remove(2, [FILE]), { collection: { id: 2, name: 'Portraits' }, removed: 1 });
|
||||
assert.deepEqual(api.calls.at(-1), ['DELETE', COLLECTION_ITEMS_URL, { collection_id: 2, items: [FILE] }]);
|
||||
assert.equal(timers.length, 1);
|
||||
assert.deepEqual(await store.membership(FILE), [2]);
|
||||
const [method, url] = api.calls.at(-1);
|
||||
assert.equal(method, 'GET');
|
||||
const query = new URL(url, 'http://local');
|
||||
assert.equal(query.pathname, COLLECTION_MEMBERSHIP_URL);
|
||||
assert.deepEqual(Object.fromEntries(query.searchParams), { root_index: '0', root_scope: FILE.root_scope, path: FILE.path });
|
||||
api.membershipResult = Promise.resolve({ success: true, collection_ids: [1, 'x'] });
|
||||
await assert.rejects(store.membership(FILE), TypeError);
|
||||
});
|
||||
|
||||
test('rename and delete change the listed row at once and re-read the counts', async () => {
|
||||
const { store, api, timers } = await readyStore();
|
||||
const { COLLECTIONS_URL } = await source;
|
||||
const renamed = await store.rename(2, ' A grids ');
|
||||
assert.deepEqual(api.calls.at(-1), ['PATCH', `${COLLECTIONS_URL}/2`, { name: 'A grids' }]);
|
||||
assert.deepEqual(renamed, { id: 2, name: 'A grids' });
|
||||
// The row keeps its counts and moves to its new place in name order.
|
||||
assert.deepEqual(store.collections.map(item => [item.id, item.name, item.count]), [[2, 'A grids', 12], [4, 'Experiments', 8]]);
|
||||
assert.equal(await store.delete(4), 4);
|
||||
assert.deepEqual(api.calls.at(-1), ['DELETE', `${COLLECTIONS_URL}/4`, undefined]);
|
||||
assert.deepEqual(store.collections.map(item => item.id), [2]);
|
||||
// Already gone (another tab): the outcome the user asked for.
|
||||
api.deleteResult = Promise.reject(apiError(404, 'collection-not-found'));
|
||||
assert.equal(await store.delete(2), 2);
|
||||
assert.deepEqual(store.collections, []);
|
||||
api.patchResult = Promise.reject(apiError(409, 'name-taken'));
|
||||
await assert.rejects(store.rename(9, 'Taken'), error => error.code === 'name-taken');
|
||||
assert.equal(timers.filter(timer => !timer.cleared).length, 1);
|
||||
});
|
||||
|
||||
test('a disposed store stops its count timer, drops a read in flight and reads no more', async () => {
|
||||
const { store, timers, controller, api, pending } = await readyStore();
|
||||
await store.rename(2, 'Kept');
|
||||
const seen = [];
|
||||
store.subscribe(next => seen.push(next));
|
||||
store.load();
|
||||
controller.abort();
|
||||
assert.equal(timers.at(-1).cleared, true);
|
||||
pending.shift().resolve(LISTED);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(seen.filter(next => next.status === 'ready').length, 0);
|
||||
const sent = api.calls.length;
|
||||
await store.load();
|
||||
assert.equal(api.calls.length, sent);
|
||||
});
|
||||
@@ -141,6 +141,22 @@ test('the Favorites page asks the listing for favorites only (#249 D51)', async
|
||||
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
|
||||
});
|
||||
|
||||
test('a collection page asks the listing for that collection only (#331 D137)', async () => {
|
||||
const {createImageStream}=await source;let query;
|
||||
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
|
||||
await stream.reset({collection:'12',q:'fog'});
|
||||
assert.equal(query.get('collection'),'12');assert.equal(query.get('q'),'fog');assert.equal(query.has('favorites'),false);
|
||||
await stream.reset({collection:7});
|
||||
assert.equal(query.get('collection'),'7');
|
||||
for(const collection of ['',null,undefined]) {
|
||||
await stream.reset({collection});
|
||||
assert.equal(query.has('collection'),false);
|
||||
}
|
||||
for(const collection of ['0','07','x',-1,1.5,'1234567890',true])await assert.rejects(stream.reset({collection}),/Invalid collection filter/);
|
||||
// Favorites and a collection are never both a page's filter.
|
||||
await assert.rejects(stream.reset({collection:'3',favorites:true}),/Invalid collection filter/);
|
||||
});
|
||||
|
||||
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
|
||||
const {createImageStream}=await source;
|
||||
const calls=[];
|
||||
@@ -500,5 +516,7 @@ test('listingFilter is the listing query as an object, without paging (#392)', a
|
||||
assert.deepEqual(listingFilter({q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:[' x ','y'], media:'video', sort:'oldest', favorites:true}),
|
||||
{q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:'x,y', media:'video', sort:'oldest', favorites:'1'});
|
||||
assert.deepEqual(listingFilter({root:'all', q:'', tags:[], media:'all'}), {sort:'newest'});
|
||||
// #331: "Select all N matching" on a collection page acts on that collection's listing.
|
||||
assert.deepEqual(listingFilter({collection:'4', q:'cat'}), {q:'cat', sort:'newest', collection:'4'});
|
||||
assert.throws(() => listingFilter({sort:'sideways'}), TypeError);
|
||||
});
|
||||
|
||||
@@ -311,3 +311,29 @@ test('Needs review is a native SAVED VIEWS route titled Review, with its own cou
|
||||
assert.deepEqual(calls, ['/prompt_manager/review/items?limit=1']);
|
||||
assert.deepEqual([...counts], [['review', 4]]);
|
||||
});
|
||||
|
||||
// Collections (#331 D137): `#/collection/<id>` opens Images filtered to one collection; its breadcrumb is "Collection".
|
||||
test('collection links resolve to their id, title Collection, and re-render only when the id changes', async () => {
|
||||
const {resolveRoute,mountRouter,isHandoff,routeTitle,routes,COLLECTION_ROUTE}=await source;
|
||||
assert.deepEqual(COLLECTION_ROUTE,{id:'collection',label:'Collection'});
|
||||
assert.ok(Object.isFrozen(COLLECTION_ROUTE));
|
||||
const route=resolveRoute('#/collection/7');
|
||||
assert.deepEqual([route.id,route.collectionId,route.hash,routeTitle(route)],['collection',7,'#/collection/7','Collection']);
|
||||
assert.equal(isHandoff(route),false);
|
||||
assert.equal(resolveRoute('#/collection/999999999').collectionId,999999999);
|
||||
assert.equal(routes.some(item=>item.id==='collection'),false,'a collection is not an Overview card or a WORKSPACE link');
|
||||
// The listing's and routes' id rule: 1 to 9 digits without a leading zero.
|
||||
for(const hash of ['#/collection','#/collection/','#/collection/0','#/collection/07','#/collection/-2','#/collection/1.5',
|
||||
'#/collection/1234567890','#/collection/3/x','#/collection/abc','#/collections/3']) {
|
||||
assert.equal(resolveRoute(hash).id,'images',hash);
|
||||
}
|
||||
const win=new EventTarget();win.location={hash:'#/collection/3'};
|
||||
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
||||
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.collectionId]));
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen,[['collection',3],['collection',4],['saved',undefined],['collection',4]]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
+15
-9
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"admin.js": {
|
||||
"lines": 37,
|
||||
"branches": 61,
|
||||
"functions": 32,
|
||||
"statements": 37
|
||||
"lines": 39,
|
||||
"branches": 62,
|
||||
"functions": 35,
|
||||
"statements": 39
|
||||
},
|
||||
"api-client.js": {
|
||||
"lines": 98,
|
||||
@@ -49,7 +49,7 @@
|
||||
},
|
||||
"gallery.js": {
|
||||
"lines": 49,
|
||||
"branches": 68,
|
||||
"branches": 69,
|
||||
"functions": 50,
|
||||
"statements": 49
|
||||
},
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"image-helpers.js": {
|
||||
"lines": 96,
|
||||
"branches": 91,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"metadata-autocomplete.js": {
|
||||
"lines": 98,
|
||||
"branches": 82,
|
||||
"branches": 85,
|
||||
"functions": 85,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -197,6 +197,12 @@
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/collections.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/file-inspector.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 93,
|
||||
@@ -355,7 +361,7 @@
|
||||
},
|
||||
"workspace/library-location.mjs": {
|
||||
"lines": 94,
|
||||
"branches": 91,
|
||||
"branches": 92,
|
||||
"functions": 93,
|
||||
"statements": 94
|
||||
},
|
||||
@@ -403,7 +409,7 @@
|
||||
},
|
||||
"workspace/local-resolver.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 86,
|
||||
"branches": 87,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Collections (#331): named, hand-picked groups of images, persisted through `/prompt_manager/collections*`.
|
||||
*
|
||||
* - D136: the sidebar COLLECTIONS group (the concept's `#collections-nav`, index.html 28–31, app.js 336–339) lists
|
||||
* each collection as `#/collection/<id>` with its listed-member count, without the concept's "PROPOSED" badge.
|
||||
* - D137: a collection page is Images filtered to the collection; its Edit renames or deletes it through the saved
|
||||
* views' Edit dialog (D51) with collection nouns.
|
||||
* - This module holds the client store (`createCollections`), the route hash, the name rule and every user-facing
|
||||
* string of the design (§7.4, §8), so the selection bar, the inspector and the shell say the same things.
|
||||
*/
|
||||
import { noun } from './plural.mjs';
|
||||
import { openEditDialog } from './saved-views.mjs';
|
||||
|
||||
export const COLLECTIONS_URL = '/prompt_manager/collections';
|
||||
export const COLLECTION_ITEMS_URL = `${COLLECTIONS_URL}/items`;
|
||||
export const COLLECTION_MEMBERSHIP_URL = `${COLLECTIONS_URL}/membership`;
|
||||
export const MAX_COLLECTION_NAME_LENGTH = 100;
|
||||
// The routes' cap on one add or remove (the bulk tagging cap, MAX_BULK_IMAGES).
|
||||
export const MAX_COLLECTION_ITEMS = 500;
|
||||
// Counts are re-read once after a run of confirmed changes settles, as the Favorites count (#249 M1).
|
||||
export const COLLECTION_COUNT_DELAY_MS = 300;
|
||||
|
||||
// Strings (design §7.4 and §8).
|
||||
export const ADD_DIALOG_TITLE = 'Add selected images to collection';
|
||||
export const ADD_DIALOG_LABEL = 'Collection name';
|
||||
export const ADD_DIALOG_ACTION = 'Add';
|
||||
export const TOO_MANY_TOAST = `Select at most ${MAX_COLLECTION_ITEMS} images.`;
|
||||
export const FILES_NOT_FOUND_TOAST = 'None of the selected images could be found. Refresh the images and try again.';
|
||||
export const SOURCE_CHANGED_TOAST = 'The sources changed. Refresh the images and try again.';
|
||||
export const ADD_FAILED_TOAST = 'The images could not be added. Try again.';
|
||||
export const REMOVE_FAILED_TOAST = 'The images could not be removed. Try again.';
|
||||
export const COLLECTION_GONE_TOAST = 'That collection no longer exists.';
|
||||
export const RENAMED_TOAST = 'Collection renamed.';
|
||||
export const RENAME_FAILED_TOAST = 'The collection could not be renamed. Try again.';
|
||||
export const DELETED_TOAST = 'Collection deleted.';
|
||||
export const DELETE_FAILED_TOAST = 'The collection could not be deleted. Try again.';
|
||||
export const DELETED_PAGE_TEXT = 'This collection no longer exists.';
|
||||
export const OPEN_IMAGES_LINK = 'Open Images';
|
||||
export const VIEW_COLLECTION_DELETED = 'The collection this view uses was deleted.';
|
||||
export const LIST_FAILED_TEXT = 'Collections could not be loaded. Your saved data has not changed.';
|
||||
export const COLLECTIONS_UPDATED_TOAST = 'Collections updated.';
|
||||
export const UPDATE_FAILED_TEXT = 'The collections could not be updated. Try again.';
|
||||
export const MEMBERSHIP_LOADING_TEXT = 'Loading collections…';
|
||||
export const MEMBERSHIP_FAILED_TEXT = 'Collections could not be loaded.';
|
||||
export const NO_COLLECTIONS_TEXT = 'No collections yet. Name one below.';
|
||||
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/;
|
||||
const isCount = value => Number.isSafeInteger(value) && value >= 0;
|
||||
const isId = value => Number.isSafeInteger(value) && value >= 1 && value <= 999_999_999;
|
||||
|
||||
/** The route of one collection. */
|
||||
export function collectionHash(id) {
|
||||
if (!isId(id)) throw new TypeError('Invalid collection id');
|
||||
return `#/collection/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A name as the database stores it (design §1): trimmed, each run of whitespace one space, 1 to 100 characters
|
||||
* without control characters; null otherwise. Tabs and newlines are whitespace, so they collapse.
|
||||
*/
|
||||
export function normalizeCollectionName(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const name = value.trim().replace(/\s+/g, ' ');
|
||||
return name.length >= 1 && name.length <= MAX_COLLECTION_NAME_LENGTH && !CONTROL.test(name) ? name : null;
|
||||
}
|
||||
|
||||
/** One listed collection, or null when the row is not usable. `count` and `missing` are null unless counts. */
|
||||
export function parseCollection(row) {
|
||||
if (!isId(row?.id) || typeof row.name !== 'string' || !row.name.trim()) return null;
|
||||
const counted = isCount(row.count) && isCount(row.missing);
|
||||
return { id: row.id, name: row.name, count: counted ? row.count : null, missing: counted ? row.missing : null };
|
||||
}
|
||||
|
||||
/** The listing's collections in the server's order (name, then id), skipping unusable rows. */
|
||||
export function parseCollections(data) {
|
||||
if (!Array.isArray(data?.collections)) throw new TypeError('Invalid collections');
|
||||
return data.collections.map(parseCollection).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */
|
||||
const nameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC');
|
||||
const ordered = list => [...list].sort((a, b) => {
|
||||
const left = nameKey(a.name), right = nameKey(b.name);
|
||||
return left < right ? -1 : left > right ? 1 : a.id - b.id;
|
||||
});
|
||||
|
||||
/** "Added N images to “X”." with what was already in it and what could not be found. */
|
||||
export function addedMessage({ collection, added, already, notFound }) {
|
||||
if (added === 0 && notFound === 0) return `All selected images were already in “${collection.name}”.`;
|
||||
let text = `Added ${added} ${noun(added, 'image')} to “${collection.name}”.`;
|
||||
if (already > 0) text += already === 1 ? ' 1 was already in it.' : ` ${already} were already in it.`;
|
||||
if (notFound > 0) text += ` ${notFound} could not be found.`;
|
||||
return text;
|
||||
}
|
||||
|
||||
export function removedMessage(name, removed) {
|
||||
return `Removed ${removed} ${noun(removed, 'image')} from “${name}”.`;
|
||||
}
|
||||
|
||||
export function nameTakenMessage(name) {
|
||||
return `A collection named “${name}” already exists.`;
|
||||
}
|
||||
|
||||
/** The Delete confirmation, with the stored count (listed plus missing) when the counts are known. */
|
||||
export function deleteQuestion({ name, count, missing }) {
|
||||
if (!isCount(count) || !isCount(missing)) {
|
||||
return `Delete “${name}”? Its images stay where they are; only the collection is removed.`;
|
||||
}
|
||||
const stored = count + missing;
|
||||
return stored === 1
|
||||
? `Delete “${name}”? Its 1 image stays where it is; only the collection is removed.`
|
||||
: `Delete “${name}”? Its ${stored} images stay where they are; only the collection is removed.`;
|
||||
}
|
||||
|
||||
/** The collection page's note about members its sources do not list now (MC3); empty when there are none. */
|
||||
export function missingNote(missing) {
|
||||
if (!isCount(missing) || missing === 0) return '';
|
||||
return missing === 1
|
||||
? '1 image in this collection is not in your current sources.'
|
||||
: `${missing} images in this collection are not in your current sources.`;
|
||||
}
|
||||
|
||||
export const isCollectionGone = error => error?.code === 'collection-not-found';
|
||||
|
||||
/** A failed add or remove's toast: the routes' error codes, else `fallback`. */
|
||||
export function writeFailure(error, fallback) {
|
||||
if (isCollectionGone(error)) return COLLECTION_GONE_TOAST;
|
||||
if (error?.code === 'source-changed') return SOURCE_CHANGED_TOAST;
|
||||
if (error?.code === 'files-not-found') return FILES_NOT_FOUND_TOAST;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** The Edit dialog's mapping of a failed rename of `name` (openEditDialog `renameError`). */
|
||||
export function renameFailure(name) {
|
||||
return error => {
|
||||
if (error?.code === 'name-taken') return { field: nameTakenMessage(name) };
|
||||
if (isCollectionGone(error)) return { toast: COLLECTION_GONE_TOAST, close: true };
|
||||
return { toast: RENAME_FAILED_TOAST };
|
||||
};
|
||||
}
|
||||
|
||||
/** The collection nouns of the shared Edit dialog (D51, D137). */
|
||||
export const COLLECTION_NOUNS = Object.freeze({
|
||||
title: 'Edit collection', label: 'Collection name', deleteTitle: 'Delete collection', deleteText: deleteQuestion,
|
||||
renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST,
|
||||
});
|
||||
|
||||
/**
|
||||
* D137: the open collection's Edit. A taken name stays in the dialog under the field; a failed rename keeps it open;
|
||||
* a collection deleted meanwhile closes it and runs `onGone()` after the toast. `onRename(name)` sends the normalized
|
||||
* name; a delete's 404 counts as deleted (the store's `delete`).
|
||||
*/
|
||||
export function openEditCollectionDialog({ scope, trigger, collection, onRename, onDelete, onGone = () => {} }) {
|
||||
let attempted = '';
|
||||
const mapping = error => {
|
||||
const outcome = renameFailure(attempted)(error);
|
||||
if (outcome.close) queueMicrotask(onGone);
|
||||
return outcome;
|
||||
};
|
||||
openEditDialog({ scope, trigger, item: collection, nouns: COLLECTION_NOUNS, onDelete, renameError: mapping,
|
||||
onRename: name => {
|
||||
attempted = normalizeCollectionName(name) ?? name;
|
||||
return onRename(attempted);
|
||||
} });
|
||||
}
|
||||
|
||||
function fileTargets(items) {
|
||||
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_COLLECTION_ITEMS) {
|
||||
throw new TypeError('A collection change needs 1 to 500 files');
|
||||
}
|
||||
return items.map(item => {
|
||||
if (!Number.isSafeInteger(item?.root_index) || item.root_index < 0 || typeof item.root_scope !== 'string' ||
|
||||
!item.root_scope || typeof item.path !== 'string' || !item.path) {
|
||||
throw new TypeError('Invalid collection file');
|
||||
}
|
||||
return { root_index: item.root_index, root_scope: item.root_scope, path: item.path };
|
||||
});
|
||||
}
|
||||
|
||||
function requireId(id) {
|
||||
if (!isId(id)) throw new TypeError('Invalid collection id');
|
||||
return id;
|
||||
}
|
||||
|
||||
function requireName(name) {
|
||||
const normalized = normalizeCollectionName(name);
|
||||
if (normalized === null) throw new TypeError('Invalid collection name');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** The `{id, name}` a write answered with. */
|
||||
function answered(data) {
|
||||
const collection = parseCollection(data?.collection);
|
||||
if (!collection) throw new TypeError('Invalid collection');
|
||||
return { id: collection.id, name: collection.name };
|
||||
}
|
||||
|
||||
function countOf(value) {
|
||||
if (!isCount(value)) throw new TypeError('Invalid collection count');
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's collection list. `load()` re-reads it (a newer read supersedes an older one), `subscribe(fn)` reports
|
||||
* `{collections, status}` on every change, `settled()` resolves once no read is in flight; `status` is 'loading',
|
||||
* 'ready' or 'error' and a failed read keeps the last list. `add`, `remove`, `rename` and `delete` change the listed
|
||||
* rows at once and re-read the counts once, 300 ms after the last confirmed change; a write to a collection that is
|
||||
* gone (404 `collection-not-found`) re-reads the list at once and rejects. `timers` is a test seam.
|
||||
*/
|
||||
export function createCollections(api, signal, { setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
||||
let collections = [], status = 'loading', request = null, timer = 0;
|
||||
const listeners = new Set();
|
||||
let waiting = [];
|
||||
const emit = () => {
|
||||
const snapshot = { collections, status };
|
||||
for (const listener of listeners) listener(snapshot);
|
||||
if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); }
|
||||
};
|
||||
const stopTimer = () => { if (timer) clearTimer(timer); timer = 0; };
|
||||
signal?.addEventListener('abort', () => { stopTimer(); request?.abort(); }, { once: true });
|
||||
function load() {
|
||||
if (signal?.aborted) return Promise.resolve();
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
request = controller;
|
||||
status = 'loading';
|
||||
emit();
|
||||
return api.get(COLLECTIONS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => {
|
||||
if (request !== controller) return;
|
||||
collections = parseCollections(data);
|
||||
status = 'ready';
|
||||
}).catch(() => {
|
||||
if (request !== controller) return;
|
||||
status = 'error';
|
||||
}).finally(() => {
|
||||
if (request !== controller || signal?.aborted) return;
|
||||
request = null;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
function changed(next) {
|
||||
collections = ordered(next);
|
||||
emit();
|
||||
stopTimer();
|
||||
timer = setTimer(() => { timer = 0; load(); }, COLLECTION_COUNT_DELAY_MS);
|
||||
}
|
||||
/** Send one write; a collection found gone meanwhile re-reads the list before the error reaches the caller. */
|
||||
async function send(write) {
|
||||
try {
|
||||
return await write();
|
||||
} catch (error) {
|
||||
if (isCollectionGone(error)) load();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function add(target, items) {
|
||||
const byId = target?.collectionId !== undefined, byName = target?.name !== undefined;
|
||||
if (byId === byName) throw new TypeError('A collection is named by its id or a name');
|
||||
const body = byId ? { collection_id: requireId(target.collectionId) } : { name: requireName(target.name) };
|
||||
body.items = fileTargets(items);
|
||||
const data = await send(() => api.post(COLLECTION_ITEMS_URL, body, { signal }));
|
||||
const result = { collection: answered(data), created: data.created === true, added: countOf(data.added),
|
||||
already: countOf(data.already), notFound: countOf(data.not_found) };
|
||||
const { id, name } = result.collection;
|
||||
changed(collections.some(item => item.id === id)
|
||||
? collections.map(item => item.id === id ? { ...item, name } : item)
|
||||
: [...collections, { id, name, count: null, missing: null }]);
|
||||
return result;
|
||||
}
|
||||
async function remove(id, items) {
|
||||
const body = { collection_id: requireId(id), items: fileTargets(items) };
|
||||
const data = await send(() => api.del(COLLECTION_ITEMS_URL, { signal, body }));
|
||||
const result = { collection: answered(data), removed: countOf(data.removed) };
|
||||
changed(collections);
|
||||
return result;
|
||||
}
|
||||
async function membership(identity) {
|
||||
const [file] = fileTargets([identity]);
|
||||
const query = new URLSearchParams({ root_index: String(file.root_index), root_scope: file.root_scope, path: file.path });
|
||||
const data = await api.get(`${COLLECTION_MEMBERSHIP_URL}?${query}`, { signal, cache: 'no-store' });
|
||||
const ids = data?.collection_ids;
|
||||
if (!Array.isArray(ids) || !ids.every(isId)) throw new TypeError('Invalid collection membership');
|
||||
return [...ids];
|
||||
}
|
||||
async function rename(id, name) {
|
||||
const url = `${COLLECTIONS_URL}/${requireId(id)}`;
|
||||
const body = { name: requireName(name) };
|
||||
const renamed = answered(await send(() => api.patch(url, body, { signal })));
|
||||
changed(collections.map(item => item.id === renamed.id ? { ...item, name: renamed.name } : item));
|
||||
return renamed;
|
||||
}
|
||||
async function deleteCollection(id) {
|
||||
const url = `${COLLECTIONS_URL}/${requireId(id)}`;
|
||||
try {
|
||||
await api.del(url, { signal });
|
||||
} catch (error) {
|
||||
// Already gone (another tab deleted it): the outcome the user asked for.
|
||||
if (error?.status !== 404) throw error;
|
||||
}
|
||||
changed(collections.filter(item => item.id !== id));
|
||||
return id;
|
||||
}
|
||||
return {
|
||||
load, add, remove, membership, rename, delete: deleteCollection,
|
||||
get collections() { return collections; },
|
||||
get status() { return status; },
|
||||
find: id => collections.find(item => item.id === id) ?? null,
|
||||
settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ collections, status }),
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,14 @@ function queryFor(filters, batchSize) {
|
||||
const favorites = filters.favorites ?? false;
|
||||
if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter');
|
||||
if (favorites) query.set('favorites', '1');
|
||||
// A collection page (#331 D137): the listing keeps only that collection's members; never together with favorites.
|
||||
const collection = filters.collection ?? '';
|
||||
if (collection !== '') {
|
||||
if (favorites || !/^[1-9]\d{0,8}$/.test(typeof collection === 'number' ? String(collection) : collection)) {
|
||||
throw new TypeError('Invalid collection filter');
|
||||
}
|
||||
query.set('collection', String(collection));
|
||||
}
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openRefineDialog, resc
|
||||
import { openMetadataScan } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
import { VIEW_COLLECTION_DELETED, isCollectionGone } from './collections.mjs';
|
||||
import { displayPreferences } from './preferences.mjs';
|
||||
import { onImageTagsSaved } from './image-tagging.mjs';
|
||||
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
|
||||
@@ -63,6 +64,8 @@ const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name',
|
||||
['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]);
|
||||
const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]);
|
||||
const RETRY_RESCAN = 'Rescan from Import to retry.';
|
||||
// The concept's empty collection (app.js updateImages).
|
||||
const NO_MATCHES = 'No matching images. Try another search or category.';
|
||||
const RETRY_REFRESH = 'Refresh image collection to retry.';
|
||||
/** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */
|
||||
function loadFailure(sourcesRetry, categoriesFailed) {
|
||||
@@ -96,10 +99,13 @@ export function selectedImage() {
|
||||
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
|
||||
* `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view.
|
||||
* `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the
|
||||
* Images last visit.
|
||||
* Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is
|
||||
* off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls
|
||||
* `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a
|
||||
* deleted collection, D140) the page lists nothing and says so.
|
||||
*/
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
|
||||
favorites = false, onSaveView = () => {}, activity = null} = {}) {
|
||||
favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null} = {}) {
|
||||
const scope = createScope();
|
||||
const dispose = () => scope.dispose();
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
@@ -114,7 +120,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
const saved = embedded || !savedState ? null : imagesFilters(savedState);
|
||||
const prior = embedded || saved || favorites ? null : lastVisit;
|
||||
// The open collection: the route's, else a saved view's (D140); '' on every other page.
|
||||
const openCollection = embedded ? '' : collectionId !== null ? String(collectionId) : saved?.collection || '';
|
||||
const prior = embedded || saved || favorites || openCollection ? null : lastVisit;
|
||||
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
|
||||
const visit = prior?.stale ? null : prior;
|
||||
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
|
||||
@@ -131,7 +139,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const rendered = new Map();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved,
|
||||
favorites: favorites || saved?.favorites === true };
|
||||
// A collection and favorites are never both the filter (DC10).
|
||||
favorites: !openCollection && (favorites || saved?.favorites === true), collection: openCollection };
|
||||
filters.tags = [...filters.tags];
|
||||
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
|
||||
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
|
||||
@@ -142,7 +151,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
|
||||
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
|
||||
// Images return state (#249 D51, and M2 for saved views).
|
||||
if (!embedded && !filters.favorites && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
cancelAnimationFrame(restoreFrame);
|
||||
});
|
||||
main.classList.add('images-main');
|
||||
@@ -237,7 +246,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards.
|
||||
// An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620).
|
||||
const showsEmpty = !embedded || typeof emptyText === 'string';
|
||||
const empty = el('div', embedded ? emptyText : 'No matching images. Try another search or category.', 'empty');
|
||||
const empty = el('div', embedded ? emptyText : NO_MATCHES, 'empty');
|
||||
empty.hidden = true;
|
||||
// D47: the history page's shared gallery and dialogs are named by their own heading or title, so they have no caption.
|
||||
if (!embedded) collection.append(caption);
|
||||
@@ -457,8 +466,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); });
|
||||
});
|
||||
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
|
||||
/** The listing refused the open collection: it was deleted (MC6, D140). */
|
||||
const collectionGone = next => Boolean(filters.collection) && isCollectionGone(next.error);
|
||||
let reportedMissing = false;
|
||||
function continuationText(next) {
|
||||
if (recovering) return 'Images paused until sources and folders are available.';
|
||||
if (collectionGone(next)) return '';
|
||||
if (next.error) return 'Images could not be loaded. Retry or rescan the collection.';
|
||||
if (next.loading && !next.loaded) return 'Loading images…';
|
||||
if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`;
|
||||
@@ -492,12 +505,18 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const added = next.loaded - previous.loaded;
|
||||
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
|
||||
}
|
||||
const gone = collectionGone(next);
|
||||
setText(message, continuationText(next));
|
||||
setAttr(message, 'role', next.error ? 'alert' : 'status');
|
||||
retry.hidden = !next.error;
|
||||
setAttr(message, 'role', next.error && !gone ? 'alert' : 'status');
|
||||
retry.hidden = !next.error || gone;
|
||||
more.hidden = !next.hasMore || !!next.error;
|
||||
more.setAttribute('aria-disabled', String(next.loading || recovering));
|
||||
if (showsEmpty) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0;
|
||||
if (gone && onCollectionMissing) {
|
||||
if (!reportedMissing) { reportedMissing = true; queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); }); }
|
||||
} else if (showsEmpty) {
|
||||
setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES);
|
||||
empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0);
|
||||
}
|
||||
if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
|
||||
if (!selection && next.items.length) inspect(next.items[0]);
|
||||
scheduleLoad();
|
||||
|
||||
+109
-14
@@ -1,6 +1,6 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo,
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
|
||||
@@ -11,6 +11,8 @@ import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading, pageHeading } from './heading.mjs';
|
||||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
|
||||
import { createCollections, collectionHash, openEditCollectionDialog, missingNote, DELETED_PAGE_TEXT, OPEN_IMAGES_LINK,
|
||||
LIST_FAILED_TEXT } from './collections.mjs';
|
||||
import { createActivityStore } from './activity-store.mjs';
|
||||
import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
|
||||
import { mountActivityJobView } from './activity-job-view.mjs';
|
||||
@@ -44,6 +46,7 @@ function mountWorkspace() {
|
||||
const main = document.getElementById('workspace-main');
|
||||
const primaryNav = document.getElementById('primary-nav');
|
||||
const savedNav = document.getElementById('saved-nav');
|
||||
const collectionsNav = document.getElementById('collections-nav');
|
||||
const systemNav = document.getElementById('system-nav');
|
||||
scope.listen(document.querySelector('.skip'), 'click', event => {
|
||||
event.preventDefault();
|
||||
@@ -83,8 +86,9 @@ function mountWorkspace() {
|
||||
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
|
||||
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
|
||||
const sidebar = document.querySelector('aside.sidebar');
|
||||
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
|
||||
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
|
||||
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`, a collection's `collection/<id>`.
|
||||
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}`
|
||||
: route.id === 'collection' ? `collection/${route.collectionId}` : route.id;
|
||||
let currentKey = null;
|
||||
const markCurrent = () => {
|
||||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||||
@@ -110,6 +114,24 @@ function mountWorkspace() {
|
||||
markCurrent();
|
||||
}));
|
||||
savedViews.load();
|
||||
// COLLECTIONS (#331 /* D136 */): the concept's collections-nav rows (folder icon, name, the listed-member count once
|
||||
// read), without the "PROPOSED" badge. The label shows only while a collection is listed. A failed read keeps the
|
||||
// last rows; a collection page says the list could not be loaded.
|
||||
const collectionsLabel = collectionsNav.previousElementSibling;
|
||||
const collections = createCollections(api, scope.signal);
|
||||
scope.cleanup(collections.subscribe(({ collections: listed }) => {
|
||||
collectionsLabel.hidden = listed.length === 0;
|
||||
collectionsNav.replaceChildren(...listed.map(item => {
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon('folder'), document.createTextNode(item.name));
|
||||
if (Number.isSafeInteger(item.count)) link.append(element('span', item.count.toLocaleString(), 'nav-count'));
|
||||
link.href = collectionHash(item.id);
|
||||
link.dataset.route = `collection/${item.id}`;
|
||||
return link;
|
||||
}));
|
||||
markCurrent();
|
||||
}));
|
||||
collections.load();
|
||||
/** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */
|
||||
const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q,
|
||||
onSave: name => savedViews.create(page, name, state) });
|
||||
@@ -167,6 +189,7 @@ function mountWorkspace() {
|
||||
forgetLibraryVisit();
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
savedViews.load();
|
||||
collections.load();
|
||||
if (currentKey !== 'activity') navigateTo(window.location.hash);
|
||||
}));
|
||||
// S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back:
|
||||
@@ -213,6 +236,10 @@ function mountWorkspace() {
|
||||
openSavedView(route, view);
|
||||
return;
|
||||
}
|
||||
if (route.id === 'collection') {
|
||||
openCollectionPage(route, view);
|
||||
return;
|
||||
}
|
||||
renderPage(route, view);
|
||||
}
|
||||
|
||||
@@ -230,7 +257,7 @@ function mountWorkspace() {
|
||||
if (saved) {
|
||||
const page = routes.find(item => item.id === saved.page);
|
||||
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
|
||||
mountEditAction(saved, view);
|
||||
mountSavedViewEdit(saved, view);
|
||||
return;
|
||||
}
|
||||
if (savedViews.status === 'ready') {
|
||||
@@ -249,13 +276,19 @@ function mountWorkspace() {
|
||||
main.append(retry);
|
||||
}
|
||||
|
||||
// /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and
|
||||
// retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page.
|
||||
function mountEditAction(saved, view) {
|
||||
// /* D51 */ An open sidebar item's Edit (maintainer ruling): first in the heading's actions; `open(trigger)` opens
|
||||
// its dialog. Saved views and collections (D137) share it.
|
||||
function mountEditAction(view, open) {
|
||||
// renderPage has just written the heading.
|
||||
const edit = createButton('Edit');
|
||||
pageHeading(main).actions.prepend(edit);
|
||||
view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved,
|
||||
view.listen(edit, 'click', () => open(edit));
|
||||
}
|
||||
|
||||
// A rename re-reads the list and retitles the open page; a delete re-reads it and replaces the dead route with the
|
||||
// view's plain page.
|
||||
function mountSavedViewEdit(saved, view) {
|
||||
mountEditAction(view, trigger => openEditViewDialog({ scope: view, trigger, view: saved,
|
||||
onRename: async name => {
|
||||
const renamed = await savedViews.rename(saved.id, name);
|
||||
if (view.signal.aborted) return;
|
||||
@@ -268,14 +301,76 @@ function mountWorkspace() {
|
||||
} }));
|
||||
}
|
||||
|
||||
function retitle(name) {
|
||||
/** `name` becomes the h1; the breadcrumb and title too unless the page keeps its own (a collection's "Collection"). */
|
||||
function retitle(name, title = name) {
|
||||
// Only the mounted view retitles (its rename returns once aborted), so its heading is present.
|
||||
pageHeading(main).h1.textContent = name;
|
||||
document.getElementById('workspace-breadcrumb').textContent = name;
|
||||
document.title = `${name} · Prompt Manager`;
|
||||
document.getElementById('workspace-breadcrumb').textContent = title;
|
||||
document.title = `${title} · Prompt Manager`;
|
||||
}
|
||||
|
||||
function renderPage(route, view, saved = null) {
|
||||
/**
|
||||
* #331 D137: `#/collection/<id>` is Images filtered to the collection, its name the h1, "Collection" the breadcrumb
|
||||
* and title (concept a1186–1189), with Edit and the note about members the sources do not list now. An unknown id
|
||||
* is read once more (another tab may have made it), then shows the deleted-collection page.
|
||||
*/
|
||||
async function openCollectionPage(route, view) {
|
||||
main.replaceChildren();
|
||||
await collections.settled();
|
||||
if (view.signal.aborted) return;
|
||||
if (!collections.find(route.collectionId) && collections.status === 'ready') {
|
||||
await collections.load();
|
||||
if (view.signal.aborted) return;
|
||||
}
|
||||
let collection = collections.find(route.collectionId);
|
||||
const showAgain = () => { if (!view.signal.aborted) navigateTo(window.location.hash); };
|
||||
if (collection) {
|
||||
renderPage({...COLLECTION_ROUTE, label:collection.name, title:routeTitle(COLLECTION_ROUTE)}, view, null, {collection:collection.id,
|
||||
// The listing found the collection gone meanwhile: the list is read again and, once it no longer lists
|
||||
// the collection, the page is shown again (the deleted-collection page).
|
||||
onCollectionMissing:() => collections.load().then(() => {
|
||||
if (!collections.find(route.collectionId)) showAgain();
|
||||
})});
|
||||
const note = missingNote(collection.missing);
|
||||
if (note) pageHeading(main).description.after(element('p', note, 'small'));
|
||||
// The dialog's Delete question uses the latest stored count.
|
||||
mountEditAction(view, trigger => openEditCollectionDialog({ scope: view, trigger,
|
||||
collection: collection = {...collection, ...collections.find(collection.id), name:collection.name},
|
||||
onRename: async name => {
|
||||
const renamed = await collections.rename(collection.id, name);
|
||||
if (view.signal.aborted) return;
|
||||
collection = {...collection, ...renamed};
|
||||
retitle(renamed.name, routeTitle(COLLECTION_ROUTE));
|
||||
},
|
||||
onDelete: async () => {
|
||||
await collections.delete(collection.id);
|
||||
window.location.replace('#/images');
|
||||
},
|
||||
onGone: () => collections.settled().then(showAgain) }));
|
||||
return;
|
||||
}
|
||||
const {description} = renderHeading(main, {title:route.label});
|
||||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
if (collections.status === 'ready') {
|
||||
// Deleted, or never existed: say so, with the way back to Images.
|
||||
description.textContent = DELETED_PAGE_TEXT;
|
||||
const link = element('a', OPEN_IMAGES_LINK, 'workspace-link');
|
||||
link.href = '#/images';
|
||||
main.append(link);
|
||||
return;
|
||||
}
|
||||
description.textContent = LIST_FAILED_TEXT;
|
||||
description.setAttribute('role', 'alert');
|
||||
const retry = createButton('Retry');
|
||||
view.listen(retry, 'click', () => {
|
||||
collections.load();
|
||||
openCollectionPage(route, view);
|
||||
});
|
||||
main.append(retry);
|
||||
}
|
||||
|
||||
function renderPage(route, view, saved = null, {collection = null, onCollectionMissing = null} = {}) {
|
||||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||||
// The concept's heading(); each view fills its description and actions.
|
||||
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
|
||||
@@ -283,10 +378,10 @@ function mountWorkspace() {
|
||||
if (!firstRender) h1.focus();
|
||||
firstRender = false;
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
if (route.id === 'images' || route.id === 'favorites') {
|
||||
if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') {
|
||||
mountNewPromptAction(main, api, view);
|
||||
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
|
||||
onSaveView:saveView('images', view), activity});
|
||||
collection, onCollectionMissing, onSaveView:saveView('images', view), activity});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'library') {
|
||||
|
||||
@@ -221,14 +221,20 @@ export function openSaveViewDialog({ scope, trigger, q, onSave }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* D51: the open view's Edit dialog. "View name" (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
|
||||
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so nothing is removed by one press and
|
||||
* no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the store's promise; the toast reports either
|
||||
* outcome, and the shell navigates after a delete.
|
||||
* D51: an open sidebar item's Edit dialog, shared by saved views and collections (#331 D137) with their own nouns. The
|
||||
* name field (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
|
||||
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so
|
||||
* nothing is removed by one press and no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the
|
||||
* store's promise; the toast reports either outcome, and the shell navigates after a delete.
|
||||
*
|
||||
* `nouns`: `title`, `label`, `deleteTitle`, `deleteText(item)`, `renamed`, `renameFailed`, `deleted`, `deleteFailed`.
|
||||
* By default the dialog closes before the rename is sent (saved views). `renameError(error)` instead keeps it open
|
||||
* while the rename runs and maps a failure: `{field: text}` writes the message under the field and keeps the dialog,
|
||||
* `{toast: text, close}` toasts it and closes the dialog only when `close` is true; null is `renameFailed`, kept open.
|
||||
*/
|
||||
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
|
||||
export function openEditDialog({ scope, trigger, item, nouns, onRename, onDelete, renameError = null }) {
|
||||
const input = document.createElement('input');
|
||||
input.value = view.name;
|
||||
input.value = item.name;
|
||||
input.maxLength = MAX_NAME_LENGTH;
|
||||
const remove = createButton('Delete…', 'danger');
|
||||
const cancel = createButton('Cancel');
|
||||
@@ -236,28 +242,66 @@ export function openEditViewDialog({ scope, trigger, view, onRename, onDelete })
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
actions.append(remove, cancel, save);
|
||||
const message = document.createElement('p');
|
||||
message.className = 'small';
|
||||
message.setAttribute('role', 'alert');
|
||||
message.hidden = true;
|
||||
const say = text => { message.textContent = text; message.hidden = !text; };
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
save.addEventListener('click', async () => {
|
||||
const name = input.value.trim();
|
||||
if (!name) { input.focus(); return; }
|
||||
closeWorkspaceDialog();
|
||||
if (name === view.name) return;
|
||||
if (!renameError || name === item.name) closeWorkspaceDialog();
|
||||
if (name === item.name) return;
|
||||
if (!renameError) {
|
||||
try {
|
||||
await onRename(name);
|
||||
showWorkspaceToast(nouns.renamed);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(nouns.renameFailed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Kept open: one rename at a time, its outcome shown in place.
|
||||
say('');
|
||||
for (const button of [remove, cancel, save]) button.disabled = true;
|
||||
try {
|
||||
await onRename(name);
|
||||
showWorkspaceToast(RENAMED_TOAST);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST);
|
||||
if (scope.signal.aborted) return;
|
||||
closeWorkspaceDialog();
|
||||
showWorkspaceToast(nouns.renamed);
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted) return;
|
||||
for (const button of [remove, cancel, save]) button.disabled = false;
|
||||
const outcome = renameError(error) ?? { toast: nouns.renameFailed };
|
||||
if (outcome.field) { say(outcome.field); input.focus(); return; }
|
||||
if (outcome.close) closeWorkspaceDialog();
|
||||
showWorkspaceToast(outcome.toast);
|
||||
}
|
||||
});
|
||||
remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete }));
|
||||
openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field('View name', input, 'action-input'), actions] });
|
||||
remove.addEventListener('click', () => confirmDelete({ scope, trigger, item, nouns, onDelete }));
|
||||
// The message line exists only where a failure keeps the dialog open (the saved views' dialog is unchanged).
|
||||
const nodes = renameError ? [field(nouns.label, input, 'action-input'), message, actions] : [field(nouns.label, input, 'action-input'), actions];
|
||||
openContentDialog({ scope, trigger, title: nouns.title, nodes });
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
function confirmDelete({ scope, trigger, view, onDelete }) {
|
||||
/** The saved views' nouns for the shared Edit dialog (D51). */
|
||||
export const SAVED_VIEW_NOUNS = Object.freeze({
|
||||
title: 'Edit saved view', label: 'View name', deleteTitle: 'Delete saved view',
|
||||
deleteText: view => `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`,
|
||||
renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST,
|
||||
});
|
||||
|
||||
/** D51: the open view's Edit dialog. */
|
||||
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
|
||||
openEditDialog({ scope, trigger, item: view, nouns: SAVED_VIEW_NOUNS, onRename, onDelete });
|
||||
}
|
||||
|
||||
function confirmDelete({ scope, trigger, item, nouns, onDelete }) {
|
||||
const text = document.createElement('p');
|
||||
text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`;
|
||||
text.textContent = nouns.deleteText(item);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
const cancel = createButton('Cancel');
|
||||
@@ -268,11 +312,11 @@ function confirmDelete({ scope, trigger, view, onDelete }) {
|
||||
closeWorkspaceDialog();
|
||||
try {
|
||||
await onDelete();
|
||||
showWorkspaceToast(DELETED_TOAST);
|
||||
showWorkspaceToast(nouns.deleted);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(DELETE_FAILED_TOAST);
|
||||
if (!scope.signal.aborted) showWorkspaceToast(nouns.deleteFailed);
|
||||
}
|
||||
});
|
||||
openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] });
|
||||
openContentDialog({ scope, trigger, title: nouns.deleteTitle, nodes: [text, actions] });
|
||||
cancel.focus();
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export const routes = [
|
||||
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
|
||||
];
|
||||
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system']);
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system',
|
||||
'collection']);
|
||||
|
||||
/**
|
||||
* System's sections in the concept's order (app.js `systemSections` 635–644); the concept's `general` is production's
|
||||
@@ -45,6 +46,13 @@ export function isSystemSection(id) {
|
||||
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
|
||||
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
|
||||
|
||||
/**
|
||||
* A collection (#331 D137): `#/collection/<id>` is Images filtered to its members. Not a `routes` entry (no Overview
|
||||
* card, no WORKSPACE link); the shell names the page after the collection, the breadcrumb and title stay "Collection"
|
||||
* (concept render a1186–1189).
|
||||
*/
|
||||
export const COLLECTION_ROUTE = Object.freeze({ id: 'collection', label: 'Collection' });
|
||||
|
||||
/** Breadcrumb and document title: the concept names a few pages differently from their nav label. */
|
||||
export function routeTitle(route) {
|
||||
return route.title || route.label;
|
||||
@@ -58,6 +66,9 @@ export function isHandoff(route) {
|
||||
export function resolveRoute(hash) {
|
||||
const saved = /^#\/saved\/([1-9]\d*)$/.exec(hash);
|
||||
if (saved && Number.isSafeInteger(Number(saved[1]))) return {...SAVED_ROUTE, savedId:Number(saved[1]), hash};
|
||||
// A collection id is the routes' 1 to 9 digits without a leading zero.
|
||||
const collection = /^#\/collection\/([1-9]\d{0,8})$/.exec(hash);
|
||||
if (collection) return {...COLLECTION_ROUTE, collectionId:Number(collection[1]), hash};
|
||||
// One job of the Activity page (its id is the registry's uuid4 hex).
|
||||
const job = /^#\/activity\/([0-9a-f]{32})$/.exec(hash);
|
||||
if (job) return {...routes.find(route => route.id === 'activity'), jobId:job[1], hash};
|
||||
@@ -151,7 +162,7 @@ export function mountRouter(win, onRoute) {
|
||||
const hash = route.hash || `#/${route.id}`;
|
||||
if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash);
|
||||
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId &&
|
||||
current?.jobId === route.jobId && current?.section === route.section) return;
|
||||
current?.collectionId === route.collectionId && current?.jobId === route.jobId && current?.section === route.section) return;
|
||||
current = route;
|
||||
onRoute(route);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
<!-- D49: the concept's SAVED VIEWS group without its "PROPOSED" badge (saved views are persisted, #249 D16). -->
|
||||
<div class="sidebar-label">SAVED VIEWS</div>
|
||||
<nav id="saved-nav" aria-label="Saved views"></nav>
|
||||
<!-- D136: the concept's COLLECTIONS group without its "PROPOSED" badge; the label shows once a collection is listed. -->
|
||||
<div class="sidebar-label" hidden>COLLECTIONS</div>
|
||||
<nav id="collections-nav" aria-label="Collections"></nav>
|
||||
<div class="sidebar-bottom">
|
||||
<nav id="system-nav" aria-label="Tools and system"></nav>
|
||||
<div class="connection"><i></i><div>ComfyUI workspace<small>Your library, on your server</small></div></div>
|
||||
|
||||
Reference in New Issue
Block a user