fix(library): a reading that never finished no longer says nought

`saveWalkedDirs` filled in a nought beside every folder a reading had listed but
never opened. A reading stopped by a closed tab, and one kept to a branch, list
the rest of the roll before they have been into any of it — 484 folders of one
real roll read empty over tens of thousands of frames — and the record is folded
back into itself, so the noughts stayed.

Only a reading that met the whole roll may say nought now (`WalkedDirs.full`), and
the screen drops a nought from a record that does not say so. An old record has no
word to give, so its rows fall back on the catalogue instead.

feat(library): search Immich from the filter row

One box, one query, one node: the backend reads what is typed as a tag name when
the key can see one and as a file name otherwise, and the answer is filed under the
node itself. A share link searches its one album in place. The box is not a filter —
it leaves the shelf, which is what the md's search permission is for.

feat(ui): a shorter status line, and a wall that keeps its own row height

The status line is one short 12px row at the foot of the library. A wall with fewer
frames than there are columns no longer stretches its one row — and the cards' own
borders with it — down to the stage.
This commit is contained in:
2026-10-10 18:27:49 +07:00
parent ed179fc200
commit 3befdf50eb
8 changed files with 267 additions and 34 deletions
+66 -9
View File
@@ -183,10 +183,12 @@ export async function saveImmichAlbums(server: string, selected: string[]): Prom
async function listAssets(
server: string,
album: string | null,
page: number
page: number,
search = ''
): Promise<{ items: ImmichAsset[]; hasMore: boolean }> {
const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) });
if (album) query.set('album', album);
if (search) query.set('q', search);
const body = await get<{ items?: ImmichAsset[]; hasMore?: boolean }>(`/api/immich/assets?${query.toString()}`);
return { items: body?.items ?? [], hasMore: body?.hasMore === true };
}
@@ -236,12 +238,20 @@ export interface ImmichSyncResult {
// fresh while the others are a week old is a column nobody can read a number off.
export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResult> {
const server = folder.server ?? '';
const albums = await listImmichAlbums(server);
// A search node is one query and no albums: the backend reads it as a name or a
// tag, and what it answers with is filed under the node itself — a label of `''`
// is the node's own wall — so the album list is not even asked for.
const search = folder.kind === 'search' ? (folder.query ?? '').trim() : '';
const albums = search ? [] : await listImmichAlbums(server);
const labels = albumLabels(albums);
const chosen = folder.albums ?? [];
// An empty tick list is a decision and not a missing value: it means every album
// this key can see (see the plan, the album-ID field).
const wanted = albums.filter((a) => chosen.length === 0 || chosen.includes(a.id));
const wanted: { id: string | null; label: string }[] = search
? [{ id: null, label: '' }]
: albums
.filter((a) => chosen.length === 0 || chosen.includes(a.id))
.map((a) => ({ id: a.id, label: labels.get(a.id) ?? a.id.slice(0, 8) }));
const rows: LibraryPhoto[] = [];
const dirs: LibraryDir[] = [];
@@ -249,20 +259,22 @@ export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResul
const root = normPath(folder.name);
for (const album of wanted) {
const label = labels.get(album.id) ?? album.id.slice(0, 8);
const label = album.label;
// The names met in this one album. Two files of one name are two files, and
// the id is the name, so the second of them moves rather than disappears.
const taken = new Set<string>();
let page = 1;
let count = 0;
for (;;) {
const { items, hasMore } = await listAssets(server, album.id, page);
const { items, hasMore } = await listAssets(server, album.id, page, search);
for (const item of items) {
if (!item.id || !item.name) continue;
const name = frameName(item.name, item.id, taken);
taken.add(item.name);
rows.push({
id: `${root}/${label}/${name}`,
// A search node's frames hang off the node itself, so their path is one
// segment shorter than an album's.
id: [root, label, name].filter(Boolean).join('/'),
folder: folder.name,
dir: label,
name,
@@ -277,8 +289,12 @@ export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResul
if (!hasMore || !items.length) break;
page++;
}
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
counts[`${root}/${label}`] = count;
// A search node has no row under it: what it answers with is the node's own
// frames, and the row for them is the node.
if (label) {
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
counts[`${root}/${label}`] = count;
}
}
counts[root] = rows.length;
@@ -286,7 +302,9 @@ export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResul
// A frame the server no longer lists is not a frame to keep drawing: the row,
// its tile and its marks go, exactly as a frame taken off the disk does. This
// read saw the whole node, so it is the one allowed to say it.
const met = new Set(rows.map((r) => `${normPath(r.dir)}/${r.name}`.toLowerCase()));
// Spelled the way `prunePhotos` reads a row back: no leading slash for a frame
// that hangs off the node itself.
const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase()));
await prunePhotos(root, '', met);
await putPhotos(rows);
@@ -331,6 +349,45 @@ export function immichFolderLabel(serverName: string): string {
// name, the server it reads, and the albums it keeps — and `taken` is what it must
// not collide with, because the name is the store's key and two keys cannot be
// one. The name is `Immich`, `Immich 2`, …; the label is what the tree paints.
// The name a search node takes. The query is in it because the name is what the
// node is: a column of `Immich · …` rows would say nothing about which search is
// which, and the name is the store's key, so the same words are already the same
// node. Only a clash with something that is not this query's own node — a disk
// folder someone called the same — moves it on to a number.
export function immichSearchName(q: string, taken: Iterable<string>): string {
const used = new Set(taken);
const base = `Immich · ${q}`;
let name = base;
for (let n = 2; used.has(name); n++) name = `${base} ${n}`;
return name;
}
// Put a search in the column as a node of its own. The same words are the same
// node read again rather than one more row for every time they are typed: the
// frames are keyed by it, and a node per attempt would be a column of dead ends.
export async function searchImmichFolder(
server: ImmichServer,
query: string,
current: LibraryFolder[]
): Promise<LibraryFolder> {
// Bounded the way the backend bounds it, so the name and the query are the same
// words: a row whose name is longer than the query that made it is a row nobody
// can match up again.
const q = query.trim().slice(0, 100);
const found = current.find((f) => f.kind === 'search' && f.server === server.id && f.query === q);
if (found) return found;
const folder: LibraryFolder = {
name: immichSearchName(q, current.map((f) => f.name)),
label: `Immich · ${server.name} · “${q}”`,
source: 'immich',
server: server.id,
kind: 'search',
query: q,
};
await putFolder(folder);
return folder;
}
export async function addImmichFolder(server: ImmichServer, taken: Iterable<string>): Promise<LibraryFolder> {
const folder: LibraryFolder = {
name: immichFolderName(taken),
+43 -17
View File
@@ -95,8 +95,15 @@ export interface LibraryFolder {
// field's empty state — every album the key can see.
albums?: string[];
// 'album' for a node built from album names, 'share' for one built from a share
// link: a share link *is* one album, so it has no picker and no "Người".
kind?: 'album' | 'share';
// link: a share link *is* one album, so it has no picker and no "Người". 'search'
// for one built from the shelf's search box: it has no album rows at all — its
// frames are the answer to one query, filed under the node itself, and `query`
// is what that query was.
kind?: 'album' | 'share' | 'search';
// The words a 'search' node was made from. The node's name carries them too —
// the name is the store's key — but a label the reader may rename is no place to
// keep a query, and the sync asks with this.
query?: string;
}
export interface LibraryPhoto {
@@ -568,12 +575,18 @@ export interface WalkedDirs {
// Folder path in the reading's own spelling, lower-cased on the way in, to the
// number of frames the walk met under it.
counts: Record<string, number>;
// True when the reading that wrote this met the whole of the roll it was
// pointed at. A reading that was stopped, or one kept to a branch, has no word
// on the folders it listed and never opened, and a folder it never opened is
// not a folder it found empty — so only a record that says this one may stand
// a nought beside a name. See `saveWalkedDirs`.
full?: boolean;
}
export async function listWalkedDirs(): Promise<WalkedDirs[]> {
try {
const rows = await ask<WalkedDirs[]>(DIRS, 'readonly', (s) => s.getAll());
return rows.map((r) => ({ folder: r.folder, dirs: r.dirs ?? [], counts: r.counts ?? {} }));
return rows.map((r) => ({ folder: r.folder, dirs: r.dirs ?? [], counts: r.counts ?? {}, full: r.full === true }));
} catch {
return [];
}
@@ -607,8 +620,12 @@ export async function saveWalkedDirs(
};
// The folders this reading was in, before another visit's are folded in.
const walked = dirs.slice();
// Read before the writing, not only when a branch is folded in: the record
// carries on saying whether a reading ever met the whole roll, and the noughts
// of the one that did are not for a branch reading to lose. See
// `WalkedDirs.full`.
const prev = replace ? null : ((await ask<WalkedDirs | undefined>(DIRS, 'readonly', (s) => s.get(folder))) ?? null);
if (!replace) {
const prev = (await ask<WalkedDirs | undefined>(DIRS, 'readonly', (s) => s.get(folder))) ?? null;
const byId = new Map((prev?.dirs ?? []).map((d) => [d.id, d]));
for (const d of dirs) byId.set(d.id, d);
dirs = [...byId.values()];
@@ -654,22 +671,31 @@ export async function saveWalkedDirs(
// nothing under it: left out of the record, the row falls back on the
// catalogue, and a frame taken off the disk keeps its number for good.
const key = (rel: string) => (rel ? `${root}/${normPath(rel)}` : root).toLowerCase();
// A folder the reading could not open is a folder it has no word on, and
// nothing — not even nothing — is what a reading with no word on a folder
// may write beside its name. Written in as a nought it takes the place of
// the number the catalogue holds, and a column drawn off a drive that
// hiccupped once reads noughts over folders full of frames until the reader
// rescans the roll. Left out, the row falls back on the catalogue, which is
// what the last reading that could open it found.
const unsaid = new Set([...(unlisted ?? [])].map((r) => normPath(r).toLowerCase()));
for (const d of walked) {
if (unsaid.has(normPath(d.rel).toLowerCase())) continue;
counts[key(d.rel)] ??= 0;
// Only a reading that met the whole of the roll gets to say nought beside a
// name. A reading kept to one folder lists the folders under it before it
// has been into them, and one cut short lists the rest of the roll and opens
// none of it: written in as noughts they take the place of the numbers the
// catalogue holds, and the record is folded back into itself, so the column
// of a roll full of frames reads empty until the reader walks it again.
// Beyond that a folder the reading could not open is a folder it has no word
// on, and nothing — not even nothing — is what it may write beside its name.
// Left out, the row falls back on the catalogue, which is what the last
// reading that could open it found. See `WalkedDirs.full`.
if (replace) {
const unsaid = new Set([...(unlisted ?? [])].map((r) => normPath(r).toLowerCase()));
for (const d of walked) {
if (unsaid.has(normPath(d.rel).toLowerCase())) continue;
counts[key(d.rel)] ??= 0;
}
counts[root] ??= 0;
}
if (replace) counts[root] ??= 0;
counts = fold(counts);
}
await ask(DIRS, 'readwrite', (s) => s.put({ folder, dirs, counts: counts ?? {} } satisfies WalkedDirs));
await ask(
DIRS,
'readwrite',
(s) => s.put({ folder, dirs, counts: counts ?? {}, full: replace || prev?.full === true } satisfies WalkedDirs)
);
} catch {
// A catalogue that will not keep them is a column drawn from the catalogue, as
// it was before.