feat(views): saved views and Favorites on Images and Library (#268)
* feat(views): saved-views store and API
Add a saved_views table (page images|library, trimmed 1-100 character
name, whitelisted JSON state of at most 4 KB, position, timestamps)
installed through the same validated-savepoint migration as image
favorites. It is optional in old backups and required in a migrated
restore stage; restore rejects a tampered definition, extra indexes and
any row that would fail write validation.
Routes under /prompt_manager/saved-views: GET lists views by position,
POST {page,name,state} returns 201 {success, view}, PATCH /{id} {name}
renames and DELETE /{id} removes, both 404 for an unknown id. The state
must hold exactly the page's toolbar and Refine-this-view keys (Images:
q, category, sort, source, folder, media, tags; Library: q, category,
sort, tags) with their types and sort/media values; anything else is a
400.
Refs #249
* feat(views): Save view, the SAVED VIEWS sidebar and opening a view
The shared toolbar gains the concept's Save view between sort and Filters
on Images and the Prompt library. It opens the concept's simpleInput
("Save this view", "View name", default "Search: <q>" or "My workspace
view", Cancel / Save); an empty name is "Saved view". A save posts the
page's full whitelisted state (toolbar value plus Refine filters) and
toasts "Saved view added to the sidebar.".
The shell adds the concept's SAVED VIEWS label and nav#saved-nav, loaded
with the shell and re-read after every save. Rows use the concept's
links() markup (search icon, name, no count) and link #/saved/<id>.
Opening one mounts its page with the saved state through the page's own
filters, so the toolbar and "Filters · N" (D44) show it; the h1,
breadcrumb and title are the view's name. A removed Source or Folder is
dropped to its default after discovery, so no listing read is rejected;
an unknown id re-reads the list once, then opens Images; a failed list
read offers Retry.
D16 is retired: images-concept and library-concept no longer map Save
view out. New deviations: D49 (no PROPOSED badge) and D50 (no Needs
review row); shell-concept maps Favorites out until Task 3 and compares
a saved row against the concept's. Browser fixtures now wipe
saved_views.
* feat(views): Edit rename/delete and the Favorites saved view
- Favorites is the first SAVED VIEWS row (star icon, name, real count).
The count is the Favorites listing's own limit=1 total, follows the
nav-count rules and is re-read after every confirmed star or unstar.
- #/favorites opens Images filtered to favorites. The workspace listing
takes favorites=1: each selected root's favorites are read through the
image_favorites primary key and matched against the cached scan with the
toggle's case folding; a failed read is an error, never an unfiltered
page. The cursor scope includes the flag.
- Saved Images state gains a required favorites boolean, so Save view on
Favorites reopens the favorites.
- An open saved view's heading leads with Edit: "Edit saved view" with
View name, Delete... (danger), Cancel and Save. Delete... confirms in the
same dialog (never window.confirm). Toasts "Saved view renamed." and
"Saved view deleted."; a delete returns to the view's plain page.
- shell-concept no longer maps Favorites out (Needs review stays, D50).
Recorded as D51 in TESTING.md.
* fix(views): open stale saved views safely and finish the review fixes
Startup and restore now check only the saved_views structure (table,
columns, types, sizes, a JSON object state) and never the value rules,
so a row saved under older rules no longer stops the database from
opening. Reads normalize each view: missing keys take their defaults,
unknown keys are dropped, invalid values default and the name is made
displayable. POST and PATCH still validate strictly.
Only request validation (SavedViewInvalid) maps to 400; database and
internal errors are a logged generic 500. A saved view no longer
replaces the plain Images or Library return state, a toolbar change
while sources load keeps a saved Source and Folder, a state over 4 KB
is refused in the browser with its own toast, the delete confirmation
skips its failure toast after the page is gone, the Edit dialog reuses
the shared field helper, and quick star toggles coalesce into one
Favorites count read.
* test(views): cover leaving a pending saved view and add its coverage floor
A saved view left while the list or a rename is still pending leaves
the next page alone; the shell drops two unreachable heading guards.
workspace/saved-views.mjs gets its frontend coverage baseline entry;
no existing floor changes.
* fix(views): a corrupt saved-view state no longer blocks startup
At startup a saved_views row whose state is not a JSON object is
logged as a warning by id only and left in place. The listing and
rename skip it, so its #/saved/<id> link behaves like a missing view.
Restore stays strict and still rejects a backup holding such a row.
This commit is contained in:
+57
-6
@@ -431,13 +431,63 @@ Recorded shell deviations:
|
||||
- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI
|
||||
palette, then the OS.
|
||||
- **D5**: no Activity button or Activity row until an activity center exists.
|
||||
- **D28**: no SAVED VIEWS or COLLECTIONS, and no Design review. The bottom nav is
|
||||
- **D28**: no COLLECTIONS, and no Design review. The bottom nav is
|
||||
System plus the production Overview hub (its own outline home glyph, not the concept's
|
||||
`activity` pulse, and no count).
|
||||
The connection reads "Your library, on your server" with a neutral muted dot.
|
||||
- **D49** (saved views, #249 D16): the SAVED VIEWS group label has no "PROPOSED"
|
||||
badge, because saved views are persisted (`/prompt_manager/saved-views`). Its rows are
|
||||
the concept's `links()` rows: the search icon, the view name and no count, linking
|
||||
`#/saved/<id>`. Opening one shows its page with the saved toolbar value and Refine
|
||||
filters, and the view's name as the h1, breadcrumb and title. A value its page no
|
||||
longer accepts (a removed Source or Folder) stays default; an unknown id opens Images.
|
||||
A toolbar change made while the sources are still loading keeps the saved Source and
|
||||
Folder until discovery settles. A saved view, like Favorites, never replaces the plain
|
||||
Images or Library return state. Writes (POST, PATCH) validate strictly; a state over
|
||||
4 KB is refused in the browser with "The view is too large to save. Shorten its
|
||||
search, folder or tags and try again." Startup and restore check only the table's
|
||||
structure and that each row's state is a JSON object of at most 4 KB, never the value
|
||||
rules, so a row saved under older rules (a missing or retired key, a sort no longer
|
||||
offered) never stops the database from opening: reads fill missing keys with their
|
||||
defaults, drop unknown keys, default invalid values and make the name displayable
|
||||
(`tests/test_saved_views.py`). A live row whose state is not a JSON object never
|
||||
blocks startup either: its id is logged as a warning, the row is left in place, the
|
||||
listing and rename skip it, and its `#/saved/<id>` link behaves like a missing view.
|
||||
Restore stays strict and rejects such a backup ("Saved views contains invalid values"). Only request validation is a 400; any other failure is
|
||||
a logged 500 with a generic message. The Favorites count re-read after starring is
|
||||
coalesced to one read 300 ms after the last toggle.
|
||||
`a saved view row matches the concept's` saves one view in both documents and
|
||||
compares the rows; `tests/browser/workspace-saved-views.spec.mjs` covers saving,
|
||||
reopening, the defaults that replace rejected values, and failed reads and saves.
|
||||
- **D50**: no "Needs review" row in SAVED VIEWS until the tag-review slice (maintainer
|
||||
ruling, 2026-10-21). The comparison maps only that row out.
|
||||
- **D51** (maintainer ruling, 2026-10-21, beyond the concept): SAVED VIEWS starts with
|
||||
the concept's Favorites row (star icon, name, count). Its count is the Favorites
|
||||
listing's own `limit=1` total (`/images/output?mode=workspace&favorites=1`), follows
|
||||
the nav-count rules (hidden until loaded, absent when its read fails, never a
|
||||
placeholder) and is re-read after every confirmed star or unstar, on any page.
|
||||
`#/favorites` is Images filtered to favorites, named "Favorites" (concept
|
||||
`renderImages`); `favorites=1` keeps only the selected roots' favorites, read per root
|
||||
from `image_favorites` and matched against the scan with the toggle's case folding,
|
||||
and a failed read is an error, never an unfiltered page. Unlike the concept, an
|
||||
unstarred card stays on Favorites (showing ☆) until the next load, so the star can be
|
||||
undone in place. Favorites neither restores nor replaces the Images return state.
|
||||
Save view there saves `favorites: true`, so the view reopens the favorites. An open
|
||||
saved view's heading leads with **Edit**: "Edit saved view" with "View name" and
|
||||
Delete… (danger), Cancel and Save. Save re-reads the list, retitles the page and
|
||||
toasts "Saved view renamed."; an empty name keeps the dialog open. Delete… swaps the
|
||||
same dialog to "Delete saved view" (Cancel / Delete; never `window.confirm`; its Cancel
|
||||
closes the dialog without deleting rather than returning to Edit); Delete
|
||||
removes the view, replaces the dead route with the view's plain page and toasts
|
||||
"Saved view deleted.". Failures toast "The view could not be renamed. Try again." or
|
||||
"The view could not be deleted. Try again."; a view another tab already deleted counts
|
||||
as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count,
|
||||
route, star updates, Save view on Favorites, Edit, and the failure paths;
|
||||
`tests/test_image_favorites.py` covers `favorites=1`.
|
||||
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
|
||||
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
|
||||
library.", and the title has no " concept" suffix.
|
||||
library.", and the title has no " concept" suffix. Save view's dialog action reads
|
||||
"Save", not "Apply in prototype", and its toast drops the demo's word "Sample".
|
||||
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
|
||||
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
|
||||
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
|
||||
@@ -464,7 +514,7 @@ zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape retur
|
||||
to it (production only: the concept re-renders its grid and drops focus), Info on the
|
||||
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
|
||||
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
|
||||
D16 (follow-up PR: no Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
sorted alphabetically). Removed toolbar and filter-row items flow again from the
|
||||
concept's own boxes (`reflow`), checked against the concept page with those items
|
||||
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
|
||||
@@ -570,7 +620,9 @@ Recorded deviations on this page (spec §8):
|
||||
files in M output folders." (the scan's `total` and `roots`) or "Rescan could not
|
||||
finish. Try again."; while it runs, a reopened Import shows an aria-disabled
|
||||
"Rescanning…".
|
||||
- **D16 (follow-up)**: no Save view in PR C; a follow-up PR adds real saved views.
|
||||
- **D16 (retired)**: Save view is the concept's own toolbar button, between sort and
|
||||
Filters, on Images and the Prompt library (see D49). It saves the visible toolbar value
|
||||
with the Refine filters; the comparisons no longer map it.
|
||||
- **D17**: the caption date is the newest result's day under Recently added (", YYYY"
|
||||
outside the current year), hidden under every other sort.
|
||||
- **D18**: the concept hover zoom, with no sticky zoom on hover-less (touch) devices.
|
||||
@@ -775,8 +827,7 @@ Behaviors, in both documents: the title and Explore history open the prompt's hi
|
||||
(its `h1` is the title), the footer send carries the positive only (a bridge host page
|
||||
records the roles production's preview asks for), copy writes the full positive with
|
||||
"Prompt copied.", and the "neon" search and the Landscapes category leave the same cards
|
||||
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D16 (no Save
|
||||
view; the toolbar flows again, checked against the concept without it), D23 (the
|
||||
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D23 (the
|
||||
title button's `min-width: 0`; "− 1 negative variant", measured on the concept with the
|
||||
singular at runtime; the spoken "Rated N out of 5"; the two-line clamp, which the
|
||||
concept's samples exceed at 390px, checked against the concept with the clamp rule
|
||||
|
||||
@@ -137,3 +137,16 @@ class ImageFavoriteOperations:
|
||||
)
|
||||
found.update(path for (path,) in rows)
|
||||
return found
|
||||
|
||||
def get_root_favorites(self, root_key: str) -> set[str]:
|
||||
"""Every relative key favorited under *root_key* (a primary-key range).
|
||||
|
||||
The Favorites listing (``favorites=1``) intersects these with the
|
||||
root's scan, so its size follows the root's favorites, not its files.
|
||||
"""
|
||||
conn = self.model.get_connection()
|
||||
rows = conn.execute(
|
||||
"SELECT relative_path FROM image_favorites WHERE root_key=?",
|
||||
(root_key,),
|
||||
)
|
||||
return {path for (path,) in rows}
|
||||
|
||||
+10
-2
@@ -17,6 +17,7 @@ from typing import Optional, Set, Tuple
|
||||
|
||||
from .library_schema import library_schema_error, migrate_library_state
|
||||
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
|
||||
from .saved_views import migrate_saved_views, saved_views_schema_error
|
||||
from .execution_history_schema import (
|
||||
history_schema_error,
|
||||
migrate_execution_history,
|
||||
@@ -300,6 +301,7 @@ class PromptModel:
|
||||
migrate_execution_history(conn)
|
||||
migrate_library_state(conn)
|
||||
migrate_image_favorites(conn)
|
||||
migrate_saved_views(conn)
|
||||
|
||||
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute("SAVEPOINT prompt_pair_schema")
|
||||
@@ -1019,8 +1021,9 @@ class PromptModel:
|
||||
Args:
|
||||
path: File to inspect
|
||||
require_library_state: Also require the tables every current
|
||||
database has (Library state and image favorites), as a
|
||||
migrated restore stage must; old backups may lack them.
|
||||
database has (Library state, image favorites and saved
|
||||
views), as a migrated restore stage must; old backups may
|
||||
lack them.
|
||||
|
||||
Returns:
|
||||
(ok, reason): reason is "ok" when the file is acceptable, otherwise
|
||||
@@ -1080,6 +1083,11 @@ class PromptModel:
|
||||
)
|
||||
if favorites_error:
|
||||
return False, favorites_error
|
||||
views_error = saved_views_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if views_error:
|
||||
return False, views_error
|
||||
return True, "ok"
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
|
||||
@@ -12,6 +12,7 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
from .pairs import PairDependencyError, PairOperations
|
||||
from .library import LibraryOperations, titled
|
||||
from .image_favorites import ImageFavoriteOperations
|
||||
from .saved_views import SavedViewOperations
|
||||
from .library_commands import LibraryCommandOperations
|
||||
from .vocabulary import VocabularyOperations
|
||||
from .execution_history import ExecutionHistoryOperations
|
||||
@@ -306,6 +307,7 @@ class PromptDatabase(
|
||||
ExecutionOutputOperations,
|
||||
HistoryReadOperations,
|
||||
ImageFavoriteOperations,
|
||||
SavedViewOperations,
|
||||
):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Persisted saved views (D16): a named Images or Library toolbar state.
|
||||
|
||||
A view's state holds exactly its page's toolbar values and its Refine-this-view
|
||||
refinements, whitelisted per page and stored as compact JSON of at most 4 KB.
|
||||
|
||||
Writes (the API's POST and PATCH) validate strictly. Startup and restore check
|
||||
only the structure (the table, its columns and each row's types, sizes and JSON
|
||||
object), never the value rules: a row saved under older rules must not stop the
|
||||
database from opening. Startup is more tolerant still: a row whose state is not
|
||||
a JSON object is logged by id, left in place and skipped by every read, while
|
||||
restore rejects such a backup. Reads normalize instead, so a stale row opens with its
|
||||
usable values and defaults for the rest. Optional in old backups, required once
|
||||
a database is current (see ``library_schema.py``).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
# The models logger's parent ("prompt_manager.database.models" logs there too).
|
||||
_LOGGER = logging.getLogger("prompt_manager.database.saved_views")
|
||||
|
||||
TABLE = "saved_views"
|
||||
PAGES = ("images", "library")
|
||||
# The schema check's literal SQL repeats the name and state caps (names in
|
||||
# bytes, at most 4 per character); keep them in step.
|
||||
MAX_NAME_LENGTH = 100
|
||||
MAX_STATE_BYTES = 4096
|
||||
MAX_TAGS = 20
|
||||
MAX_TAG_LENGTH = 50
|
||||
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
|
||||
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
|
||||
_MEDIA = ("all", "image", "video")
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
|
||||
FALLBACK_NAME = "Saved view"
|
||||
|
||||
|
||||
class SavedViewInvalid(ValueError):
|
||||
"""A request's page, name or state breaks the write rules (a 400)."""
|
||||
|
||||
|
||||
# (kind, limit-or-choices, default) per state key; "text" values are
|
||||
# length-capped. The defaults match the client's (saved-views.mjs FIELDS).
|
||||
_STATE_FIELDS = {
|
||||
"images": {
|
||||
"q": ("text", 500, ""),
|
||||
"category": ("text", 100, ""),
|
||||
"sort": ("choice", _IMAGE_SORTS, "newest"),
|
||||
"source": ("source", None, "all"),
|
||||
"folder": ("text", 1024, ""),
|
||||
"media": ("choice", _MEDIA, "all"),
|
||||
"tags": ("tags", None, ()),
|
||||
# The Favorites page's filter (#249 D51): a view saved there reopens it.
|
||||
"favorites": ("bool", None, False),
|
||||
},
|
||||
"library": {
|
||||
"q": ("text", 500, ""),
|
||||
"category": ("text", 100, ""),
|
||||
"sort": ("choice", _LIBRARY_SORTS, "newest"),
|
||||
"tags": ("tags", None, ()),
|
||||
},
|
||||
}
|
||||
|
||||
_DDL = (
|
||||
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"page TEXT NOT NULL CHECK(page IN ('images','library')), "
|
||||
"name TEXT NOT NULL, state TEXT NOT NULL, position INTEGER NOT NULL, "
|
||||
"created_at TEXT NOT NULL, updated_at TEXT NOT NULL)"
|
||||
)
|
||||
_COLUMNS = (
|
||||
("id", "INTEGER", 0, 1),
|
||||
("page", "TEXT", 1, 0),
|
||||
("name", "TEXT", 1, 0),
|
||||
("state", "TEXT", 1, 0),
|
||||
("position", "INTEGER", 1, 0),
|
||||
("created_at", "TEXT", 1, 0),
|
||||
("updated_at", "TEXT", 1, 0),
|
||||
)
|
||||
_SELECT = "SELECT id, page, name, state FROM saved_views"
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql).lower()
|
||||
|
||||
|
||||
def validate_name(name: object) -> str:
|
||||
"""The trimmed name, 1 to 100 characters without control characters."""
|
||||
if not isinstance(name, str):
|
||||
raise SavedViewInvalid("Name must be a string")
|
||||
trimmed = name.strip()
|
||||
if not 1 <= len(trimmed) <= MAX_NAME_LENGTH:
|
||||
raise SavedViewInvalid(f"Name must be 1 to {MAX_NAME_LENGTH} characters")
|
||||
if _CONTROL.search(trimmed):
|
||||
raise SavedViewInvalid("Name contains control characters")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_value(key: str, kind: str, limit, value: object) -> None:
|
||||
"""Raise ``SavedViewInvalid`` when *value* breaks *key*'s write rule."""
|
||||
if kind == "tags":
|
||||
if not isinstance(value, list) or len(value) > MAX_TAGS:
|
||||
raise SavedViewInvalid(
|
||||
f"State '{key}' must be a list of at most {MAX_TAGS}"
|
||||
)
|
||||
for tag in value:
|
||||
if (
|
||||
not isinstance(tag, str)
|
||||
or not tag.strip()
|
||||
or len(tag) > MAX_TAG_LENGTH
|
||||
or "," in tag
|
||||
or _CONTROL.search(tag)
|
||||
):
|
||||
raise SavedViewInvalid(f"State '{key}' holds an invalid tag")
|
||||
return
|
||||
if kind == "bool":
|
||||
if not isinstance(value, bool):
|
||||
raise SavedViewInvalid(f"State '{key}' must be true or false")
|
||||
return
|
||||
if not isinstance(value, str):
|
||||
raise SavedViewInvalid(f"State '{key}' must be a string")
|
||||
if kind == "choice" and value not in limit:
|
||||
raise SavedViewInvalid(f"State '{key}' is not a supported value")
|
||||
if kind == "source" and not _SOURCE.fullmatch(value):
|
||||
raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index")
|
||||
if kind == "text" and (len(value) > limit or _CONTROL.search(value)):
|
||||
raise SavedViewInvalid(f"State '{key}' is too long or has control characters")
|
||||
|
||||
|
||||
def encode_state(page: object, state: object) -> str:
|
||||
"""Validate *state* for *page* and return its compact JSON (at most 4 KB)."""
|
||||
if page not in PAGES:
|
||||
raise SavedViewInvalid("Page must be 'images' or 'library'")
|
||||
fields = _STATE_FIELDS[page]
|
||||
if not isinstance(state, dict):
|
||||
raise SavedViewInvalid("State must be an object")
|
||||
if set(state) != set(fields):
|
||||
raise SavedViewInvalid(
|
||||
f"State for {page} must contain exactly " + ", ".join(sorted(fields))
|
||||
)
|
||||
for key, (kind, limit, _default) in fields.items():
|
||||
_validate_value(key, kind, limit, state[key])
|
||||
encoded = json.dumps(
|
||||
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
if len(encoded.encode("utf-8")) > MAX_STATE_BYTES:
|
||||
raise SavedViewInvalid("State must encode to at most 4 KB")
|
||||
return encoded
|
||||
|
||||
|
||||
def _decode_object(raw: object) -> Optional[dict]:
|
||||
"""The JSON object stored in *raw*, or None when it holds no object."""
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
except (ValueError, RecursionError):
|
||||
return None
|
||||
return decoded if isinstance(decoded, dict) else None
|
||||
|
||||
|
||||
def normalize_state(page: str, raw: object) -> dict:
|
||||
"""The page's full state from a stored row, never raising.
|
||||
|
||||
Every key of the page's current whitelist is present: a stored value that
|
||||
passes today's write rule is kept, anything else (missing, invalid, or a
|
||||
row that is not a JSON object) takes the key's default, and keys the
|
||||
whitelist no longer has are dropped. An unknown page has no keys.
|
||||
"""
|
||||
fields = _STATE_FIELDS.get(page, {})
|
||||
stored = _decode_object(raw) or {}
|
||||
state = {}
|
||||
for key, (kind, limit, default) in fields.items():
|
||||
value = stored.get(key, default)
|
||||
try:
|
||||
_validate_value(key, kind, limit, value)
|
||||
except SavedViewInvalid:
|
||||
value = default
|
||||
state[key] = list(value) if kind == "tags" else value
|
||||
return state
|
||||
|
||||
|
||||
def normalize_name(name: object) -> str:
|
||||
"""A stored name made displayable: trimmed, capped, never empty."""
|
||||
text = _CONTROL.sub("", name) if isinstance(name, str) else ""
|
||||
trimmed = text.strip()[:MAX_NAME_LENGTH].strip()
|
||||
return trimmed or FALLBACK_NAME
|
||||
|
||||
|
||||
def saved_views_schema_error(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
allow_missing: bool = True,
|
||||
check_states: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Reject unsupported saved-views schemas and structurally invalid rows.
|
||||
|
||||
Reads only. Checks the table, its columns, each row's types and sizes and
|
||||
that each state is a JSON object; never the value rules, which change
|
||||
between versions and are normalized on read instead. A missing table is
|
||||
acceptable for backups that predate it unless ``allow_missing`` is false.
|
||||
``check_states=False`` (live startup) skips the JSON-object check; such
|
||||
rows are reported by ``unusable_view_ids`` and skipped on read.
|
||||
"""
|
||||
objects = conn.execute(
|
||||
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
|
||||
"OR (type IN ('index','trigger') AND tbl_name=?)",
|
||||
(TABLE, TABLE),
|
||||
).fetchall()
|
||||
if not objects:
|
||||
return None if allow_missing else "Missing saved views schema"
|
||||
tables = [sql for kind, _name, sql in objects if kind == "table"]
|
||||
if len(tables) != 1 or len(objects) != 1:
|
||||
return "Saved views name is reserved by an unsupported schema object"
|
||||
if _compact(tables[0] or "") != _compact(_DDL):
|
||||
return "Saved views has an invalid table definition"
|
||||
columns = tuple(
|
||||
(row[1], row[2].upper(), row[3], row[5])
|
||||
for row in conn.execute(f"PRAGMA table_info({TABLE})")
|
||||
)
|
||||
if columns != _COLUMNS:
|
||||
return "Saved views has invalid columns"
|
||||
# Literal SQL: the table name and limits are constants of this module.
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM saved_views WHERE typeof(page) != 'text' "
|
||||
"OR page NOT IN ('images','library') "
|
||||
"OR typeof(name) != 'text' OR typeof(state) != 'text' "
|
||||
"OR typeof(position) != 'integer' OR typeof(created_at) != 'text' "
|
||||
"OR typeof(updated_at) != 'text' "
|
||||
"OR length(CAST(name AS BLOB)) > 400 "
|
||||
"OR length(CAST(state AS BLOB)) > 4096 "
|
||||
"OR length(CAST(created_at AS BLOB)) > 64 "
|
||||
"OR length(CAST(updated_at AS BLOB)) > 64 LIMIT 1"
|
||||
).fetchone():
|
||||
return "Saved views contains invalid values"
|
||||
if check_states and unusable_view_ids(conn):
|
||||
return "Saved views contains invalid values"
|
||||
return None
|
||||
|
||||
|
||||
def unusable_view_ids(conn: sqlite3.Connection) -> list[int]:
|
||||
"""Ids of rows whose state is not a JSON object (never listed)."""
|
||||
return [
|
||||
view_id
|
||||
for view_id, state in conn.execute("SELECT id, state FROM saved_views")
|
||||
if _decode_object(state) is None
|
||||
]
|
||||
|
||||
|
||||
def migrate_saved_views(conn: sqlite3.Connection) -> None:
|
||||
"""Install the table atomically; validate before and after creating it."""
|
||||
conn.execute("SAVEPOINT saved_views_schema")
|
||||
try:
|
||||
error = saved_views_schema_error(conn, check_states=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
error = saved_views_schema_error(conn, allow_missing=False, check_states=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
# A corrupt state never stops the live database from opening: the row
|
||||
# stays (no data is destroyed) and reads skip it. Only its id is logged.
|
||||
for view_id in unusable_view_ids(conn):
|
||||
_LOGGER.warning(
|
||||
"Saved view id %d has an unreadable state and is hidden", view_id
|
||||
)
|
||||
conn.execute("RELEASE saved_views_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO saved_views_schema")
|
||||
conn.execute("RELEASE saved_views_schema")
|
||||
raise
|
||||
|
||||
|
||||
def _view(row) -> dict:
|
||||
view_id, page, name, state = row
|
||||
return {
|
||||
"id": view_id,
|
||||
"page": page,
|
||||
"name": normalize_name(name),
|
||||
"state": normalize_state(page, state),
|
||||
}
|
||||
|
||||
|
||||
class SavedViewOperations:
|
||||
"""PromptDatabase mixin: list, create, rename and delete saved views."""
|
||||
|
||||
def list_saved_views(self) -> list[dict]:
|
||||
"""Every view, in list order (position, then creation)."""
|
||||
rows = self.model.get_connection().execute(_SELECT + " ORDER BY position, id")
|
||||
# A row whose state is no JSON object (logged at startup) is never listed.
|
||||
return [_view(row) for row in rows if _decode_object(row[3]) is not None]
|
||||
|
||||
def create_saved_view(self, page: object, name: object, state: object) -> dict:
|
||||
"""Validate and append one view at the end of the list."""
|
||||
from .models import utc_now_iso # models imports this module's schema
|
||||
|
||||
encoded = encode_state(page, state)
|
||||
trimmed = validate_name(name)
|
||||
now = utc_now_iso()
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO saved_views (page, name, state, position, "
|
||||
"created_at, updated_at) SELECT ?, ?, ?, "
|
||||
"COALESCE(MAX(position), -1) + 1, ?, ? FROM saved_views",
|
||||
(page, trimmed, encoded, now, now),
|
||||
)
|
||||
row = conn.execute(
|
||||
_SELECT + " WHERE id = ?", (cursor.lastrowid,)
|
||||
).fetchone()
|
||||
return _view(row)
|
||||
|
||||
def rename_saved_view(self, view_id: int, name: object) -> Optional[dict]:
|
||||
"""The renamed view, or None when no view has *view_id*."""
|
||||
from .models import utc_now_iso
|
||||
|
||||
trimmed = validate_name(name)
|
||||
with self.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ?",
|
||||
(trimmed, utc_now_iso(), view_id),
|
||||
)
|
||||
row = conn.execute(_SELECT + " WHERE id = ?", (view_id,)).fetchone()
|
||||
# A hidden (unreadable) view is not found, as in the listing.
|
||||
return _view(row) if row and _decode_object(row[3]) is not None else None
|
||||
|
||||
def delete_saved_view(self, view_id: int) -> bool:
|
||||
"""True when a view was removed."""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute("DELETE FROM saved_views WHERE id = ?", (view_id,))
|
||||
return cursor.rowcount > 0
|
||||
@@ -25,6 +25,7 @@ from .prompts import PromptRoutesMixin
|
||||
from .pairs import PairRoutesMixin
|
||||
from .library import LibraryRoutesMixin
|
||||
from .vocabulary import VocabularyRoutesMixin
|
||||
from .saved_views import SavedViewRoutesMixin
|
||||
from .history import HistoryRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
@@ -248,6 +249,7 @@ class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
LibraryRoutesMixin,
|
||||
VocabularyRoutesMixin,
|
||||
SavedViewRoutesMixin,
|
||||
HistoryRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
@@ -432,6 +434,7 @@ class PromptManagerAPI(
|
||||
self._register_pair_routes(routes)
|
||||
self._register_library_routes(routes)
|
||||
self._register_vocabulary_routes(routes)
|
||||
self._register_saved_view_routes(routes)
|
||||
self._register_history_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
|
||||
+40
-4
@@ -696,7 +696,9 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
``q`` (at most 500 characters) matches the relative path, case-folded, and
|
||||
the linked positive's text, title and tag names. ``category`` is exact,
|
||||
``tags`` is a comma list that must all be present, ``sort=rating`` ranks by
|
||||
the linked positive. Raises ValueError for any unknown or oversized value.
|
||||
the linked positive, and ``favorites=1`` keeps only favorites (the
|
||||
Favorites page, #249 D51). Raises ValueError for any unknown or oversized
|
||||
value.
|
||||
"""
|
||||
text = query.get("q", "").strip()
|
||||
folded = text.casefold()
|
||||
@@ -707,8 +709,10 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
tags = sorted(
|
||||
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
|
||||
)
|
||||
favorites = query.get("favorites", "0")
|
||||
if (
|
||||
len(text) > 500
|
||||
favorites not in ("0", "1")
|
||||
or len(text) > 500
|
||||
or len(folded) > 500
|
||||
or media not in ("all", "image", "video")
|
||||
or order not in WORKSPACE_ORDERS
|
||||
@@ -727,6 +731,7 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
"folder": folder,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"favorites": favorites == "1",
|
||||
}
|
||||
|
||||
|
||||
@@ -736,7 +741,12 @@ _COUNT_OMITTED = object()
|
||||
|
||||
|
||||
def _filtered_page(
|
||||
entries: list, start: int, size: int, filters: dict, links: dict | None
|
||||
entries: list,
|
||||
start: int,
|
||||
size: int,
|
||||
filters: dict,
|
||||
links: dict | None,
|
||||
favorites: frozenset | None = None,
|
||||
) -> tuple[int, list, int | None]:
|
||||
"""Count the *entries* the filters admit and take *size* from *start* on.
|
||||
|
||||
@@ -745,6 +755,7 @@ def _filtered_page(
|
||||
category or tag filter admits only those, and ``q`` admits a filename match
|
||||
or a linked-positive hit. Returns the total, the page and the number of
|
||||
distinct linked positives among all admitted entries (None without links).
|
||||
*favorites*, when given, admits only those listed paths (``favorites=1``).
|
||||
"""
|
||||
query, media, folder = filters["query"], filters["media"], filters["folder"]
|
||||
members_only = filters["category"] is not None or bool(filters["tags"])
|
||||
@@ -758,6 +769,8 @@ def _filtered_page(
|
||||
continue
|
||||
if media != "all" and (media == "video") != entry.video:
|
||||
continue
|
||||
if favorites is not None and entry.path not in favorites:
|
||||
continue
|
||||
match = None if links is None else links.get(entry.normalized)
|
||||
if members_only and match is None:
|
||||
continue
|
||||
@@ -1146,6 +1159,7 @@ class ImageRoutesMixin:
|
||||
[str(root) for root in output_dirs],
|
||||
filters["category"],
|
||||
filters["tags"],
|
||||
filters["favorites"],
|
||||
]
|
||||
).encode()
|
||||
).hexdigest()
|
||||
@@ -1333,8 +1347,11 @@ class ImageRoutesMixin:
|
||||
start = len(entries) - bisect_left(ascending, after, key=key)
|
||||
else:
|
||||
start = bisect_right(entries, after, key=key)
|
||||
favorites = (
|
||||
self._workspace_favorite_paths(listings) if filters["favorites"] else None
|
||||
)
|
||||
total, page, positive_count = _filtered_page(
|
||||
entries, start, limit + 1, filters, links
|
||||
entries, start, limit + 1, filters, links, favorites
|
||||
)
|
||||
if not needed and after is not None:
|
||||
positive_count = _COUNT_OMITTED
|
||||
@@ -2347,6 +2364,25 @@ class ImageRoutesMixin:
|
||||
self._favorite_listing_cache[cache_key] = (files, listing)
|
||||
return listing
|
||||
|
||||
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
|
||||
"""The listed paths of every favorite in the selected roots (blocking).
|
||||
|
||||
One primary-key read per root (get_root_favorites), matched against the
|
||||
root's cached ``{relative key: listing spelling}``, so the work follows
|
||||
the favorites rather than the files, and a key folds case exactly as
|
||||
the toggle stored it (Windows). A favorite whose file is gone matches
|
||||
nothing. A failed read raises: an unfiltered page must never pass for
|
||||
the favorites.
|
||||
"""
|
||||
paths = set()
|
||||
for _index, root, files in listings:
|
||||
listing = self._favorite_listing(root, files)
|
||||
for key in self.db.get_root_favorites(_favorite_root_key(root)):
|
||||
listed = listing.get(key)
|
||||
if listed is not None:
|
||||
paths.add(root / listed)
|
||||
return frozenset(paths)
|
||||
|
||||
def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]:
|
||||
"""``(root index, listing path)`` of the favorites among *page* entries."""
|
||||
by_root: dict[tuple[int, Path], dict[str, str]] = {}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Saved views (D16): list, create, rename and delete named toolbar states.
|
||||
|
||||
The page, the name and the whitelisted per-page state are validated here
|
||||
(through ``database/saved_views.py``) before anything is written. Only that
|
||||
request validation (``SavedViewInvalid``) is a 400 carrying its message; any
|
||||
other failure, including a ``ValueError`` raised by the database layer, is a
|
||||
logged 500 with a generic message.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...database.saved_views import SavedViewInvalid, encode_state, validate_name
|
||||
except ImportError:
|
||||
from database.saved_views import SavedViewInvalid, encode_state, validate_name
|
||||
|
||||
_CREATE_FIELDS = frozenset({"page", "name", "state"})
|
||||
_RENAME_FIELDS = frozenset({"name"})
|
||||
_MAX_ID = 2**63 - 1
|
||||
|
||||
|
||||
class SavedViewNotFound(LookupError):
|
||||
pass
|
||||
|
||||
|
||||
def saved_view_response(handler: Callable) -> Callable:
|
||||
"""``{success:false,error}`` with 400, 404 or 500 on every failure."""
|
||||
|
||||
@wraps(handler)
|
||||
async def wrapped(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
return await handler(self, request)
|
||||
except SavedViewNotFound:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Saved view not found"}, status=404
|
||||
)
|
||||
except SavedViewInvalid as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception:
|
||||
self.logger.exception("Saved view operation failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Saved view operation failed"}, status=500
|
||||
)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
async def _body(request: web.Request, fields: frozenset) -> dict:
|
||||
"""A JSON object whose keys are exactly ``fields``."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError): # JSONDecodeError is a ValueError
|
||||
raise SavedViewInvalid("Expected a JSON object") from None
|
||||
if not isinstance(body, dict):
|
||||
raise SavedViewInvalid("Expected a JSON object")
|
||||
if set(body) != fields:
|
||||
raise SavedViewInvalid(
|
||||
"Request must contain exactly " + ", ".join(sorted(fields))
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
def _view_id(request: web.Request) -> int:
|
||||
raw = request.match_info["view_id"]
|
||||
if not raw.isascii() or not raw.isdigit():
|
||||
raise SavedViewInvalid("Invalid saved view ID")
|
||||
value = int(raw)
|
||||
if not 1 <= value <= _MAX_ID:
|
||||
raise SavedViewInvalid("Invalid saved view ID")
|
||||
return value
|
||||
|
||||
|
||||
class SavedViewRoutesMixin:
|
||||
def _register_saved_view_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.get("/prompt_manager/saved-views")(self.list_saved_views)
|
||||
routes.post("/prompt_manager/saved-views")(self.create_saved_view)
|
||||
routes.patch("/prompt_manager/saved-views/{view_id}")(self.rename_saved_view)
|
||||
routes.delete("/prompt_manager/saved-views/{view_id}")(self.delete_saved_view)
|
||||
|
||||
@saved_view_response
|
||||
async def list_saved_views(self, request: web.Request) -> web.Response:
|
||||
views = await self._run_in_executor(self.db.list_saved_views)
|
||||
return web.json_response({"success": True, "views": views})
|
||||
|
||||
@saved_view_response
|
||||
async def create_saved_view(self, request: web.Request) -> web.Response:
|
||||
body = await _body(request, _CREATE_FIELDS)
|
||||
encode_state(body["page"], body["state"])
|
||||
validate_name(body["name"])
|
||||
view = await self._run_in_executor(
|
||||
self.db.create_saved_view, body["page"], body["name"], body["state"]
|
||||
)
|
||||
return web.json_response({"success": True, "view": view}, status=201)
|
||||
|
||||
@saved_view_response
|
||||
async def rename_saved_view(self, request: web.Request) -> web.Response:
|
||||
view_id = _view_id(request)
|
||||
body = await _body(request, _RENAME_FIELDS)
|
||||
validate_name(body["name"])
|
||||
view = await self._run_in_executor(
|
||||
self.db.rename_saved_view, view_id, body["name"]
|
||||
)
|
||||
if view is None:
|
||||
raise SavedViewNotFound
|
||||
return web.json_response({"success": True, "view": view})
|
||||
|
||||
@saved_view_response
|
||||
async def delete_saved_view(self, request: web.Request) -> web.Response:
|
||||
view_id = _view_id(request)
|
||||
if not await self._run_in_executor(self.db.delete_saved_view, view_id):
|
||||
raise SavedViewNotFound
|
||||
return web.json_response({"success": True, "id": view_id})
|
||||
@@ -97,6 +97,8 @@ export async function openProduction(page,route,theme,motion='no-preference') {
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||||
// SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture.
|
||||
await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1);
|
||||
}
|
||||
|
||||
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
|
||||
|
||||
@@ -85,8 +85,8 @@ const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y
|
||||
|
||||
export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
||||
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
||||
const toolbar=withoutItems(reference.toolbar,item=>item.text==='Save view');
|
||||
// D16 (follow-up PR): no Save view until the saved-views store ships. D14: the sort appends production's two sorts.
|
||||
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...reference.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
|
||||
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
|
||||
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
|
||||
@@ -329,9 +329,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
|
||||
const conceptLoad=await openConceptLoaded(page,theme,motion);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
|
||||
// The browser as the re-layout's oracle: the concept page itself without Save view, then without the sample tag.
|
||||
// The browser as the re-layout's oracle: the concept page itself without the sample tag.
|
||||
const removed={};
|
||||
for(const [key,selector] of [['toolbar','.toolbar > [data-action="save-view"]'],['filterRow','.filter-row > .sample-tag']]) {
|
||||
for(const [key,selector] of [['filterRow','.filter-row > .sample-tag']]) {
|
||||
const restore=await mutate(page,{remove:[selector]});
|
||||
removed[key]=(await inventoryOf(page,'#main'))[key];
|
||||
await restore();
|
||||
@@ -385,12 +385,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
|
||||
expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`);
|
||||
expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px');
|
||||
expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']);
|
||||
// The row model reproduces the concept's own rows, and the concept page itself once Save view (D16) or the
|
||||
// sample tag (D29) is removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
|
||||
// The row model reproduces the concept's own rows, and the concept page itself once the sample tag (D29) is
|
||||
// removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
|
||||
expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(withoutItems(reference.toolbar,item=>item.text==='Save view')),stripExact(removed.toolbar)),
|
||||
`${motion} toolbar without Save view`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'))),stripExact(removed.filterRow)),
|
||||
`${motion} filter row without the sample tag`).toEqual([]);
|
||||
expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,`${motion} concept categories`).toEqual(CONCEPT_CATEGORIES);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
|
||||
|
||||
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
|
||||
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
|
||||
@@ -18,8 +18,8 @@ const CONCEPT_PROMPTS=6;
|
||||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||
export function mapLibraryConcept(reference) {
|
||||
const library=reference.library;
|
||||
// D16 (follow-up PR): no Save view until the saved-views store ships; the remaining items flow again (`reflow`).
|
||||
const toolbar=withoutItems(library.toolbar,item=>item.text==='Save view');
|
||||
// Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...library.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>{
|
||||
// D14: the Library appends its Oldest first after the concept's three sorts.
|
||||
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
|
||||
@@ -251,11 +251,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
|
||||
await openConceptLibrary(page,theme,motion);
|
||||
const reference=await conceptInventory(page);
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`);
|
||||
// The browser as the re-layout's oracle: the concept page itself without Save view (D16).
|
||||
const restore=await mutate(page,{remove:['.toolbar > [data-action="save-view"]']});
|
||||
const removed=(await inventoryOf(page,'#main')).library.toolbar;
|
||||
await restore();
|
||||
// ...and with the D23 clamp applied to its cards.
|
||||
// The browser as the re-layout's oracle: the concept page itself with the D23 clamp applied to its cards.
|
||||
const restoreClamp=await mutate(page,{css:CLAMP_CSS});
|
||||
const clamped=(await inventoryOf(page,'#main')).library;
|
||||
await restoreClamp();
|
||||
@@ -269,13 +265,13 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`);
|
||||
const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null;
|
||||
result[motion]={reference,removed,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
|
||||
result[motion]={reference,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
|
||||
}
|
||||
const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('library-geometry',{path,contentType:'application/json'});
|
||||
|
||||
for(const [motion,{reference,removed,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
|
||||
const {grid,cards,toolbar}=reference.library,card=cards[0];
|
||||
for(const [motion,{reference,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
|
||||
const {grid,cards}=reference.library,card=cards[0];
|
||||
// The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483).
|
||||
expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS});
|
||||
expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'});
|
||||
@@ -291,7 +287,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
|
||||
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
|
||||
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
|
||||
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
|
||||
expect.soft(diffInventory(stripExact(withoutItems(toolbar,item=>item.text==='Save view')),stripExact(removed)),`${motion} toolbar without Save view`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
|
||||
@@ -181,6 +181,7 @@ def create_app(root: Path) -> web.Application:
|
||||
) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
@@ -469,6 +470,29 @@ def create_app(root: Path) -> web.Application:
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.post("/__test__/saved-views/raw")
|
||||
async def insert_raw_saved_view(request: web.Request) -> web.Response:
|
||||
"""Store a view row as an older build wrote it, bypassing the API's rules."""
|
||||
data = await request.json()
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or set(data) != {"page", "name", "state"}
|
||||
or not all(isinstance(data[key], str) for key in data)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
|
||||
def insert() -> int:
|
||||
with api.db.model.get_connection() as conn:
|
||||
return conn.execute(
|
||||
"INSERT INTO saved_views (page, name, state, position, "
|
||||
"created_at, updated_at) VALUES (?, ?, ?, 0, 'x', 'x')",
|
||||
(data["page"], data["name"], data["state"]),
|
||||
).lastrowid
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "id": await api._run_in_executor(insert)}
|
||||
)
|
||||
|
||||
@routes.post("/__test__/add-nested-image")
|
||||
async def add_nested_image(request: web.Request) -> web.Response:
|
||||
destination = root / "output" / "project" / "day" / "new-output.png"
|
||||
|
||||
@@ -56,13 +56,23 @@ function remainingFooter(footer,spans) {
|
||||
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
|
||||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||||
|
||||
/**
|
||||
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
|
||||
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
|
||||
*/
|
||||
function savedNav(nav) {
|
||||
const rows=nav.rows.filter(row=>row.text!=='Needs review');
|
||||
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
|
||||
}
|
||||
|
||||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||||
function mapShellConcept(reference) {
|
||||
const {chrome}=reference;
|
||||
const navs=chrome.navs
|
||||
// D28: SAVED VIEWS and COLLECTIONS are proposals with no persisted store, so their navs are not rendered.
|
||||
.filter(nav=>nav.id!=='saved-nav'&&nav.id!=='collections-nav')
|
||||
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
|
||||
.filter(nav=>nav.id!=='collections-nav')
|
||||
.map(nav=>{
|
||||
if(nav.id==='saved-nav')return savedNav(nav);
|
||||
if(nav.id!=='system-nav')return nav;
|
||||
const system=rowNamed(nav,'System');
|
||||
return {...nav,rows:[
|
||||
@@ -76,8 +86,9 @@ function mapShellConcept(reference) {
|
||||
return {...reference,chrome:{...chrome,
|
||||
// D29: no prototype suffix in the document title.
|
||||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||||
// D28: only the WORKSPACE group label remains.
|
||||
labels:chrome.labels.filter(label=>label.text==='WORKSPACE'),
|
||||
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
|
||||
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
|
||||
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
|
||||
navs,
|
||||
connection:{...chrome.connection,
|
||||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||||
@@ -199,6 +210,30 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry
|
||||
}
|
||||
});
|
||||
|
||||
// Saved views (#249 D16): a view saved from the Images toolbar adds the concept's own saved row (search icon, name, no
|
||||
// count) to SAVED VIEWS. The concept's demo action reads "Apply in prototype"; production's is "Save" (D29).
|
||||
for(const [width,height] of SIZES)test(`a saved view row matches the concept's ${width}x${height}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const save=async(action,mainSelector)=>{
|
||||
await page.locator(`${mainSelector} .toolbar`).getByRole('button',{name:'Save view',exact:true}).click();
|
||||
const dialog=page.getByRole('dialog',{name:'Save this view',exact:true});
|
||||
await expect(dialog.getByRole('textbox',{name:'View name',exact:true})).toHaveValue('My workspace view');
|
||||
await dialog.getByRole('textbox',{name:'View name',exact:true}).fill('Golden hour');
|
||||
await dialog.getByRole('button',{name:action,exact:true}).click();
|
||||
await expect(page.locator('#saved-nav').getByRole('link',{name:'Golden hour',exact:true})).toHaveCount(1);
|
||||
await page.mouse.move(0,0);
|
||||
return inventoryOf(page,mainSelector);
|
||||
};
|
||||
await openConcept(page,'images','dark');
|
||||
const reference=await save('Apply in prototype','#main');
|
||||
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
|
||||
await openProduction(page,'images','dark');
|
||||
const actual=await save('Save','#workspace-main');
|
||||
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
|
||||
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
|
||||
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
|
||||
});
|
||||
|
||||
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||||
|
||||
@@ -808,7 +808,9 @@ test('images toolbar and filter row match the concept controls', async ({ page,
|
||||
await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: 'Current gallery' })).toHaveCount(0);
|
||||
await expect(main.locator('h2', { hasText: /^All images$/ })).toHaveCount(0);
|
||||
await expect(main.locator('search.toolbar > *')).toHaveCount(4);
|
||||
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
|
||||
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
|
||||
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
|
||||
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
|
||||
const row = main.locator('.filter-row');
|
||||
await expect(row.locator(':scope > *')).toHaveCount(5);
|
||||
|
||||
@@ -151,19 +151,19 @@ test('failed library reads retry while late search responses cannot overwrite ne
|
||||
|
||||
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Filters.
|
||||
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters.
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
|
||||
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button']);
|
||||
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
|
||||
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
|
||||
await expect(page.locator('.library-shelf > search.toolbar:first-child')).toHaveCount(1);
|
||||
// Tags apply from the dialog only; Clear clears them and the toolbar category, as the concept's does.
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
|
||||
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
|
||||
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
// Saved views (#249 D16, maintainer ruling 2026-10-21): the toolbar's Save view (concept app.js a359, a1330–1346),
|
||||
// the sidebar SAVED VIEWS rows (a331–335) and `#/saved/<id>` (a1158–1162), on Images and the Prompt library.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const API = '/prompt_manager/saved-views';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const toolbar = page => main(page).locator('search.toolbar');
|
||||
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
|
||||
const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
|
||||
const filtersButton = page => toolbar(page).getByRole('button', { name: /^Filters(?: · \d+)?$/ });
|
||||
const imageCards = page => page.locator('article[data-image-key]');
|
||||
const promptCards = page => page.locator('article[data-prompt-id]');
|
||||
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
|
||||
// The saved views' own rows, after the Favorites row (D51).
|
||||
const viewRows = page => savedNav(page).locator('a[data-route^="saved/"]');
|
||||
const favoritesRow = page => savedNav(page).locator('a[data-route="favorites"]');
|
||||
const favoritesCount = page => favoritesRow(page).locator('.nav-count');
|
||||
const editDialog = page => page.getByRole('dialog', { name: 'Edit saved view', exact: true });
|
||||
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete saved view', exact: true });
|
||||
const heading = page => main(page).getByRole('heading', { level: 1 });
|
||||
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
|
||||
async function refine(page, values) {
|
||||
await filtersButton(page).click();
|
||||
for (const [label, value] of Object.entries(values)) {
|
||||
const control = refineDialog(page).getByLabel(label, { exact: true });
|
||||
if (label === 'Tags') await control.fill(value);
|
||||
else await control.selectOption(value);
|
||||
}
|
||||
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await expect(refineDialog(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function listed(request) {
|
||||
const response = await request.get(API);
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).views;
|
||||
}
|
||||
|
||||
async function create(request, data) {
|
||||
const response = await request.post(API, { data });
|
||||
expect(response.status()).toBe(201);
|
||||
return (await response.json()).view;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.savedErrors = [];
|
||||
page.on('pageerror', error => page.savedErrors.push(error.message));
|
||||
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
|
||||
page.on('dialog', dialog => { page.savedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
|
||||
// No listing read may fail on a saved view's values (an unknown Source would be a 400).
|
||||
page.on('response', response => {
|
||||
if (response.status() >= 400 && new URL(response.url()).pathname === '/prompt_manager/images/output') {
|
||||
page.savedErrors.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.savedErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('Save view saves the Images toolbar and Refine filters, and its sidebar row reopens them', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
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']);
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
|
||||
await search(page).fill('dogs');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await sortBy(page).selectOption('name');
|
||||
await refine(page, { Source: '1', Media: 'image' });
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
const saveButton = toolbar(page).getByRole('button', { name: 'Save view', exact: true });
|
||||
await saveButton.click();
|
||||
// The concept's simpleInput: "View name", the search's default name, Cancel and the primary action.
|
||||
const dialog = saveDialog(page);
|
||||
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toHaveValue('Search: dogs');
|
||||
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toBeFocused();
|
||||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(saveButton).toBeFocused();
|
||||
expect(await listed(request)).toEqual([]);
|
||||
|
||||
await saveButton.click();
|
||||
await dialog.getByRole('textbox', { name: 'View name', exact: true }).fill('Dog shots');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
const [view] = await listed(request);
|
||||
expect(view).toMatchObject({ page: 'images', name: 'Dog shots',
|
||||
state: { ...IMAGE_DEFAULTS, q: 'dogs', sort: 'name', source: '1', media: 'image' } });
|
||||
// The concept's links() row: the search icon, then the name, and no count.
|
||||
const row = savedNav(page).getByRole('link', { name: 'Dog shots', exact: true });
|
||||
await expect(row).toHaveAttribute('href', `#/saved/${view.id}`);
|
||||
expect(await row.evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE ? `text:${node.textContent}`
|
||||
: `${node.localName}.${node.getAttribute('class')}`))).toEqual(['svg.icon', 'text:Dog shots']);
|
||||
await expect(row).not.toHaveAttribute('aria-current', 'page');
|
||||
|
||||
// Another page, then the saved row: its page opens with every saved value and the Filters count (D44).
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Prompt library/ }).click();
|
||||
await expect(promptCards(page).first()).toBeVisible();
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toBeFocused();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Dog shots');
|
||||
await expect(page).toHaveTitle('Dog shots · Prompt Manager');
|
||||
await expect(row).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await expect(sortBy(page)).toHaveValue('name');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await filtersButton(page).click();
|
||||
await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('1');
|
||||
await expect(refineDialog(page).getByLabel('Media', { exact: true })).toHaveValue('image');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// A reload opens the same view from its link.
|
||||
await page.reload();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(savedNav(page).getByRole('link', { name: 'Dog shots', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('an empty name is Saved view, and a Library view reopens the shelf filters', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const name = saveDialog(page).getByRole('textbox', { name: 'View name', exact: true });
|
||||
await expect(name).toHaveValue('My workspace view');
|
||||
await name.fill(' ');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(viewRows(page)).toHaveText(['Saved view']);
|
||||
expect((await listed(request))[0]).toMatchObject({ page: 'images', name: 'Saved view', state: IMAGE_DEFAULTS });
|
||||
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(promptCards(page)).toHaveCount(2);
|
||||
await search(page).fill('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await refine(page, { Tags: 'dogs' });
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await expect(name).toHaveValue('Search: dog');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(viewRows(page)).toHaveText(['Saved view', 'Search: dog']);
|
||||
const library = (await listed(request))[1];
|
||||
expect(library).toMatchObject({ page: 'library', name: 'Search: dog', state: { q: 'dog', category: '', sort: 'newest', tags: ['dogs'] } });
|
||||
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await savedNav(page).getByRole('link', { name: 'Search: dog', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Search: dog');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
// Saved views open the page; one saved view to another re-renders with the other's state.
|
||||
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Saved view');
|
||||
await expect(search(page)).toHaveValue('');
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('a saved value the page no longer accepts stays default while the rest applies', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'images', name: 'Gone source',
|
||||
state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'name-desc', source: '7', folder: 'missing/folder' } });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Gone source');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(sortBy(page)).toHaveValue('name-desc');
|
||||
// The missing Source and Folder are dropped, so no refinement is active.
|
||||
await expect(filtersButton(page)).toHaveText('Filters');
|
||||
await expect(main(page).locator('.filter-row > span.chip')).toHaveText('All output folders');
|
||||
|
||||
// A Source that does exist, with a folder it lists, applies.
|
||||
const kept = await create(request, { page: 'images', name: 'Input shared',
|
||||
state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
|
||||
await page.goto(`${workspace}#/saved/${kept.id}`);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Input shared');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
});
|
||||
|
||||
test('a view stored under older rules still lists and opens with its usable values (#249 I1)', async ({ page, request }) => {
|
||||
// No favorites key, a retired key and a sort that is no longer offered, as an older build stored it.
|
||||
const raw = async data => {
|
||||
const response = await request.post('/__test__/saved-views/raw', { data });
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).id;
|
||||
};
|
||||
const imagesId = await raw({ page: 'images', name: 'Old cats', state: JSON.stringify({ q: 'cat', sort: 'shuffle', layout: 'grid' }) });
|
||||
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
|
||||
expect(await listed(request)).toEqual([
|
||||
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
|
||||
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [] } }),
|
||||
]);
|
||||
// A state that is no JSON object is never listed, and its link behaves like a missing view.
|
||||
const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' });
|
||||
expect((await listed(request)).map(view => view.id)).not.toContain(corruptId);
|
||||
await page.goto(`${workspace}#/saved/${corruptId}`);
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
|
||||
|
||||
await page.goto(`${workspace}#/saved/${imagesId}`);
|
||||
await expect(heading(page)).toHaveText('Old cats');
|
||||
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(sortBy(page)).toHaveValue('newest');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
|
||||
await expect(heading(page)).toHaveText('Saved view');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
|
||||
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
|
||||
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await search(page).fill('cat');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await viewRows(page).filter({ hasText: 'Dogs' }).click();
|
||||
await expect(heading(page)).toHaveText('Dogs');
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
|
||||
await page.locator('#primary-nav a[data-route="library"]').click();
|
||||
await expect(promptCards(page)).toHaveCount(2);
|
||||
await search(page).fill('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await page.goto(`${workspace}#/saved/${cats.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await page.locator('#primary-nav a[data-route="library"]').click();
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
expect(dogs.id).not.toBe(cats.id);
|
||||
});
|
||||
|
||||
test('a toolbar change while sources load keeps the saved Source and Folder (#249 M3)', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'images', name: 'Input shared', state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/gallery/subfolders?**', async route => { await held; await route.fallback(); });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Input shared');
|
||||
await sortBy(page).selectOption('name');
|
||||
await expect(imageCards(page)).toHaveCount(0);
|
||||
release();
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(sortBy(page)).toHaveValue('name');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
});
|
||||
|
||||
test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
let releaseList = () => {};
|
||||
const holdList = async () => {
|
||||
const held = new Promise(resolve => { releaseList = resolve; });
|
||||
await page.unroute(`**${API}`);
|
||||
await page.route(`**${API}`, async route => { if (route.request().method() === 'GET') await held; await route.fallback(); });
|
||||
};
|
||||
// Leaving while the shell's first read is pending: the view never mounts over the page left to.
|
||||
await holdList();
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await page.evaluate(() => { window.location.hash = '#/tags'; });
|
||||
await expect(heading(page)).toHaveText('Tags & categories');
|
||||
releaseList();
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
await expect(heading(page)).toHaveText('Tags & categories');
|
||||
// An unknown id re-reads the list once; leaving during that read keeps the page left to.
|
||||
await holdList();
|
||||
await page.evaluate(() => { window.location.hash = '#/saved/999'; });
|
||||
await page.evaluate(() => { window.location.hash = '#/library'; });
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
releaseList();
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
await page.unroute(`**${API}`);
|
||||
|
||||
// A rename that lands after the view was left does not retitle the next page.
|
||||
let releaseRename;
|
||||
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
|
||||
await page.route(`**${API}/${view.id}`, async route => { await renameHeld; await route.fallback(); });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
await main(page).locator('.heading-actions > button').first().click();
|
||||
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
releaseRename();
|
||||
await expect(viewRows(page)).toHaveText(['Kittens']);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(page).toHaveTitle('Images · Prompt Manager');
|
||||
});
|
||||
|
||||
test('an unknown saved view opens Images, and a failed listing offers Retry', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/saved/999`);
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
|
||||
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
let failing = true;
|
||||
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
|
||||
: route.fallback());
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(main(page).getByRole('alert')).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
failing = false;
|
||||
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Cats');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
});
|
||||
|
||||
test('a failed save says so and adds nothing', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await page.route(`**${API}`, route => route.request().method() === 'POST'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
|
||||
: route.fallback());
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be saved. Try again.');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toEqual([]);
|
||||
});
|
||||
|
||||
async function star(page, key) {
|
||||
await page.locator(`article[data-image-key="${key}"] .star-button`).click();
|
||||
}
|
||||
|
||||
test('Favorites is the first SAVED VIEWS row, with a real count that follows every star', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
// Hidden until its read settles: never a placeholder "0".
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
|
||||
&& url.searchParams.get('limit') === '1', async route => { await held; await route.fallback(); }, { times: 1 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await expect(favoritesRow(page)).toHaveAttribute('href', '#/favorites');
|
||||
await expect(favoritesCount(page)).toHaveCount(0);
|
||||
release();
|
||||
// The concept's row (app.js a332): the star icon, "Favorites", then the count.
|
||||
await expect(favoritesCount(page)).toHaveText('0');
|
||||
expect(await favoritesRow(page).evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE
|
||||
? `text:${node.textContent}` : `${node.localName}.${node.getAttribute('class')}`)))
|
||||
.toEqual(['svg.icon', 'text:Favorites', 'span.nav-count']);
|
||||
expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites']);
|
||||
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(toast(page)).toHaveText('Added to favorites.');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
|
||||
// #/favorites: Images filtered to favorites, named Favorites (concept renderImages a417).
|
||||
const listings = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') !== '1') listings.push(url.searchParams);
|
||||
});
|
||||
await favoritesRow(page).click();
|
||||
await expect(page).toHaveURL(/#\/favorites$/);
|
||||
await expect(heading(page)).toHaveText('Favorites');
|
||||
await expect(heading(page)).toBeFocused();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Favorites');
|
||||
await expect(page).toHaveTitle('Favorites · Prompt Manager');
|
||||
await expect(favoritesRow(page)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await expect(main(page).locator('#result-count')).toHaveText(/^1 of 1 image/);
|
||||
expect(listings.at(-1).get('favorites')).toBe('1');
|
||||
|
||||
// Unstarring on Favorites keeps the card (with ☆) so it can be undone in place; the count follows.
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(toast(page)).toHaveText('Removed from favorites.');
|
||||
await expect(imageCards(page).locator('.star-button')).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(favoritesCount(page)).toHaveText('0');
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
|
||||
// Favorites never replaces the Images return state: Images still lists everything.
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images/ }).click();
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await page.goto(`${workspace}#/favorites`);
|
||||
await expect(heading(page)).toHaveText('Favorites');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a failed Favorites count shows no badge', async ({ page }) => {
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
|
||||
&& url.searchParams.get('limit') === '1', route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText('2');
|
||||
await expect(favoritesRow(page)).toBeVisible();
|
||||
await expect(favoritesCount(page)).toHaveCount(0);
|
||||
// The refused count read is this test's own; nothing else failed.
|
||||
page.savedErrors = page.savedErrors.filter(error => !/^503 .*favorites=1/.test(error));
|
||||
});
|
||||
|
||||
test('Save view on Favorites reopens the favorites', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await star(page, '0:shared/same.png');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
await page.goto(`${workspace}#/favorites`);
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await saveDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Best');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
const [view] = await listed(request);
|
||||
expect(view).toMatchObject({ page: 'images', name: 'Best', state: { ...IMAGE_DEFAULTS, favorites: true } });
|
||||
await expect(viewRows(page)).toHaveText(['Best']);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await viewRows(page).click();
|
||||
await expect(heading(page)).toHaveText('Best');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
});
|
||||
|
||||
test('Edit renames an open view, and deletes it only after confirming inside the dialog', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const view = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
// Plain pages have no Edit.
|
||||
await expect(main(page).locator('.heading-actions').getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0);
|
||||
await viewRows(page).click();
|
||||
await expect(heading(page)).toHaveText('Dogs');
|
||||
// D51: Edit leads the heading's actions of an open view.
|
||||
const edit = main(page).locator('.heading-actions > button').first();
|
||||
await expect(edit).toHaveText('Edit');
|
||||
await edit.click();
|
||||
const dialog = editDialog(page);
|
||||
const name = dialog.getByRole('textbox', { name: 'View name', exact: true });
|
||||
await expect(name).toHaveValue('Dogs');
|
||||
await expect(name).toBeFocused();
|
||||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
|
||||
await expect(dialog.getByRole('button', { name: 'Delete…', exact: true })).toHaveClass(/\bdanger\b/);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(edit).toBeFocused();
|
||||
|
||||
// An empty name keeps the dialog open on its field.
|
||||
await edit.click();
|
||||
await name.fill(' ');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(name).toBeFocused();
|
||||
await name.fill('Good dogs');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view renamed.');
|
||||
await expect(viewRows(page)).toHaveText(['Good dogs']);
|
||||
await expect(heading(page)).toHaveText('Good dogs');
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Good dogs');
|
||||
await expect(page).toHaveTitle('Good dogs · Prompt Manager');
|
||||
await expect(edit).toBeFocused();
|
||||
expect(await listed(request)).toEqual([{ ...view, name: 'Good dogs' }]);
|
||||
// The rename kept the view's page state.
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
|
||||
// Delete… asks first; its Cancel deletes nothing.
|
||||
await edit.click();
|
||||
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const confirm = deleteDialog(page);
|
||||
await expect(confirm).toContainText('Delete “Good dogs”? Only the saved view is removed; its images and prompts stay.');
|
||||
await expect(confirm.locator('.dialog-actions button')).toHaveText(['Cancel', 'Delete']);
|
||||
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
|
||||
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(confirm).toHaveCount(0);
|
||||
expect(await listed(request)).toHaveLength(1);
|
||||
|
||||
await edit.click();
|
||||
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await confirm.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view deleted.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toEqual([]);
|
||||
// The dead link left no history entry.
|
||||
await page.goBack();
|
||||
await expect(page).not.toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
});
|
||||
|
||||
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
const edit = main(page).locator('.heading-actions > button').first();
|
||||
await expect(edit).toHaveText('Edit');
|
||||
let failing = true;
|
||||
await page.route(`**${API}/${view.id}`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
|
||||
: route.fallback());
|
||||
await edit.click();
|
||||
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be renamed. Try again.');
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
await edit.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('The view could not be deleted. Try again.');
|
||||
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
expect(await listed(request)).toHaveLength(1);
|
||||
|
||||
failing = false;
|
||||
await edit.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('Saved view deleted.');
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -95,11 +95,11 @@ test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const shelf=page.locator('article[data-prompt-id]');await expect(shelf.first()).toBeVisible();const total=await shelf.count();
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await page.goto(route);
|
||||
const row=tagRow(page,'cinematic');await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{tags:'cinematic',count:2});
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await refine.getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine).toHaveCount(0);
|
||||
@@ -109,7 +109,7 @@ test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})
|
||||
const nav=page.getByRole('navigation',{name:'Workspace',exact:true});
|
||||
await nav.getByRole('link',{name:/^Tags & categories(?:\s*[\d,]+)?$/}).click();await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await nav.getByRole('link',{name:/^Prompt library(?:\s*[\d,]+)?$/}).click();
|
||||
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
|
||||
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
});
|
||||
|
||||
test('category Browse sets only the category constraint',async({page})=>{
|
||||
|
||||
@@ -18,6 +18,8 @@ const routes = [
|
||||
['tags', 'Tags & categories', null],
|
||||
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
|
||||
['favorites', 'Favorites', null],
|
||||
];
|
||||
|
||||
async function countThemeWrites(page) {
|
||||
@@ -427,10 +429,13 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
await expect(page.locator('body > .app-shell > .workspace > #workspace-main')).toHaveCount(1);
|
||||
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-bottom']);
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
|
||||
'nav#saved-nav', 'div.sidebar-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
|
||||
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
|
||||
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
|
||||
await expect(page.locator('#collections-nav, .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'));
|
||||
@@ -457,7 +462,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(2);
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
|
||||
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
|
||||
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -168,6 +168,7 @@ _TABLES = (
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
|
||||
@@ -26,6 +26,7 @@ EDITOR_METADATA = {
|
||||
"notes": "Keep the soft light.",
|
||||
}
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
|
||||
@@ -112,6 +112,7 @@ 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 (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
|
||||
@@ -79,6 +79,7 @@ UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
|
||||
@@ -128,6 +128,17 @@ test('all-source defaults are omitted and descending name sort remains supported
|
||||
await stream.reset({root:'all',media:'all',sort:'name-desc'});
|
||||
assert.equal(query.has('root'),false);assert.equal(query.has('media'),false);
|
||||
assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40');
|
||||
assert.equal(query.has('favorites'),false);
|
||||
});
|
||||
|
||||
test('the Favorites page asks the listing for favorites only (#249 D51)', 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({favorites:true,q:'fog'});
|
||||
assert.equal(query.get('favorites'),'1');assert.equal(query.get('q'),'fog');
|
||||
await stream.reset({favorites:false});
|
||||
assert.equal(query.has('favorites'),false);
|
||||
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
|
||||
});
|
||||
|
||||
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// Saved views (#249 D16): the state whitelist, the concept's default name, the listing parser and the shell's store.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/saved-views.mjs');
|
||||
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
|
||||
|
||||
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
|
||||
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
|
||||
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
|
||||
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
|
||||
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
|
||||
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
|
||||
assert.deepEqual(normalizeViewState('images', full), full);
|
||||
// D51: favorites is a boolean; anything else is the whole listing.
|
||||
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
|
||||
// Each rejected value falls back on its own; the rest of the view still applies.
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, sort: 'bogus', source: '-1', media: 'audio', tags: ['a,b'], extra: 1 }),
|
||||
{ ...full, sort: 'newest', source: 'all', media: 'all', tags: [] });
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }),
|
||||
{ ...full, q: '', folder: '', tags: [] });
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
|
||||
// Library has no source, folder or media, and no Name Z–A.
|
||||
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'] });
|
||||
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
|
||||
// The returned tags are a copy.
|
||||
const tags = ['one'];
|
||||
const state = normalizeViewState('library', { tags });
|
||||
state.tags.push('two');
|
||||
assert.deepEqual(tags, ['one']);
|
||||
assert.throws(() => normalizeViewState('tags', {}), TypeError);
|
||||
});
|
||||
|
||||
test('the default name is the concept search name, else My workspace view, within the name limit', async () => {
|
||||
const { defaultViewName } = await source;
|
||||
assert.equal(defaultViewName('neon'), 'Search: neon');
|
||||
assert.equal(defaultViewName(' neon city '), 'Search: neon city');
|
||||
assert.equal(defaultViewName(''), 'My workspace view');
|
||||
assert.equal(defaultViewName(' '), 'My workspace view');
|
||||
assert.equal(defaultViewName(undefined), 'My workspace view');
|
||||
assert.equal(defaultViewName('x'.repeat(500)).length, 100);
|
||||
});
|
||||
|
||||
test('the listing keeps usable views in order and normalizes their state', async () => {
|
||||
const { parseSavedViews, parseSavedView, savedViewHash } = await source;
|
||||
const views = parseSavedViews({ success: true, views: [
|
||||
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
|
||||
{ id: 0, page: 'images', name: 'Bad id', state: {} },
|
||||
{ id: 4, page: 'tags', name: 'Bad page', state: {} },
|
||||
{ id: 5, page: 'images', name: ' ', state: {} },
|
||||
{ id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } },
|
||||
] });
|
||||
assert.deepEqual(views, [
|
||||
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
|
||||
{ id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
|
||||
]);
|
||||
assert.equal(parseSavedView(null), null);
|
||||
assert.throws(() => parseSavedViews({ success: true }), TypeError);
|
||||
assert.equal(savedViewHash(12), '#/saved/12');
|
||||
});
|
||||
|
||||
test('page filters and saved states convert both ways', async () => {
|
||||
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
|
||||
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
|
||||
const state = imagesViewState(filters);
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
|
||||
assert.deepEqual(imagesFilters(state), filters);
|
||||
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false });
|
||||
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
|
||||
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
|
||||
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
|
||||
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
|
||||
});
|
||||
|
||||
function fakeApi() {
|
||||
const calls = [];
|
||||
const pending = [];
|
||||
const api = {
|
||||
calls,
|
||||
get(url, options) {
|
||||
calls.push(['GET', url, options.cache]);
|
||||
return new Promise((resolve, reject) => pending.push({ resolve, reject, signal: options.signal }));
|
||||
},
|
||||
post(url, body) {
|
||||
calls.push(['POST', url, body]);
|
||||
return Promise.resolve({ success: true, view: { id: 9, ...body } });
|
||||
},
|
||||
patch(url, body) {
|
||||
calls.push(['PATCH', url, body]);
|
||||
return api.patchResult ?? Promise.resolve({ success: true, view: { id: 9, page: 'images', name: body.name, state: {} } });
|
||||
},
|
||||
del(url) {
|
||||
calls.push(['DELETE', url]);
|
||||
return api.deleteResult ?? Promise.resolve({ success: true, id: 9 });
|
||||
},
|
||||
};
|
||||
return { api, pending };
|
||||
}
|
||||
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('the store reports loads, keeps the last views after a failed read, and a newer read supersedes', async () => {
|
||||
const { createSavedViews, SAVED_VIEWS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const controller = new AbortController();
|
||||
const store = createSavedViews(api, controller.signal);
|
||||
const seen = [];
|
||||
store.subscribe(next => seen.push([next.status, next.views.map(view => view.id)]));
|
||||
store.load();
|
||||
const settled = store.settled();
|
||||
pending[0].resolve({ success: true, views: [{ id: 2, page: 'images', name: 'A', state: {} }] });
|
||||
assert.equal((await settled).status, 'ready');
|
||||
assert.deepEqual(store.find(2).state, IMAGE_DEFAULTS);
|
||||
assert.equal(store.find(3), null);
|
||||
assert.deepEqual(api.calls[0], ['GET', SAVED_VIEWS_URL, 'no-store']);
|
||||
|
||||
store.load();
|
||||
pending[1].reject(new Error('offline'));
|
||||
await flush();
|
||||
assert.equal(store.status, 'error');
|
||||
assert.deepEqual(store.views.map(view => view.id), [2]);
|
||||
|
||||
// A second read aborts the first; only the newer one lands.
|
||||
store.load();
|
||||
store.load();
|
||||
assert.equal(pending[2].signal.aborted, true);
|
||||
pending[2].resolve({ success: true, views: [] });
|
||||
pending[3].resolve({ success: true, views: [{ id: 5, page: 'library', name: 'B', state: {} }] });
|
||||
await flush();
|
||||
assert.deepEqual(store.views.map(view => view.id), [5]);
|
||||
assert.deepEqual(seen, [['loading', []], ['ready', [2]], ['loading', [2]], ['error', [2]], ['loading', [2]], ['loading', [2]], ['ready', [5]]]);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
test('creating a view posts the full state and re-reads the list', async () => {
|
||||
const { createSavedViews, SAVED_VIEWS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const store = createSavedViews(api);
|
||||
const created = store.create('library', 'Neon', { q: 'neon' });
|
||||
await flush();
|
||||
assert.deepEqual(api.calls[0], ['POST', SAVED_VIEWS_URL, { page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } }]);
|
||||
pending[0].resolve({ success: true, views: [{ id: 9, page: 'library', name: 'Neon', state: { q: 'neon' } }] });
|
||||
assert.deepEqual(await created, { id: 9, page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } });
|
||||
assert.equal(store.find(9).name, 'Neon');
|
||||
const broken = createSavedViews({ ...api, post: () => Promise.resolve({ success: true, view: null }) });
|
||||
await assert.rejects(broken.create('images', 'X', {}), TypeError);
|
||||
});
|
||||
|
||||
test('renaming and deleting a view re-read the list, whatever the outcome', async () => {
|
||||
const { createSavedViews, SAVED_VIEWS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const store = createSavedViews(api);
|
||||
const renamed = store.rename(9, 'Dusk');
|
||||
await flush();
|
||||
assert.deepEqual(api.calls[0], ['PATCH', `${SAVED_VIEWS_URL}/9`, { name: 'Dusk' }]);
|
||||
assert.deepEqual(api.calls[1], ['GET', SAVED_VIEWS_URL, 'no-store']);
|
||||
pending[0].resolve({ success: true, views: [{ id: 9, page: 'images', name: 'Dusk', state: {} }] });
|
||||
assert.deepEqual(await renamed, { id: 9, page: 'images', name: 'Dusk', state: IMAGE_DEFAULTS });
|
||||
assert.equal(store.find(9).name, 'Dusk');
|
||||
|
||||
const removed = store.remove(9);
|
||||
await flush();
|
||||
assert.deepEqual(api.calls[2], ['DELETE', `${SAVED_VIEWS_URL}/9`]);
|
||||
pending[1].resolve({ success: true, views: [] });
|
||||
assert.equal(await removed, 9);
|
||||
assert.equal(store.find(9), null);
|
||||
|
||||
// A view another tab already deleted is the outcome asked for; any other failure rejects, after a re-read.
|
||||
api.deleteResult = Promise.reject(Object.assign(new Error('Not found'), { status: 404 }));
|
||||
const gone = store.remove(4);
|
||||
await flush();
|
||||
pending[2].resolve({ success: true, views: [] });
|
||||
assert.equal(await gone, 4);
|
||||
api.deleteResult = Promise.reject(Object.assign(new Error('Locked'), { status: 500 }));
|
||||
const failed = store.remove(4);
|
||||
await flush();
|
||||
pending[3].resolve({ success: true, views: [] });
|
||||
await assert.rejects(failed, /Locked/);
|
||||
api.patchResult = Promise.resolve({ success: true, view: null });
|
||||
const invalid = store.rename(4, 'X');
|
||||
await flush();
|
||||
pending[4].resolve({ success: true, views: [] });
|
||||
await assert.rejects(invalid, TypeError);
|
||||
assert.equal(pending.length, 5);
|
||||
assert.throws(() => store.rename(0, 'X'), TypeError);
|
||||
assert.throws(() => store.remove('9'), TypeError);
|
||||
});
|
||||
|
||||
test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)', async () => {
|
||||
const { createSavedViews, stateFits, SavedViewTooLarge, MAX_STATE_BYTES } = await source;
|
||||
assert.equal(MAX_STATE_BYTES, 4096);
|
||||
assert.equal(stateFits(IMAGE_DEFAULTS), true);
|
||||
// Each value is within its own limit; their multibyte UTF-8 together passes 4 KB.
|
||||
const big = { ...IMAGE_DEFAULTS, q: 'é'.repeat(500), folder: 'é'.repeat(1024), tags: Array.from({ length: 20 }, () => 'é'.repeat(50)) };
|
||||
assert.equal(stateFits(big), false);
|
||||
const { api } = fakeApi();
|
||||
const store = createSavedViews(api);
|
||||
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
|
||||
assert.deepEqual(api.calls, []);
|
||||
});
|
||||
@@ -79,18 +79,19 @@ test('positive library deep links preserve the selected identity without duplica
|
||||
|
||||
test('nav counts reject negative, non-integer and missing totals', async () => {
|
||||
const { summarizeNavCounts } = await source;
|
||||
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
|
||||
const counts = summarizeNavCounts({images: {total: 24}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7}});
|
||||
assert.ok(counts instanceof Map);
|
||||
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
|
||||
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7]]);
|
||||
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
|
||||
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
|
||||
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
|
||||
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
|
||||
const result = summarizeNavCounts({images: invalid, favorites: invalid, library: {total: 2}, tags: invalid});
|
||||
assert.equal(result.get('images'), null, JSON.stringify(invalid));
|
||||
assert.equal(result.get('favorites'), null);
|
||||
assert.equal(result.get('library'), 2);
|
||||
assert.equal(result.get('tags'), null);
|
||||
}
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', null], ['library', null], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts settle independently so a slow images read never delays the others', async () => {
|
||||
@@ -98,6 +99,7 @@ test('nav counts settle independently so a slow images read never delays the oth
|
||||
let releaseImages;
|
||||
const images = new Promise(resolve => { releaseImages = resolve; });
|
||||
const api = {get: async url => {
|
||||
if (url.includes('favorites=1')) return {success: true, total: 2};
|
||||
if (url.startsWith('/prompt_manager/images')) return images;
|
||||
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
|
||||
throw new Error('Unavailable');
|
||||
@@ -105,14 +107,14 @@ test('nav counts settle independently so a slow images read never delays the oth
|
||||
const settled = [];
|
||||
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]);
|
||||
assert.deepEqual(settled.sort(), [['favorites', 2], ['library', 3], ['tags', null]]);
|
||||
releaseImages({success: true, total: 40});
|
||||
const counts = await done;
|
||||
assert.deepEqual(settled.at(-1), ['images', 40]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['favorites', 2], ['library', 3], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts issue three bounded requests and drop only the failed count', async () => {
|
||||
test('nav counts issue four bounded requests and drop only the failed count', async () => {
|
||||
const { loadNavCounts } = await source;
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
@@ -123,15 +125,42 @@ test('nav counts issue three bounded requests and drop only the failed count', a
|
||||
return {success: true, total: 12};
|
||||
}};
|
||||
const counts = await loadNavCounts(api, signal);
|
||||
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
|
||||
assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5]]);
|
||||
const urls = calls.map(([url]) => new URL(url, 'http://host'));
|
||||
assert.deepEqual(urls.map(url => url.pathname),
|
||||
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
['/prompt_manager/images/output', '/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
|
||||
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
|
||||
assert.equal(urls[0].searchParams.has('favorites'), false);
|
||||
// D51: the Favorites count is the Favorites page's own listing.
|
||||
assert.equal(urls[1].searchParams.get('mode'), 'workspace');
|
||||
assert.equal(urls[1].searchParams.get('favorites'), '1');
|
||||
assert.ok(calls.every(([, used]) => used === signal));
|
||||
});
|
||||
|
||||
test('a favorite change re-reads only the Favorites count', async () => {
|
||||
const { loadNavCounts, FAVORITE_WRITE_COUNTS } = await source;
|
||||
assert.deepEqual([...FAVORITE_WRITE_COUNTS], ['favorites']);
|
||||
assert.ok(Object.isFrozen(FAVORITE_WRITE_COUNTS));
|
||||
const calls = [];
|
||||
const api = {get: async url => { calls.push(url); return {success: true, total: 0}; }};
|
||||
const counts = await loadNavCounts(api, new AbortController().signal, () => {}, FAVORITE_WRITE_COUNTS);
|
||||
assert.deepEqual(calls, ['/prompt_manager/images/output?mode=workspace&favorites=1&limit=1']);
|
||||
// A real zero is a count, unlike an outstanding read.
|
||||
assert.deepEqual([...counts], [['favorites', 0]]);
|
||||
});
|
||||
|
||||
test('the Favorites route is native and resolves by its hash', async () => {
|
||||
const { resolveRoute, isHandoff, routeTitle, routes } = await source;
|
||||
const route = resolveRoute('#/favorites');
|
||||
assert.equal(route.id, 'favorites');
|
||||
assert.equal(route.label, 'Favorites');
|
||||
assert.equal(routeTitle(route), 'Favorites');
|
||||
assert.equal(isHandoff(route), false);
|
||||
// No href: Overview lists no legacy card for it.
|
||||
assert.equal(routes.find(item => item.id === 'favorites').href, undefined);
|
||||
});
|
||||
|
||||
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
|
||||
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
|
||||
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
|
||||
@@ -160,3 +189,24 @@ test('navigating to the current route re-renders it, as the concept go() does',
|
||||
win.dispatchEvent(new Event('hashchange'));assert.deepEqual(seen.at(-1),['library',13]);
|
||||
dispose();navigateTo('#/library/13',win);assert.equal(seen.length,3);
|
||||
});
|
||||
|
||||
// Saved views (#249 D16): `#/saved/<id>` opens one persisted view; the shell resolves it to its page.
|
||||
test('saved-view links resolve to their id and re-render only when the id changes', async () => {
|
||||
const {resolveRoute,mountRouter,isHandoff,routes}=await source;
|
||||
const route=resolveRoute('#/saved/7');
|
||||
assert.deepEqual([route.id,route.savedId,route.hash],['saved',7,'#/saved/7']);
|
||||
assert.equal(isHandoff(route),false);
|
||||
assert.equal(routes.some(item=>item.id==='saved'),false,'saved views are not an Overview card or a WORKSPACE link');
|
||||
for(const hash of ['#/saved','#/saved/0','#/saved/-2','#/saved/1.5','#/saved/9007199254740992','#/saved/3/x','#/saved/abc']) {
|
||||
assert.equal(resolveRoute(hash).id,'images',hash);
|
||||
}
|
||||
const win=new EventTarget();win.location={hash:'#/saved/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.savedId]));
|
||||
assert.equal(win.location.hash,'#/saved/3');
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/images';win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen,[['saved',3],['saved',4],['images',undefined]]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -116,19 +116,21 @@ test('toolbar emits concept markup and order', async () => {
|
||||
dom.body.append(container);
|
||||
const changes = [];
|
||||
const filters = [];
|
||||
const saves = [];
|
||||
const toolbar = mountToolbar(container, {
|
||||
scope, categories: ['Portraits', 'Landscapes'],
|
||||
sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
|
||||
value: { q: 'castle', category: 'Landscapes', sort: 'name' },
|
||||
onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
|
||||
onSaveView: trigger => saves.push(trigger),
|
||||
});
|
||||
const root = container.children[0];
|
||||
assert.equal(toolbar.element, root);
|
||||
assert.equal(root.tagName, 'SEARCH');
|
||||
assert.equal(root.className, 'toolbar');
|
||||
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button']);
|
||||
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button', 'button']);
|
||||
|
||||
const [label, category, sort, filtersButton] = root.children;
|
||||
const [label, category, sort, saveButton, filtersButton] = root.children;
|
||||
assert.equal(label.className, 'search-field');
|
||||
assert.equal(label.htmlFor, 'search');
|
||||
assert.deepEqual(tags(label), ['svg', 'input']);
|
||||
@@ -151,8 +153,12 @@ test('toolbar emits concept markup and order', async () => {
|
||||
assert.equal(sort.getAttribute('aria-label'), 'Sort results');
|
||||
assert.equal(sort.value, 'name');
|
||||
|
||||
// D16: no Save view until saved views are persisted.
|
||||
assert.equal(button(root, 'Save view'), undefined);
|
||||
// The concept's Save view, between sort and Filters (a359), reports its own button as the trigger.
|
||||
assert.equal(button(root, 'Save view'), saveButton);
|
||||
assert.equal(saveButton.type, 'button');
|
||||
saveButton.click();
|
||||
assert.deepEqual(saves, [saveButton]);
|
||||
assert.deepEqual(filters, []);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
assert.equal(filtersButton.type, 'button');
|
||||
filtersButton.click();
|
||||
@@ -184,6 +190,9 @@ test('toolbar emits concept markup and order', async () => {
|
||||
toolbar.setValue({ sort: 'bogus' });
|
||||
assert.equal(sort.value, 'newest');
|
||||
assert.throws(() => mountToolbar(container, { categories: [], sorts: [['newest', 'Recently added']],
|
||||
onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
|
||||
// Every toolbar page offers Save view, so the callback is required.
|
||||
assert.throws(() => mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
onChange() {}, onFilters() {} }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
@@ -200,7 +209,7 @@ test('toolbar search uses a debounced onChange that stops with its scope', async
|
||||
const container = new dom.Element('div');
|
||||
const changes = [];
|
||||
mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {} });
|
||||
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {}, onSaveView() {} });
|
||||
const input = container.children[0].children[0].children[1];
|
||||
assert.equal(input.id, 'search');
|
||||
for (const text of ['c', 'ca', 'cat']) {
|
||||
@@ -244,7 +253,7 @@ test('sort lists concept options first', async () => {
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
|
||||
onChange() {}, onFilters() {} });
|
||||
onChange() {}, onFilters() {}, onSaveView() {} });
|
||||
const sort = container.children[0].children[2];
|
||||
assert.deepEqual(optionPairs(sort).map(([, label]) => label),
|
||||
['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
|
||||
@@ -469,6 +478,40 @@ test('copy fallback selects the text', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The concept's Save view dialog (a1330–1346 via simpleInput a1138): an empty name falls back to "Saved view".
|
||||
test('an input dialog with a fallback applies it for an empty value', async () => {
|
||||
const { openInputDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const applied = [];
|
||||
const open = value => openInputDialog({ scope, title: 'Save this view', label: 'View name', action: 'Save',
|
||||
value, maxLength: 100, fallback: 'Saved view', onApply: name => applied.push(name) });
|
||||
open('My workspace view');
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Save this view');
|
||||
const body = byId(dom, 'modal-body');
|
||||
const [field, actions] = body.children;
|
||||
const [label, input] = field.children;
|
||||
assert.equal(label.textContent, 'View name');
|
||||
assert.equal(input.id, 'action-input');
|
||||
assert.equal(input.value, 'My workspace view');
|
||||
assert.equal(input.maxLength, 100);
|
||||
assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Save']);
|
||||
input.value = ' ';
|
||||
actions.children[1].click();
|
||||
assert.deepEqual(applied, ['Saved view']);
|
||||
open('');
|
||||
byId(dom, 'action-input').value = ' Castles ';
|
||||
byId(dom, 'modal-body').children[1].children[1].click();
|
||||
assert.deepEqual(applied, ['Saved view', 'Castles']);
|
||||
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', fallback: '', onApply() {} }), TypeError);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('dialogs restore focus to the trigger on Escape', async () => {
|
||||
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
@@ -662,8 +705,8 @@ test('the Filters button shows the active refinement count', async () => {
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
const toolbar = mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
activeFilters: 2, onChange() {}, onFilters() {} });
|
||||
const filtersButton = container.children[0].children[3];
|
||||
activeFilters: 2, onChange() {}, onFilters() {}, onSaveView() {} });
|
||||
const filtersButton = container.children[0].children[4];
|
||||
// The visible text is the accessible name, so the count is spoken too.
|
||||
assert.equal(filtersButton.textContent, 'Filters · 2');
|
||||
toolbar.setActiveFilters(1);
|
||||
@@ -673,7 +716,7 @@ test('the Filters button shows the active refinement count', async () => {
|
||||
for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
activeFilters: -1, onChange() {}, onFilters() {} }), TypeError);
|
||||
activeFilters: -1, onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
|
||||
@@ -292,6 +292,134 @@ class TestImageFavoriteApi(ImageAPITestCase):
|
||||
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
|
||||
|
||||
|
||||
class TestWorkspaceFavoritesFilter(ImageAPITestCase):
|
||||
"""``favorites=1`` on the workspace listing: the Favorites page (#249 D51)."""
|
||||
|
||||
async def tearDownAsync(self):
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name, self._output_tmp.name)
|
||||
await super().tearDownAsync()
|
||||
|
||||
async def page(self, status=200, **params):
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace", **params}
|
||||
)
|
||||
data = await response.json()
|
||||
self.assertEqual(response.status, status, data)
|
||||
return data
|
||||
|
||||
async def star(self, path, root_index=0, method="PUT"):
|
||||
response = await self.client.request(
|
||||
method, FAVORITE, json={"root_index": root_index, "path": path}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
def make(self, *names):
|
||||
for offset, name in enumerate(names):
|
||||
target = make_png(self.output_dir / name)
|
||||
os.utime(target, (1_700_000_000 + offset, 1_700_000_000 + offset))
|
||||
|
||||
async def test_lists_only_favorites_and_counts_them(self):
|
||||
self.make("a.png", "sub/b.png", "c.png", "sub/d.png")
|
||||
await self.star("a.png")
|
||||
await self.star("sub/d.png")
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual([i["path"] for i in data["images"]], ["sub/d.png", "a.png"])
|
||||
self.assertEqual(data["total"], 2)
|
||||
self.assertTrue(all(i["favorite"] is True for i in data["images"]))
|
||||
# The nav count's read: limit=1 still reports every favorite's total.
|
||||
self.assertEqual((await self.page(favorites="1", limit="1"))["total"], 2)
|
||||
# Other filters intersect with it.
|
||||
sub = await self.page(favorites="1", subfolder="sub")
|
||||
self.assertEqual([i["path"] for i in sub["images"]], ["sub/d.png"])
|
||||
self.assertEqual((await self.page(favorites="1", q="a.png"))["total"], 1)
|
||||
self.assertEqual((await self.page(favorites="1", media="video"))["total"], 0)
|
||||
# favorites=0 (and no parameter) is the whole listing.
|
||||
self.assertEqual((await self.page(favorites="0"))["total"], 4)
|
||||
self.assertEqual((await self.page())["total"], 4)
|
||||
await self.star("a.png", method="DELETE")
|
||||
self.assertEqual((await self.page(favorites="1"))["total"], 1)
|
||||
|
||||
async def test_cursor_pages_walk_only_favorites_in_every_order(self):
|
||||
self.make("a.png", "b.png", "c.png", "d.png", "e.png")
|
||||
for name in ("a.png", "c.png", "e.png"):
|
||||
await self.star(name)
|
||||
expected = {
|
||||
"newest": ["e.png", "c.png", "a.png"],
|
||||
"oldest": ["a.png", "c.png", "e.png"],
|
||||
"name": ["a.png", "c.png", "e.png"],
|
||||
"name-desc": ["e.png", "c.png", "a.png"],
|
||||
"rating": ["e.png", "c.png", "a.png"],
|
||||
}
|
||||
for sort, names in expected.items():
|
||||
seen, cursor = [], None
|
||||
while True:
|
||||
params = {"favorites": "1", "sort": sort, "limit": "1"}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
data = await self.page(**params)
|
||||
self.assertEqual(data["total"], 3, sort)
|
||||
seen += [i["path"] for i in data["images"]]
|
||||
cursor = data["next_cursor"]
|
||||
if not data["has_more"]:
|
||||
break
|
||||
self.assertEqual(seen, names, sort)
|
||||
# A cursor is bound to its filters: a whole-listing cursor is refused.
|
||||
whole = await self.page(limit="1")
|
||||
await self.page(400, favorites="1", cursor=whole["next_cursor"])
|
||||
|
||||
async def test_favorites_follow_their_own_root(self):
|
||||
second = self.comfy_dir / "second"
|
||||
make_png(second / "a.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
await self.star("a.png", root_index=1)
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual(
|
||||
[(i["root_index"], i["path"]) for i in data["images"]], [(1, "a.png")]
|
||||
)
|
||||
self.assertEqual((await self.page(favorites="1", root="0"))["total"], 0)
|
||||
self.assertEqual((await self.page(favorites="1", root="1"))["total"], 1)
|
||||
|
||||
async def test_windows_case_folded_favorite_is_listed(self):
|
||||
make_png(self.output_dir / "Sub" / "A.png")
|
||||
with patch("py.api.images._path_flavor", ntpath):
|
||||
await self.star("Sub/A.png")
|
||||
(self.output_dir / "Sub" / "A.png").rename(
|
||||
self.output_dir / "Sub" / "a.png"
|
||||
)
|
||||
(self.output_dir / "Sub").rename(self.output_dir / "sub")
|
||||
self.api.invalidate_gallery_cache()
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual([i["path"] for i in data["images"]], ["sub/a.png"])
|
||||
|
||||
async def test_a_favorite_whose_file_is_gone_is_not_listed(self):
|
||||
self.make("a.png", "b.png")
|
||||
await self.star("a.png")
|
||||
await self.star("b.png")
|
||||
(self.output_dir / "b.png").unlink()
|
||||
self.api.invalidate_gallery_cache()
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual([i["path"] for i in data["images"]], ["a.png"])
|
||||
self.assertEqual(data["total"], 1)
|
||||
|
||||
async def test_rejects_other_values(self):
|
||||
for value in ("true", "2", "", "yes"):
|
||||
data = await self.page(400, favorites=value)
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
async def test_store_failure_is_an_error_not_an_unfiltered_page(self):
|
||||
self.make("a.png")
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
self.api.db.get_root_favorites = broken
|
||||
data = await self.page(500, favorites="1")
|
||||
self.assertFalse(data["success"])
|
||||
self.assertNotIn("images", data)
|
||||
|
||||
|
||||
class TestImageFavoriteSchema(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
@@ -314,6 +442,10 @@ class TestImageFavoriteSchema(unittest.TestCase):
|
||||
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
|
||||
)
|
||||
self.assertEqual(db.get_image_favorites("output", []), set())
|
||||
# One root's favorites, read through the primary key.
|
||||
self.assertEqual(db.get_root_favorites("output"), {"a.png"})
|
||||
self.assertEqual(db.get_root_favorites("other"), {"a.png"})
|
||||
self.assertEqual(db.get_root_favorites("missing"), set())
|
||||
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
|
||||
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
|
||||
self.assertTrue(db.clear_image_favorite("output", "a.png"))
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
"""Saved views (D16): the persisted store, its restore schema and the API."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase
|
||||
from open_handles import assert_closed
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase
|
||||
from tests.open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
from database.saved_views import (
|
||||
MAX_STATE_BYTES,
|
||||
TABLE,
|
||||
SavedViewInvalid,
|
||||
encode_state,
|
||||
migrate_saved_views,
|
||||
normalize_state,
|
||||
saved_views_schema_error,
|
||||
validate_name,
|
||||
)
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
URL = "/prompt_manager/saved-views"
|
||||
|
||||
IMAGES_STATE = {
|
||||
"q": "cat",
|
||||
"category": "Animals",
|
||||
"sort": "newest",
|
||||
"source": "all",
|
||||
"folder": "",
|
||||
"media": "all",
|
||||
"tags": ["fluffy"],
|
||||
"favorites": False,
|
||||
}
|
||||
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
|
||||
IMAGES_DEFAULTS = {
|
||||
"q": "",
|
||||
"category": "",
|
||||
"sort": "newest",
|
||||
"source": "all",
|
||||
"folder": "",
|
||||
"media": "all",
|
||||
"tags": [],
|
||||
"favorites": False,
|
||||
}
|
||||
LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []}
|
||||
# A row saved by an older build: no favorites key, a retired key, a sort
|
||||
# that is no longer offered and an over-long folder.
|
||||
OLD_IMAGES_STATE = json.dumps(
|
||||
{
|
||||
"q": "sunset",
|
||||
"category": "",
|
||||
"sort": "shuffle",
|
||||
"source": "all",
|
||||
"folder": "x" * 2000,
|
||||
"media": "image",
|
||||
"tags": ["golden"],
|
||||
"layout": "grid",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestSavedViewsApi(ImageAPITestCase):
|
||||
async def send(self, method, path="", body=None, **kwargs):
|
||||
if body is not None:
|
||||
kwargs["json"] = body
|
||||
response = await self.client.request(method, URL + path, **kwargs)
|
||||
return response, await response.json()
|
||||
|
||||
async def create(self, page="images", name="Cats", state=None):
|
||||
state = dict(
|
||||
IMAGES_STATE if page == "images" else LIBRARY_STATE, **(state or {})
|
||||
)
|
||||
response, data = await self.send(
|
||||
"POST", body={"page": page, "name": name, "state": state}
|
||||
)
|
||||
self.assertEqual(response.status, 201, data)
|
||||
return data["view"]
|
||||
|
||||
async def test_create_list_rename_delete_round_trip(self):
|
||||
response, data = await self.send("GET")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data, {"success": True, "views": []})
|
||||
|
||||
first = await self.create(name=" Cats ")
|
||||
self.assertEqual(
|
||||
first,
|
||||
{
|
||||
"id": first["id"],
|
||||
"page": "images",
|
||||
"name": "Cats",
|
||||
"state": IMAGES_STATE,
|
||||
},
|
||||
)
|
||||
second = await self.create(page="library", name="Best", state={})
|
||||
_, data = await self.send("GET")
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["views"], [first, second])
|
||||
|
||||
response, data = await self.send(
|
||||
"PATCH", f"/{first['id']}", {"name": " Kittens "}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data, {"success": True, "view": {**first, "name": "Kittens"}})
|
||||
_, listed = await self.send("GET")
|
||||
# Renaming keeps the view's place in the list.
|
||||
self.assertEqual(
|
||||
[view["name"] for view in listed["views"]], ["Kittens", "Best"]
|
||||
)
|
||||
|
||||
response, data = await self.send("DELETE", f"/{first['id']}")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data, {"success": True, "id": first["id"]})
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(listed["views"], [second])
|
||||
|
||||
for method, body in (("PATCH", {"name": "Again"}), ("DELETE", None)):
|
||||
response, data = await self.send(method, f"/{first['id']}", body)
|
||||
self.assertEqual(response.status, 404)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("error", data)
|
||||
|
||||
async def test_views_are_ordered_by_position_and_ids_are_not_reused(self):
|
||||
views = [await self.create(name=f"View {n}") for n in range(3)]
|
||||
await self.send("DELETE", f"/{views[2]['id']}")
|
||||
later = await self.create(name="Later")
|
||||
self.assertGreater(later["id"], views[2]["id"])
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE {TABLE} SET position = -1 WHERE id = ?", (views[1]["id"],)
|
||||
)
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(
|
||||
[view["name"] for view in listed["views"]], ["View 1", "View 0", "Later"]
|
||||
)
|
||||
|
||||
async def test_create_rejects_invalid_bodies(self):
|
||||
good = {"page": "images", "name": "Cats", "state": IMAGES_STATE}
|
||||
cases = [
|
||||
["not an object"],
|
||||
{"page": "images", "name": "Cats"},
|
||||
{**good, "extra": 1},
|
||||
{**good, "page": "history"},
|
||||
{**good, "page": 1},
|
||||
{**good, "name": ""},
|
||||
{**good, "name": " "},
|
||||
{**good, "name": "x" * 101},
|
||||
{**good, "name": 7},
|
||||
{**good, "name": "bad\nname"},
|
||||
{**good, "state": "q=cat"},
|
||||
{**good, "state": None},
|
||||
{**good, "state": {**IMAGES_STATE, "unknown": ""}},
|
||||
{**good, "state": {k: v for k, v in IMAGES_STATE.items() if k != "q"}},
|
||||
{**good, "state": {**IMAGES_STATE, "q": 5}},
|
||||
{**good, "state": {**IMAGES_STATE, "q": "x" * 501}},
|
||||
{**good, "state": {**IMAGES_STATE, "category": "x" * 101}},
|
||||
{**good, "state": {**IMAGES_STATE, "sort": "random"}},
|
||||
{**good, "state": {**IMAGES_STATE, "media": "audio"}},
|
||||
{**good, "state": {**IMAGES_STATE, "source": "-1"}},
|
||||
{**good, "state": {**IMAGES_STATE, "source": 0}},
|
||||
{**good, "state": {**IMAGES_STATE, "folder": None}},
|
||||
{**good, "state": {**IMAGES_STATE, "folder": "x" * 1025}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": "cat"}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": [1]}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": [" "]}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": ["a,b"]}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": ["x" * 51]}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": [f"t{n}" for n in range(21)]}},
|
||||
# Favorites (the Favorites page's filter) is a JSON boolean.
|
||||
{**good, "state": {**IMAGES_STATE, "favorites": "true"}},
|
||||
{**good, "state": {**IMAGES_STATE, "favorites": 1}},
|
||||
{**good, "state": {**IMAGES_STATE, "favorites": None}},
|
||||
{
|
||||
**good,
|
||||
"state": {k: v for k, v in IMAGES_STATE.items() if k != "favorites"},
|
||||
},
|
||||
# Library has no source, folder or media, and its own sorts.
|
||||
{"page": "library", "name": "L", "state": IMAGES_STATE},
|
||||
{
|
||||
"page": "library",
|
||||
"name": "L",
|
||||
"state": {**LIBRARY_STATE, "sort": "name-desc"},
|
||||
},
|
||||
]
|
||||
for body in cases:
|
||||
with self.subTest(body=body):
|
||||
response, data = await self.send("POST", body=body)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIsInstance(data["error"], str)
|
||||
response = await self.client.post(URL, data="{not json")
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertFalse((await response.json())["success"])
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(listed["views"], [])
|
||||
|
||||
async def test_encoded_state_is_capped(self):
|
||||
# Each value is within its own limit; together they pass 4 KB.
|
||||
state = {
|
||||
**IMAGES_STATE,
|
||||
"q": "é" * 500,
|
||||
"folder": "é" * 1024,
|
||||
"tags": ["é" * 50 for _ in range(20)],
|
||||
}
|
||||
encoded = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
|
||||
self.assertGreater(len(encoded.encode("utf-8")), MAX_STATE_BYTES)
|
||||
response, data = await self.send(
|
||||
"POST", body={"page": "images", "name": "Big", "state": state}
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn("4 KB", data["error"])
|
||||
|
||||
async def test_every_documented_value_is_accepted(self):
|
||||
for sort in ("newest", "oldest", "name", "name-desc", "rating"):
|
||||
for media in ("all", "image", "video"):
|
||||
await self.create(state={"sort": sort, "media": media, "source": "3"})
|
||||
favorite = await self.create(state={"favorites": True})
|
||||
self.assertIs(favorite["state"]["favorites"], True)
|
||||
for sort in ("newest", "oldest", "name", "rating"):
|
||||
await self.create(page="library", state={"sort": sort, "tags": ["a b"]})
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(len(listed["views"]), 20)
|
||||
|
||||
async def test_rename_and_delete_validate_id_and_body(self):
|
||||
view = await self.create()
|
||||
for path in ("/0", "/-1", "/abc", "/1.5", f"/{2**63}"):
|
||||
for method, body in (("PATCH", {"name": "X"}), ("DELETE", None)):
|
||||
with self.subTest(path=path, method=method):
|
||||
response, data = await self.send(method, path, body)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertFalse(data["success"])
|
||||
for body in ({}, {"name": ""}, {"name": "X", "page": "library"}, ["X"]):
|
||||
with self.subTest(body=body):
|
||||
response, _ = await self.send("PATCH", f"/{view['id']}", body)
|
||||
self.assertEqual(response.status, 400)
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(listed["views"], [view])
|
||||
|
||||
async def test_database_failure_is_a_500_envelope(self):
|
||||
def broken():
|
||||
raise sqlite3.OperationalError("disk I/O error")
|
||||
|
||||
self.api.db.list_saved_views = broken
|
||||
with self.assertLogs("prompt_manager.api", level="ERROR"):
|
||||
response, data = await self.send("GET")
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertEqual(
|
||||
data, {"success": False, "error": "Saved view operation failed"}
|
||||
)
|
||||
|
||||
async def test_internal_value_errors_are_a_generic_500(self):
|
||||
# Only the request's own validation is a 400; a ValueError or
|
||||
# TypeError raised by the database layer is a server fault (M4).
|
||||
for error in (
|
||||
ValueError("secret parser detail"),
|
||||
TypeError("secret type detail"),
|
||||
json.JSONDecodeError("secret json detail", "{", 0),
|
||||
):
|
||||
|
||||
def broken(*_args, error=error):
|
||||
raise error
|
||||
|
||||
for method, path, body, attr in (
|
||||
("GET", "", None, "list_saved_views"),
|
||||
(
|
||||
"POST",
|
||||
"",
|
||||
{"page": "library", "name": "L", "state": LIBRARY_STATE},
|
||||
"create_saved_view",
|
||||
),
|
||||
("PATCH", "/1", {"name": "X"}, "rename_saved_view"),
|
||||
("DELETE", "/1", None, "delete_saved_view"),
|
||||
):
|
||||
with self.subTest(error=type(error).__name__, method=method):
|
||||
setattr(self.api.db, attr, broken)
|
||||
try:
|
||||
with self.assertLogs("prompt_manager.api", level="ERROR"):
|
||||
response, data = await self.send(method, path, body)
|
||||
finally:
|
||||
delattr(self.api.db, attr)
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertEqual(
|
||||
data,
|
||||
{"success": False, "error": "Saved view operation failed"},
|
||||
)
|
||||
|
||||
async def test_a_corrupt_state_is_skipped_by_the_listing(self):
|
||||
kept = await self.create(page="library", name="Kept", state={})
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('images', 'Broken', '{', 0, 'x', 'x')"
|
||||
)
|
||||
response, data = await self.send("GET")
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(data["views"], [kept])
|
||||
# Hidden from the listing, it is not found by rename either.
|
||||
broken_id = kept["id"] + 1
|
||||
response, _ = await self.send("PATCH", f"/{broken_id}", {"name": "X"})
|
||||
self.assertEqual(response.status, 404)
|
||||
response, _ = await self.send("DELETE", f"/{broken_id}")
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
async def test_a_row_saved_under_older_rules_lists_normalized(self):
|
||||
stale = json.dumps(
|
||||
{
|
||||
"q": "cat",
|
||||
"sort": "random",
|
||||
"media": "video",
|
||||
"tags": ["ok", 7],
|
||||
"retired": True,
|
||||
}
|
||||
)
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('images', ' Old\tview ', ?, 0, 'x', 'x')",
|
||||
(stale,),
|
||||
)
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('library', '', '{}', 1, 'x', 'x')"
|
||||
)
|
||||
response, data = await self.send("GET")
|
||||
self.assertEqual(response.status, 200, data)
|
||||
images, library = data["views"]
|
||||
self.assertEqual(images["name"], "Oldview")
|
||||
self.assertEqual(
|
||||
images["state"],
|
||||
{**IMAGES_DEFAULTS, "q": "cat", "media": "video"},
|
||||
)
|
||||
self.assertEqual(library["name"], "Saved view")
|
||||
self.assertEqual(library["state"], LIBRARY_DEFAULTS)
|
||||
# A stale view still renames and deletes.
|
||||
response, data = await self.send(
|
||||
"PATCH", f"/{images['id']}", {"name": "Renamed"}
|
||||
)
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(data["view"]["state"], images["state"])
|
||||
|
||||
|
||||
class TestSavedViewsSchema(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.path = os.path.join(self.tmpdir, "prompts.db")
|
||||
self.db = PromptDatabase(self.path)
|
||||
self.addCleanup(self._cleanup)
|
||||
|
||||
def _cleanup(self):
|
||||
PromptModel.close_all_instances()
|
||||
assert_closed(self, self.tmpdir)
|
||||
for name in os.listdir(self.tmpdir):
|
||||
os.unlink(os.path.join(self.tmpdir, name))
|
||||
os.rmdir(self.tmpdir)
|
||||
|
||||
def _source(self, name="source.db", views=()):
|
||||
"""A closed database file holding a prompt and the given views."""
|
||||
path = os.path.join(self.tmpdir, name)
|
||||
src = PromptDatabase(path)
|
||||
try:
|
||||
src.save_prompt(text="kept", prompt_hash=generate_prompt_hash("kept"))
|
||||
for page, view_name, state in views:
|
||||
src.create_saved_view(page, view_name, state)
|
||||
finally:
|
||||
src.close_all()
|
||||
return path
|
||||
|
||||
def test_current_database_has_a_valid_table(self):
|
||||
conn = self.db.model.get_connection()
|
||||
self.assertIsNone(saved_views_schema_error(conn, allow_missing=False))
|
||||
ok, reason = PromptModel.verify_database_file(
|
||||
self.path, require_library_state=True
|
||||
)
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
def test_migration_is_idempotent(self):
|
||||
conn = self.db.model.get_connection()
|
||||
self.db.create_saved_view("images", "Cats", IMAGES_STATE)
|
||||
migrate_saved_views(conn)
|
||||
self.assertEqual(len(self.db.list_saved_views()), 1)
|
||||
|
||||
def test_backup_restore_round_trip_keeps_saved_views(self):
|
||||
self.db.create_saved_view("library", "Live", LIBRARY_STATE)
|
||||
src_path = self._source(
|
||||
views=[
|
||||
("images", "Cats", IMAGES_STATE),
|
||||
("library", "Best", LIBRARY_STATE),
|
||||
]
|
||||
)
|
||||
# The views may still sit in the WAL; the backup must carry them.
|
||||
backup = os.path.join(self.tmpdir, "backup.db")
|
||||
reopened = PromptDatabase(src_path)
|
||||
try:
|
||||
self.assertTrue(reopened.model.backup_database(backup))
|
||||
finally:
|
||||
reopened.close_all()
|
||||
|
||||
previous = self.db.model.restore_from_file(backup)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
(view["page"], view["name"], view["state"])
|
||||
for view in self.db.list_saved_views()
|
||||
],
|
||||
[("images", "Cats", IMAGES_STATE), ("library", "Best", LIBRARY_STATE)],
|
||||
)
|
||||
created = self.db.create_saved_view("images", "After", IMAGES_STATE)
|
||||
self.assertEqual(self.db.list_saved_views()[-1], created)
|
||||
with closing(sqlite3.connect(previous)) as conn:
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT name FROM {TABLE}").fetchall(), [("Live",)]
|
||||
)
|
||||
|
||||
def test_restore_old_backup_without_saved_views(self):
|
||||
self.db.create_saved_view("images", "Live", IMAGES_STATE)
|
||||
src = self._source()
|
||||
with closing(sqlite3.connect(src)) as conn:
|
||||
conn.execute(f"DROP TABLE {TABLE}")
|
||||
conn.commit()
|
||||
self.assertTrue(PromptModel.verify_database_file(src)[0])
|
||||
ok, _ = PromptModel.verify_database_file(src, require_library_state=True)
|
||||
self.assertFalse(ok)
|
||||
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
self.assertEqual(self.db.list_saved_views(), [])
|
||||
self.db.create_saved_view("library", "New", LIBRARY_STATE)
|
||||
self.assertEqual(len(self.db.list_saved_views()), 1)
|
||||
with closing(sqlite3.connect(src)) as conn: # the source is untouched
|
||||
self.assertIsNone(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name=?", (TABLE,)
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def _tampered(self, *statements, views=(("images", "Cats", IMAGES_STATE),)):
|
||||
src = self._source(
|
||||
name=f"tampered{len(os.listdir(self.tmpdir))}.db", views=views
|
||||
)
|
||||
with closing(sqlite3.connect(src)) as conn:
|
||||
for statement in statements:
|
||||
conn.execute(statement)
|
||||
conn.commit()
|
||||
return src
|
||||
|
||||
def test_restore_rejects_tampered_saved_views(self):
|
||||
cases = {
|
||||
"definition": (
|
||||
f"DROP TABLE {TABLE}",
|
||||
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY, page TEXT, "
|
||||
"name TEXT, state TEXT, position INTEGER, created_at TEXT, "
|
||||
"updated_at TEXT)",
|
||||
),
|
||||
"index": (f"CREATE INDEX saved_views_extra ON {TABLE}(name)",),
|
||||
"page": (
|
||||
"PRAGMA ignore_check_constraints = ON",
|
||||
f"UPDATE {TABLE} SET page = 'history'",
|
||||
),
|
||||
"long name": (f"UPDATE {TABLE} SET name = '{'x' * 401}'",),
|
||||
"blob name": (f"UPDATE {TABLE} SET name = CAST('Cats' AS BLOB)",),
|
||||
"state json": (f"UPDATE {TABLE} SET state = '{{'",),
|
||||
"state array": (f"UPDATE {TABLE} SET state = '[]'",),
|
||||
"state string": (f"UPDATE {TABLE} SET state = '\"q\"'",),
|
||||
"state size": (
|
||||
f"UPDATE {TABLE} SET state = '{'x' * (MAX_STATE_BYTES + 1)}'",
|
||||
),
|
||||
"position": (f"UPDATE {TABLE} SET position = 'first'",),
|
||||
"timestamp": (f"UPDATE {TABLE} SET created_at = X'00'",),
|
||||
}
|
||||
for label, statements in cases.items():
|
||||
with self.subTest(label=label):
|
||||
src = self._tampered(*statements)
|
||||
ok, reason = PromptModel.verify_database_file(src)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("Saved views", reason)
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
def _insert_old_rows(self, path):
|
||||
"""Rows written under older value rules, straight into the file."""
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('images', 'Golden hour', ?, 5, 'x', 'x')",
|
||||
(OLD_IMAGES_STATE,),
|
||||
)
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('library', ?, ?, 6, 'x', 'x')",
|
||||
(" " + "n" * 120, json.dumps({"sort": "name-desc", "q": 4})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _assert_old_rows_normalized(self, views):
|
||||
self.assertEqual(
|
||||
[(view["page"], view["name"], view["state"]) for view in views],
|
||||
[
|
||||
(
|
||||
"images",
|
||||
"Golden hour",
|
||||
{
|
||||
**IMAGES_DEFAULTS,
|
||||
"q": "sunset",
|
||||
"media": "image",
|
||||
"tags": ["golden"],
|
||||
},
|
||||
),
|
||||
("library", "n" * 100, LIBRARY_DEFAULTS),
|
||||
],
|
||||
)
|
||||
|
||||
def test_a_database_with_rows_from_older_rules_still_opens(self):
|
||||
self.db.close_all()
|
||||
self._insert_old_rows(self.path)
|
||||
PromptModel.reset_schema_cache()
|
||||
|
||||
reopened = PromptDatabase(self.path)
|
||||
try:
|
||||
self._assert_old_rows_normalized(reopened.list_saved_views())
|
||||
finally:
|
||||
reopened.close_all()
|
||||
ok, reason = PromptModel.verify_database_file(
|
||||
self.path, require_library_state=True
|
||||
)
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
def test_restore_accepts_rows_from_older_rules(self):
|
||||
src = self._source()
|
||||
self._insert_old_rows(src)
|
||||
ok, reason = PromptModel.verify_database_file(src, require_library_state=True)
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
self._assert_old_rows_normalized(self.db.list_saved_views())
|
||||
|
||||
def _insert_corrupt_rows(self, path):
|
||||
"""A valid view between two rows whose state is no JSON object."""
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
ids = []
|
||||
for name, state in (
|
||||
("Broken", "{not json"),
|
||||
("Kept", json.dumps(LIBRARY_STATE)),
|
||||
("Array", "[1, 2]"),
|
||||
):
|
||||
ids.append(
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, "
|
||||
"created_at, updated_at) VALUES ('library', ?, ?, 0, "
|
||||
"'x', 'x')",
|
||||
(name, state),
|
||||
).lastrowid
|
||||
)
|
||||
conn.commit()
|
||||
return ids
|
||||
|
||||
def test_startup_tolerates_a_corrupt_state_and_list_skips_it(self):
|
||||
self.db.close_all()
|
||||
broken, kept, array = self._insert_corrupt_rows(self.path)
|
||||
PromptModel.reset_schema_cache()
|
||||
|
||||
with self.assertLogs("prompt_manager.database", level="WARNING") as logs:
|
||||
reopened = PromptDatabase(self.path)
|
||||
try:
|
||||
self.assertEqual(
|
||||
[(view["id"], view["name"]) for view in reopened.list_saved_views()],
|
||||
[(kept, "Kept")],
|
||||
)
|
||||
finally:
|
||||
reopened.close_all()
|
||||
output = "\n".join(logs.output)
|
||||
self.assertIn(f"id {broken}", output)
|
||||
self.assertIn(f"id {array}", output)
|
||||
self.assertNotIn("not json", output)
|
||||
# The rows stay in place, untouched.
|
||||
with closing(sqlite3.connect(self.path)) as conn:
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0], 3
|
||||
)
|
||||
|
||||
def test_restore_rejects_a_backup_with_a_corrupt_state(self):
|
||||
src = self._source()
|
||||
self._insert_corrupt_rows(src)
|
||||
ok, reason = PromptModel.verify_database_file(src)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(reason, "Saved views contains invalid values")
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
def test_normalize_state_keeps_valid_values_and_defaults_the_rest(self):
|
||||
self.assertEqual(normalize_state("images", IMAGES_STATE), IMAGES_STATE)
|
||||
self.assertEqual(normalize_state("library", LIBRARY_STATE), LIBRARY_STATE)
|
||||
self.assertEqual(
|
||||
normalize_state("images", json.dumps(IMAGES_STATE)), IMAGES_STATE
|
||||
)
|
||||
for raw in (None, "{", "[]", '"q"', 7, ["q"], b"{}"):
|
||||
with self.subTest(raw=raw):
|
||||
self.assertEqual(normalize_state("library", raw), LIBRARY_DEFAULTS)
|
||||
self.assertEqual(
|
||||
normalize_state(
|
||||
"images",
|
||||
{
|
||||
"q": "bad\x00",
|
||||
"source": 3,
|
||||
"favorites": "true",
|
||||
"tags": [f"t{n}" for n in range(21)],
|
||||
"folder": "kept",
|
||||
},
|
||||
),
|
||||
{**IMAGES_DEFAULTS, "folder": "kept"},
|
||||
)
|
||||
self.assertEqual(normalize_state("history", {}), {})
|
||||
|
||||
def test_validation_errors_are_saved_view_invalid(self):
|
||||
for call in (
|
||||
lambda: validate_name(""),
|
||||
lambda: validate_name(3),
|
||||
lambda: encode_state("history", {}),
|
||||
lambda: encode_state("library", {"q": ""}),
|
||||
lambda: encode_state("library", {**LIBRARY_STATE, "sort": "x"}),
|
||||
):
|
||||
with self.assertRaises(SavedViewInvalid):
|
||||
call()
|
||||
self.assertTrue(issubclass(SavedViewInvalid, ValueError))
|
||||
|
||||
def test_migration_refuses_an_unsupported_table(self):
|
||||
path = os.path.join(self.tmpdir, "foreign.db")
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
conn.execute(f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY)")
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
migrate_saved_views(conn)
|
||||
# The savepoint rolled back, so the foreign table is untouched.
|
||||
self.assertEqual(
|
||||
conn.execute(
|
||||
f"SELECT COUNT(*) FROM pragma_table_info('{TABLE}')"
|
||||
).fetchone()[0],
|
||||
1,
|
||||
)
|
||||
self.assertTrue(Path(path).exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -305,6 +305,12 @@
|
||||
"functions": 77,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/saved-views.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
|
||||
@@ -36,6 +36,10 @@ function queryFor(filters, batchSize) {
|
||||
const sort = filters.sort ?? 'newest';
|
||||
if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter');
|
||||
query.set('sort', sort);
|
||||
// The Favorites page (#249 D51): the listing keeps only favorites.
|
||||
const favorites = filters.favorites ?? false;
|
||||
if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter');
|
||||
if (favorites) query.set('favorites', '1');
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { pageHeading } from './heading.mjs';
|
||||
import { mountToolbar } from './toolbar.mjs';
|
||||
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
|
||||
/** The concept's panel without an image (app.js updateImages a456–458). */
|
||||
function noSelection() {
|
||||
@@ -95,12 +96,23 @@ function deliverFavorite(key, favorite) {
|
||||
else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
|
||||
for (const target of embeddedFavoriteTargets) target(key, favorite);
|
||||
}
|
||||
// Told after every confirmed favorite change, wherever it was made (the shell's Favorites count, #249 D51).
|
||||
const favoriteListeners = new Set();
|
||||
/** Run `listener()` after each confirmed favorite change; returns the unsubscribe. */
|
||||
export function onFavoriteChange(listener) {
|
||||
favoriteListeners.add(listener);
|
||||
return () => favoriteListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no
|
||||
* 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.
|
||||
*/
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null} = {}) {
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
|
||||
favorites = false, onSaveView = () => {}} = {}) {
|
||||
const scope = createScope();
|
||||
const dispose = () => scope.dispose();
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
@@ -113,7 +125,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
const visit = embedded ? null : lastVisit;
|
||||
const saved = embedded || !savedState ? null : imagesFilters(savedState);
|
||||
const visit = embedded || saved || favorites ? null : lastVisit;
|
||||
let density = visit?.density || 'comfortable';
|
||||
let metrics = {...INITIAL_METRICS};
|
||||
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
|
||||
@@ -122,11 +135,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
let roots = [], folders = [];
|
||||
const rendered = new Map();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved,
|
||||
favorites: favorites || saved?.favorites === true };
|
||||
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.
|
||||
let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== '');
|
||||
// The first discovery settled (found or failed): only then may a pending saved scope be kept or dropped (#249 M3).
|
||||
let discoverySettled = false;
|
||||
scope.cleanup(() => {
|
||||
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
|
||||
if (!embedded) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
// 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;
|
||||
cancelAnimationFrame(restoreFrame);
|
||||
});
|
||||
main.classList.add('images-main');
|
||||
@@ -157,7 +178,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (Object.entries(next).every(([key, item]) => filters[key] === item)) return;
|
||||
Object.assign(filters, next);
|
||||
reset();
|
||||
}, onFilters:openFilters});
|
||||
}, onFilters:openFilters, onSaveView:trigger => {
|
||||
const value = toolbar.value();
|
||||
onSaveView(trigger, imagesViewState({...filters, q:value.q.trim(), category:value.category, sort:value.sort}));
|
||||
}});
|
||||
const toolbarControls = toolbar ? [...toolbar.element.querySelectorAll('input, select, button')] : [];
|
||||
const optionsStatus = el('p', '', 'options-status muted');
|
||||
optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert');
|
||||
@@ -360,6 +384,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
favoritePending.delete(key);
|
||||
deliverFavorite(key, saved ? favorite : !favorite);
|
||||
showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
|
||||
// On Favorites an unstarred card stays (with ☆) until the next load, so the star can be undone in place (D51).
|
||||
if (saved) for (const listener of favoriteListeners) listener();
|
||||
}
|
||||
scope.listen(grid, 'click', event => {
|
||||
const card = event.target.closest('article[data-image-key]');
|
||||
@@ -593,9 +619,20 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
|
||||
bar?.clear();
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
// A toolbar change while discovery is still pending keeps the saved Source and Folder; the settled discovery
|
||||
// then lists the view with every current value (#249 M3).
|
||||
if (savedScopePending && !discoverySettled) { updateScope(); return; }
|
||||
settleSavedScope();
|
||||
updateScope();
|
||||
stream.reset(filters, {load:!recovering});
|
||||
}
|
||||
/** A saved view's Source and Folder apply only while the discovered sources still list them (#249 D16). */
|
||||
function settleSavedScope() {
|
||||
if (!savedScopePending) return;
|
||||
savedScopePending = false;
|
||||
if (filters.root !== 'all' && !roots.some(root => String(root.index) === filters.root)) filters.root = 'all';
|
||||
if (!folderOptions(filters.root).some(([path]) => path === filters.subfolder)) filters.subfolder = '';
|
||||
}
|
||||
/** A panel tag chip (D21): the Images Tags filter becomes exactly that tag, as "Refine this view" would set it. */
|
||||
function filterByTag(tag) {
|
||||
if (recovering || !tag.trim() || tag.includes(',')) return;
|
||||
@@ -744,7 +781,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); });
|
||||
updateScope();
|
||||
// The page's one discovery alert is written once both first loads settled (#249 final review m6).
|
||||
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => { if (!scope.signal.aborted) renderLoadStatus(); });
|
||||
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => {
|
||||
if (scope.signal.aborted) return;
|
||||
discoverySettled = true;
|
||||
renderLoadStatus();
|
||||
if (savedScopePending) reset();
|
||||
});
|
||||
if (visit) {
|
||||
renderState(stream.state);
|
||||
const selected = stream.state.items.find(image => imageKey(image) === selection);
|
||||
@@ -753,7 +795,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (!scope.signal.aborted) window.scrollTo(0, visit.scrollY);
|
||||
});
|
||||
if (visit.state.loading || visit.state.error) stream.loadMore();
|
||||
} else reset();
|
||||
} else if (savedScopePending) updateScope();
|
||||
else reset();
|
||||
/** D6: the selected file's prompt pair, only once its inspection loaded with both roles resolved. */
|
||||
function selectedPair() {
|
||||
const {record, prompts, loading, error, refreshRequired} = inspection.state;
|
||||
|
||||
+115
-10
@@ -1,15 +1,16 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs';
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
import { mountImages } from './images.mjs';
|
||||
import { mountImages, onFavoriteChange } from './images.mjs';
|
||||
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
|
||||
import { mountVocabulary } from './vocabulary.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading } from './heading.mjs';
|
||||
import { renderHeading, pageHeading } from './heading.mjs';
|
||||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
|
||||
@@ -31,6 +32,7 @@ function mountWorkspace() {
|
||||
const api = createApiClient();
|
||||
const main = document.getElementById('workspace-main');
|
||||
const primaryNav = document.getElementById('primary-nav');
|
||||
const savedNav = document.getElementById('saved-nav');
|
||||
const systemNav = document.getElementById('system-nav');
|
||||
scope.listen(document.querySelector('.skip'), 'click', event => {
|
||||
event.preventDefault();
|
||||
@@ -61,6 +63,35 @@ function mountWorkspace() {
|
||||
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
|
||||
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
|
||||
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;
|
||||
let currentKey = null;
|
||||
const markCurrent = () => {
|
||||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||||
if (link.dataset.route === currentKey) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
}
|
||||
};
|
||||
// SAVED VIEWS (#249 D16): the concept's saved-nav rows (search icon, name, no count), loaded with the shell and
|
||||
// re-read after every change. /* D49 */ no "PROPOSED" badge; /* D50 */ no "Needs review" row. The first row is
|
||||
// the concept's Favorites (star icon, count), kept across every re-read so its count stays (D51).
|
||||
const [favoritesLink] = navLinks([['favorites', 'star']]);
|
||||
savedNav.replaceChildren(favoritesLink);
|
||||
const savedViews = createSavedViews(api, scope.signal);
|
||||
scope.cleanup(savedViews.subscribe(({ views }) => {
|
||||
savedNav.replaceChildren(favoritesLink, ...views.map(view => {
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon('search'), document.createTextNode(view.name));
|
||||
link.href = savedViewHash(view.id);
|
||||
link.dataset.route = `saved/${view.id}`;
|
||||
return link;
|
||||
}));
|
||||
markCurrent();
|
||||
}));
|
||||
savedViews.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) });
|
||||
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
|
||||
const countRequests = new Map();
|
||||
function refreshCounts(ids) {
|
||||
@@ -71,7 +102,7 @@ function mountWorkspace() {
|
||||
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
|
||||
loadNavCounts(api, request.signal, (_, count) => {
|
||||
if (request.signal.aborted) return;
|
||||
const link = primaryNav.querySelector(`[data-route="${id}"]`);
|
||||
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
|
||||
link?.querySelector('.nav-count')?.remove();
|
||||
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
||||
}, [id]);
|
||||
@@ -80,6 +111,14 @@ function mountWorkspace() {
|
||||
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
|
||||
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
|
||||
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
|
||||
// Starring or unstarring anywhere (Images, Favorites, Library media) re-reads the Favorites count (D51), once
|
||||
// after a run of quick toggles settles rather than once per click (#249 M1).
|
||||
let favoriteCountTimer = 0;
|
||||
scope.cleanup(onFavoriteChange(() => {
|
||||
clearTimeout(favoriteCountTimer);
|
||||
favoriteCountTimer = setTimeout(() => refreshCounts(FAVORITE_WRITE_COUNTS), FAVORITE_COUNT_DELAY_MS);
|
||||
}));
|
||||
scope.cleanup(() => clearTimeout(favoriteCountTimer));
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
let viewScope;
|
||||
let firstRender = true;
|
||||
@@ -92,10 +131,75 @@ function mountWorkspace() {
|
||||
images = null;
|
||||
viewScope = createScope();
|
||||
const view = viewScope;
|
||||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||||
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
currentKey = routeKey(route);
|
||||
markCurrent();
|
||||
if (route.id === 'saved') {
|
||||
openSavedView(route, view);
|
||||
return;
|
||||
}
|
||||
renderPage(route, view);
|
||||
}
|
||||
|
||||
/** A saved view opens its page with its state; the heading, breadcrumb and title are the view's name. */
|
||||
async function openSavedView(route, view) {
|
||||
main.replaceChildren();
|
||||
await savedViews.settled();
|
||||
if (view.signal.aborted) return;
|
||||
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
|
||||
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
|
||||
await savedViews.load();
|
||||
if (view.signal.aborted) return;
|
||||
}
|
||||
const saved = savedViews.find(route.savedId);
|
||||
if (saved) {
|
||||
const page = routes.find(item => item.id === saved.page);
|
||||
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
|
||||
mountEditAction(saved, view);
|
||||
return;
|
||||
}
|
||||
if (savedViews.status === 'ready') {
|
||||
// A removed or unknown view: the shell's default page, without a history entry for the dead link.
|
||||
window.location.replace('#/images');
|
||||
return;
|
||||
}
|
||||
const {description} = renderHeading(main, {title:route.label});
|
||||
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
|
||||
description.setAttribute('role', 'alert');
|
||||
const retry = createButton('Retry');
|
||||
view.listen(retry, 'click', () => {
|
||||
savedViews.load();
|
||||
openSavedView(route, view);
|
||||
});
|
||||
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) {
|
||||
// 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,
|
||||
onRename: async name => {
|
||||
const renamed = await savedViews.rename(saved.id, name);
|
||||
if (view.signal.aborted) return;
|
||||
saved = renamed;
|
||||
retitle(renamed.name);
|
||||
},
|
||||
onDelete: async () => {
|
||||
await savedViews.remove(saved.id);
|
||||
window.location.replace(`#/${saved.page}`);
|
||||
} }));
|
||||
}
|
||||
|
||||
function retitle(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`;
|
||||
}
|
||||
|
||||
function renderPage(route, view, saved = 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`.
|
||||
@@ -103,13 +207,14 @@ function mountWorkspace() {
|
||||
if (!firstRender) h1.focus();
|
||||
firstRender = false;
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
if (route.id === 'images') {
|
||||
if (route.id === 'images' || route.id === 'favorites') {
|
||||
mountNewPromptAction(main, api, view);
|
||||
images = mountImages(main, api, view);
|
||||
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
|
||||
onSaveView:saveView('images', view)});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'library') {
|
||||
mountLibrary(main, api, view, {positiveId:route.positiveId});
|
||||
mountLibrary(main, api, view, {positiveId:route.positiveId, savedState:saved?.state, onSaveView:saveView('library', view)});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'tags') {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {countOf} from './plural.mjs';
|
||||
import {addedTerms} from './history-state.mjs';
|
||||
import {pageHeading} from './heading.mjs';
|
||||
import {mountToolbar} from './toolbar.mjs';
|
||||
import {libraryFilters,libraryViewState} from './saved-views.mjs';
|
||||
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
@@ -133,7 +134,8 @@ export function mountNewPromptAction(main,api,parentScope) {
|
||||
return button;
|
||||
}
|
||||
|
||||
export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
/** `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view. */
|
||||
export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,onSaveView=()=>{}}={}) {
|
||||
const scope=createScope();parentScope.cleanup(()=>scope.dispose());
|
||||
main.classList.add('library-main');
|
||||
const sender=createPromptSender({scope});
|
||||
@@ -144,10 +146,11 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
scope.cleanup(()=>main.classList.remove('library-main'));
|
||||
|
||||
function mountShelf() {
|
||||
const visit=shelfVisit, mountedGeneration=writeGeneration;
|
||||
const saved=savedState?libraryFilters(savedState):null;
|
||||
const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration;
|
||||
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
|
||||
const restorable=visit?.state?visit:null;
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved};
|
||||
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
|
||||
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
|
||||
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
|
||||
@@ -155,7 +158,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
// D44: Filters reads "Filters · 1" while a Tags filter (from Refine or a Browse) narrows the shelf.
|
||||
const refinements=()=>filters.tags.length?1:0;
|
||||
const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS,
|
||||
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters});
|
||||
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters,
|
||||
onSaveView:trigger=>{const value=toolbar.value();onSaveView(trigger,libraryViewState({...filters,text:value.q.trim(),category:value.category,sort:value.sort}));}});
|
||||
const optionsStatus=el('p','','library-options-status');optionsStatus.hidden=true;optionsStatus.setAttribute('role','alert');
|
||||
// The concept caption (app.js renderLibrary a586): the filtered set's parents and their negative variants.
|
||||
const caption=el('div',undefined,'section-caption');const captionCount=el('span');caption.append(captionCount,el('span','Images are grouped by positive prompt'));caption.hidden=true;
|
||||
@@ -409,7 +413,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
scope.cleanup(()=>{
|
||||
const savedFilters={...filters,tags:[...filters.tags]},savedAnchor=pendingAnchor || anchor || captureAnchor();
|
||||
// A write while mounted makes this stream stale: keep only what still means something.
|
||||
shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null;
|
||||
// A saved view is not the plain shelf, so it never replaces the Library return state (#249 M2, as Favorites).
|
||||
if(!saved) shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null;
|
||||
toolbarScope.dispose();stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
|
||||
loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Saved views (#249 D16, maintainer ruling 2026-10-21): a named toolbar value plus its "Refine this view" filters, for
|
||||
* Images or the Prompt library, persisted through `/prompt_manager/saved-views`.
|
||||
*
|
||||
* - The toolbar's Save view opens the concept's dialog (app.js a1330–1346 through `simpleInput` a1138): "Save this
|
||||
* view", "View name", `Search: <q>` or "My workspace view", Cancel / Save; an empty name is "Saved view".
|
||||
* - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162).
|
||||
* - A view's state always holds every key of its page (the API requires them all); a value its page no longer
|
||||
* accepts is dropped to that key's default, so an opened view applies what it can.
|
||||
* - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name",
|
||||
* Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { openInputDialog, openContentDialog, closeWorkspaceDialog, field } from './workspace-dialogs.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
export const SAVED_VIEWS_URL = '/prompt_manager/saved-views';
|
||||
export const SAVED_TOAST = 'Saved view added to the sidebar.';
|
||||
export const SAVE_FAILED_TOAST = 'The view could not be saved. Try again.';
|
||||
export const RENAMED_TOAST = 'Saved view renamed.';
|
||||
export const DELETED_TOAST = 'Saved view deleted.';
|
||||
export const RENAME_FAILED_TOAST = 'The view could not be renamed. Try again.';
|
||||
export const DELETE_FAILED_TOAST = 'The view could not be deleted. Try again.';
|
||||
export const TOO_LARGE_TOAST = 'The view is too large to save. Shorten its search, folder or tags and try again.';
|
||||
// The API's cap on a view's encoded state (database/saved_views.py MAX_STATE_BYTES), in UTF-8 bytes of compact JSON.
|
||||
export const MAX_STATE_BYTES = 4096;
|
||||
const MAX_NAME_LENGTH = 100;
|
||||
const MAX_TAGS = 20;
|
||||
const MAX_TAG_LENGTH = 50;
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
// The API's whitelist (database/saved_views.py _STATE_FIELDS), with each key's default.
|
||||
const text = (limit, fallback = '') => ({ fallback, accepts: value => typeof value === 'string' && value.length <= limit && !CONTROL.test(value) });
|
||||
const choice = (choices, fallback) => ({ fallback, accepts: value => choices.includes(value) });
|
||||
const tags = { fallback: [], accepts: value => Array.isArray(value) && value.length <= MAX_TAGS && value.every(tag =>
|
||||
typeof tag === 'string' && tag.trim() && tag.length <= MAX_TAG_LENGTH && !tag.includes(',') && !CONTROL.test(tag)) };
|
||||
const FIELDS = Object.freeze({
|
||||
images: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'),
|
||||
source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) },
|
||||
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
|
||||
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
|
||||
favorites: { fallback: false, accepts: value => typeof value === 'boolean' },
|
||||
}),
|
||||
library: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
|
||||
}),
|
||||
});
|
||||
|
||||
export const SAVED_VIEW_PAGES = Object.freeze(Object.keys(FIELDS));
|
||||
|
||||
/** The page's full state: every key, each saved value its page accepts, the default otherwise. */
|
||||
export function normalizeViewState(page, state) {
|
||||
const fields = FIELDS[page];
|
||||
if (!fields) throw new TypeError(`Unknown saved-view page: ${page}`);
|
||||
const source = state && typeof state === 'object' && !Array.isArray(state) ? state : {};
|
||||
return Object.fromEntries(Object.entries(fields).map(([key, field]) => {
|
||||
const value = field.accepts(source[key]) ? source[key] : field.fallback;
|
||||
return [key, Array.isArray(value) ? [...value] : value];
|
||||
}));
|
||||
}
|
||||
|
||||
/** True when the state's compact JSON fits the API's 4 KB cap (the server's encoding: no spaces, UTF-8). */
|
||||
export function stateFits(state) {
|
||||
return new TextEncoder().encode(JSON.stringify(state)).length <= MAX_STATE_BYTES;
|
||||
}
|
||||
|
||||
/** The error `create()` rejects with when the state is over the cap; nothing is sent. */
|
||||
export class SavedViewTooLarge extends RangeError {
|
||||
constructor() { super('Saved view state is over 4 KB'); this.name = 'SavedViewTooLarge'; }
|
||||
}
|
||||
|
||||
/** The concept's default name (a1336): `Search: <q>` while there is a search, else "My workspace view". */
|
||||
export function defaultViewName(q) {
|
||||
const search = typeof q === 'string' ? q.trim() : '';
|
||||
return (search ? `Search: ${search}` : 'My workspace view').slice(0, MAX_NAME_LENGTH);
|
||||
}
|
||||
|
||||
/** One listed view, or null when the response row is not a usable view. */
|
||||
export function parseSavedView(row) {
|
||||
if (!Number.isSafeInteger(row?.id) || row.id < 1 || !SAVED_VIEW_PAGES.includes(row.page)) return null;
|
||||
if (typeof row.name !== 'string' || !row.name.trim()) return null;
|
||||
return { id: row.id, page: row.page, name: row.name, state: normalizeViewState(row.page, row.state) };
|
||||
}
|
||||
|
||||
/** The listing's views in the server's order, skipping any row that is not a usable view. */
|
||||
export function parseSavedViews(data) {
|
||||
if (!Array.isArray(data?.views)) throw new TypeError('Invalid saved views');
|
||||
return data.views.map(parseSavedView).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The route of one view. */
|
||||
export const savedViewHash = id => `#/saved/${id}`;
|
||||
|
||||
/** Images filters (images.mjs `filters`) from a saved state, and the state of the current filters. */
|
||||
export function imagesFilters(state) {
|
||||
const view = normalizeViewState('images', state);
|
||||
return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media,
|
||||
tags: view.tags, favorites: view.favorites };
|
||||
}
|
||||
export function imagesViewState(filters) {
|
||||
return normalizeViewState('images', { q: filters.q, category: filters.category, sort: filters.sort, source: filters.root,
|
||||
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true });
|
||||
}
|
||||
|
||||
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
|
||||
export function libraryFilters(state) {
|
||||
const view = normalizeViewState('library', state);
|
||||
return { text: view.q, category: view.category, sort: view.sort, tags: view.tags };
|
||||
}
|
||||
export function libraryViewState(filters) {
|
||||
return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags });
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's list of saved views: `load()` re-reads it (a newer read supersedes an older one), `create()` saves one
|
||||
* and re-reads the list, `rename(id, name)` and `remove(id)` change one and re-read it (a failure re-reads it too), `subscribe(fn)` reports `{views, status}` on every change, and `settled()` resolves once no
|
||||
* read is in flight. `status` is 'loading', 'ready' or 'error'; a failed read keeps the last listed views.
|
||||
*/
|
||||
export function createSavedViews(api, signal) {
|
||||
let views = [], status = 'loading', request = null;
|
||||
const listeners = new Set();
|
||||
let waiting = [];
|
||||
const emit = () => {
|
||||
const snapshot = { views, status };
|
||||
for (const listener of listeners) listener(snapshot);
|
||||
if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); }
|
||||
};
|
||||
function load() {
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
request = controller;
|
||||
const abort = () => controller.abort();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
status = 'loading';
|
||||
emit();
|
||||
return api.get(SAVED_VIEWS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => {
|
||||
if (request !== controller) return;
|
||||
views = parseSavedViews(data);
|
||||
status = 'ready';
|
||||
}).catch(() => {
|
||||
if (request !== controller) return;
|
||||
status = 'error';
|
||||
}).finally(() => {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (request !== controller || signal?.aborted) return;
|
||||
request = null;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
async function create(page, name, state) {
|
||||
const full = normalizeViewState(page, state);
|
||||
if (!stateFits(full)) throw new SavedViewTooLarge();
|
||||
const data = await api.post(SAVED_VIEWS_URL, { page, name, state: full }, { signal });
|
||||
const view = parseSavedView(data?.view);
|
||||
if (!view) throw new TypeError('Invalid saved view');
|
||||
await load();
|
||||
return view;
|
||||
}
|
||||
/** Run one change of a listed view, then re-read the list whatever its outcome (another tab may have changed it). */
|
||||
async function change(send) {
|
||||
try { return await send(); } finally { await load(); }
|
||||
}
|
||||
const viewUrl = id => {
|
||||
if (!Number.isSafeInteger(id) || id < 1) throw new TypeError('Invalid saved view id');
|
||||
return `${SAVED_VIEWS_URL}/${id}`;
|
||||
};
|
||||
function rename(id, name) {
|
||||
const url = viewUrl(id);
|
||||
return change(async () => {
|
||||
const view = parseSavedView((await api.patch(url, { name }, { signal }))?.view);
|
||||
if (!view) throw new TypeError('Invalid saved view');
|
||||
return view;
|
||||
});
|
||||
}
|
||||
function remove(id) {
|
||||
const url = viewUrl(id);
|
||||
return change(async () => {
|
||||
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;
|
||||
}
|
||||
return id;
|
||||
});
|
||||
}
|
||||
return {
|
||||
load, create, rename, remove,
|
||||
get views() { return views; },
|
||||
get status() { return status; },
|
||||
find: id => views.find(view => view.id === id) ?? null,
|
||||
settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ views, status }),
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's Save view (a1330–1346): the name dialog, then the save. `onSave(name)` returns the save's promise;
|
||||
* the toast reports either outcome.
|
||||
*/
|
||||
export function openSaveViewDialog({ scope, trigger, q, onSave }) {
|
||||
openInputDialog({ scope, trigger, title: 'Save this view', label: 'View name', action: 'Save', value: defaultViewName(q),
|
||||
maxLength: MAX_NAME_LENGTH, fallback: 'Saved view', onApply: async name => {
|
||||
try {
|
||||
await onSave(name);
|
||||
showWorkspaceToast(SAVED_TOAST);
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(error instanceof SavedViewTooLarge ? TOO_LARGE_TOAST : SAVE_FAILED_TOAST);
|
||||
}
|
||||
} });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
|
||||
const input = document.createElement('input');
|
||||
input.value = view.name;
|
||||
input.maxLength = MAX_NAME_LENGTH;
|
||||
const remove = createButton('Delete…', 'danger');
|
||||
const cancel = createButton('Cancel');
|
||||
const save = createButton('Save', 'ca-primary');
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
actions.append(remove, cancel, save);
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
save.addEventListener('click', async () => {
|
||||
const name = input.value.trim();
|
||||
if (!name) { input.focus(); return; }
|
||||
closeWorkspaceDialog();
|
||||
if (name === view.name) return;
|
||||
try {
|
||||
await onRename(name);
|
||||
showWorkspaceToast(RENAMED_TOAST);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST);
|
||||
}
|
||||
});
|
||||
remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete }));
|
||||
openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field('View name', input, 'action-input'), actions] });
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
function confirmDelete({ scope, trigger, view, onDelete }) {
|
||||
const text = document.createElement('p');
|
||||
text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
const cancel = createButton('Cancel');
|
||||
const confirmButton = createButton('Delete', 'danger');
|
||||
actions.append(cancel, confirmButton);
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
confirmButton.addEventListener('click', async () => {
|
||||
closeWorkspaceDialog();
|
||||
try {
|
||||
await onDelete();
|
||||
showWorkspaceToast(DELETED_TOAST);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(DELETE_FAILED_TOAST);
|
||||
}
|
||||
});
|
||||
openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] });
|
||||
cancel.focus();
|
||||
}
|
||||
@@ -9,11 +9,16 @@ export const routes = [
|
||||
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
|
||||
description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' },
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
// SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it.
|
||||
{ id: 'favorites', label: 'Favorites' },
|
||||
{ id: 'system', label: 'System', href: '/prompt_manager/admin',
|
||||
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
|
||||
];
|
||||
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview']);
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites']);
|
||||
|
||||
/** 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' });
|
||||
|
||||
/** Breadcrumb and document title: the concept names a few pages differently from their nav label. */
|
||||
export function routeTitle(route) {
|
||||
@@ -26,6 +31,8 @@ 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};
|
||||
const match = /^#\/library\/([1-9]\d*)$/.exec(hash);
|
||||
if (match && Number.isSafeInteger(Number(match[1]))) {
|
||||
return {...routes.find(route => route.id === 'library'), positiveId:Number(match[1]), hash};
|
||||
@@ -42,14 +49,16 @@ export function summarizeStats(stats) {
|
||||
});
|
||||
}
|
||||
|
||||
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count.
|
||||
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the
|
||||
// Favorites page's own listing, so its count is what that page lists.
|
||||
const NAV_COUNT_SOURCES = Object.freeze([
|
||||
['images', '/prompt_manager/images/output?mode=workspace&limit=1'],
|
||||
['favorites', '/prompt_manager/images/output?mode=workspace&favorites=1&limit=1'],
|
||||
['library', '/prompt_manager/library?limit=1'],
|
||||
['tags', '/prompt_manager/vocabulary/tags?limit=1'],
|
||||
]);
|
||||
|
||||
/** Route id -> count for the WORKSPACE links; null when a page is missing or its total is not a count. */
|
||||
/** Route id -> count for the sidebar links; null when a page is missing or its total is not a count. */
|
||||
export function summarizeNavCounts(pages = {}) {
|
||||
return new Map(NAV_COUNT_SOURCES.map(([id]) => {
|
||||
const total = pages?.[id]?.total;
|
||||
@@ -58,16 +67,21 @@ export function summarizeNavCounts(pages = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the three counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read
|
||||
* settles, so a slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
|
||||
* Read the counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read settles, so a
|
||||
* slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
|
||||
*/
|
||||
/** The WORKSPACE routes that carry a count. */
|
||||
/** The sidebar routes that carry a count (WORKSPACE, plus SAVED VIEWS' Favorites). */
|
||||
export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id));
|
||||
|
||||
/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */
|
||||
export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
|
||||
|
||||
/** Read the given WORKSPACE counts (all by default), reporting each as its own read settles. */
|
||||
/** The count a confirmed favorite change can change. */
|
||||
export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']);
|
||||
// Trailing delay that coalesces quick star toggles into one Favorites count read (#249 M1).
|
||||
export const FAVORITE_COUNT_DELAY_MS = 300;
|
||||
|
||||
/** Read the given sidebar counts (all by default), reporting each as its own read settles. */
|
||||
export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) {
|
||||
const pages = {};
|
||||
await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => {
|
||||
@@ -96,7 +110,7 @@ export function mountRouter(win, onRoute) {
|
||||
const route = resolveRoute(win.location.hash);
|
||||
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) return;
|
||||
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId) return;
|
||||
current = route;
|
||||
onRoute(route);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The concept's shared page toolbar (docs/concepts/workspace/app.js toolbar(), a347–360), used by Images and the
|
||||
* Prompt library: `search.toolbar` > `label.search-field` (search icon + `input#search`), `select#category-filter`,
|
||||
* `select#sort`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options first and a
|
||||
* page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16); `Filters` reads
|
||||
* "Filters · N" while N "Refine this view" filters are active (D44).
|
||||
* `select#sort`, `Save view`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options
|
||||
* first and a page's extra ones after them (D14); `Filters` reads "Filters · N" while N "Refine this view" filters are
|
||||
* active (D44).
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
@@ -55,16 +55,16 @@ function categoryOptions(categories) {
|
||||
|
||||
/**
|
||||
* Mount the toolbar into `container`. `value` is `{q, category, sort}`; `onChange(value)` receives the whole new
|
||||
* value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view";
|
||||
* `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed.
|
||||
* value after a select change or, debounced, after typing; `onSaveView(trigger)` opens the page's Save view dialog;
|
||||
* `onFilters(trigger)` opens the page's "Refine this view"; `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed.
|
||||
* Returns `{element, value, setValue, setCategories, setActiveFilters}`.
|
||||
*/
|
||||
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters }) {
|
||||
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters, onSaveView }) {
|
||||
if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') {
|
||||
throw new TypeError('The toolbar needs the page scope');
|
||||
}
|
||||
if (typeof onChange !== 'function' || typeof onFilters !== 'function') {
|
||||
throw new TypeError('The toolbar needs onChange and onFilters');
|
||||
if (typeof onChange !== 'function' || typeof onFilters !== 'function' || typeof onSaveView !== 'function') {
|
||||
throw new TypeError('The toolbar needs onChange, onFilters and onSaveView');
|
||||
}
|
||||
const root = document.createElement('search');
|
||||
root.className = 'toolbar';
|
||||
@@ -91,8 +91,9 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
|
||||
sort.setAttribute('aria-label', 'Sort results');
|
||||
sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text)));
|
||||
|
||||
const saveView = createButton('Save view');
|
||||
const filters = createButton(filtersLabel(activeFilters));
|
||||
root.append(label, category, sort, filters);
|
||||
root.append(label, category, sort, saveView, filters);
|
||||
|
||||
const read = () => ({ q: search.value, category: category.value, sort: sort.value });
|
||||
function setValue(next) {
|
||||
@@ -114,6 +115,7 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
|
||||
scope.listen(search, 'input', scope.debounce(report, SEARCH_DELAY));
|
||||
scope.listen(category, 'change', report);
|
||||
scope.listen(sort, 'change', report);
|
||||
scope.listen(saveView, 'click', () => onSaveView(saveView));
|
||||
scope.listen(filters, 'click', () => onFilters(filters));
|
||||
scope.cleanup(() => root.remove());
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…").
|
||||
* - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22).
|
||||
* - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary
|
||||
* action; used by the Images selection bar's "Set category" (#249 D8).
|
||||
* action; used by the Images selection bar's "Set category" (#249 D8) and the toolbar's Save view.
|
||||
* - Content (a1282–1304 `dialog(title, body)`): a page-built body such as the history's "All images for this
|
||||
* positive" and "Repeat run" media (#249 D26); `onClose` runs once when that body leaves the shell.
|
||||
*
|
||||
@@ -32,7 +32,8 @@ function el(tag, text, className) {
|
||||
return node;
|
||||
}
|
||||
|
||||
function field(label, control, id) {
|
||||
/** A labelled `.field` row: `label` names `control`, which takes `id` (shared with the saved-view Edit dialog). */
|
||||
export function field(label, control, id) {
|
||||
const wrapper = el('div', undefined, 'field');
|
||||
const caption = el('label', label);
|
||||
caption.htmlFor = id;
|
||||
@@ -268,15 +269,18 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) {
|
||||
|
||||
/**
|
||||
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
|
||||
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field.
|
||||
* A positive `maxLength` limits the field.
|
||||
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or
|
||||
* reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field.
|
||||
*/
|
||||
export function openInputDialog({ scope, title, label, action, value = '', maxLength, onApply, trigger = null }) {
|
||||
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null }) {
|
||||
requireScope(scope);
|
||||
requireCallbacks({ onApply });
|
||||
for (const [name, text] of Object.entries({ title, label, action })) {
|
||||
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
|
||||
}
|
||||
if (fallback !== undefined && (typeof fallback !== 'string' || !fallback.trim())) {
|
||||
throw new TypeError('The input dialog fallback must be text');
|
||||
}
|
||||
const input = el('input');
|
||||
input.value = typeof value === 'string' ? value : '';
|
||||
if (Number.isSafeInteger(maxLength) && maxLength > 0) input.maxLength = maxLength;
|
||||
@@ -284,7 +288,7 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
|
||||
const apply = createButton(action, 'ca-primary');
|
||||
cancel.addEventListener('click', () => finish());
|
||||
apply.addEventListener('click', () => {
|
||||
const entered = input.value.trim();
|
||||
const entered = input.value.trim() || fallback;
|
||||
if (!entered) { input.focus(); return; }
|
||||
finish();
|
||||
onApply(entered);
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<a class="brand" href="#/images"><span class="brand-mark">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
|
||||
<div class="sidebar-label">WORKSPACE</div>
|
||||
<nav id="primary-nav" aria-label="Workspace"></nav>
|
||||
<!-- 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>
|
||||
<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