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
+40 -1
View File
@@ -26,10 +26,14 @@ const KEY_NODL = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbb1234'; // the same key without ass
const SHARE_KEY = 'share-key-1234567890';
const ALBUM_A = '11111111-1111-4111-8111-111111111111';
const ALBUM_B = '22222222-2222-4222-8222-222222222222';
const TAG_HOME = '33333333-3333-4333-8333-333333333333';
const TAG_TRIP = '44444444-4444-4444-8444-444444444444';
const assetId = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
const THUMB = Buffer.from('RIFF0000WEBPfake-tile-bytes');
const ORIGINAL = Buffer.from('II*\0fake-original-bytes');
// The first frame is the one tagged `Nhà`, so a search for that word has exactly
// one answer to find.
const asset = (n, album) => ({
id: assetId(n),
type: 'IMAGE',
@@ -39,6 +43,7 @@ const asset = (n, album) => ({
height: 3000,
exifInfo: { fileSizeInByte: 1000 * n },
album,
tags: n === 1 ? TAG_HOME : n === 2 ? TAG_TRIP : null,
});
let pass = 0;
@@ -103,9 +108,27 @@ function startFake() {
]);
}
// A tag list, and the one key that may not read it: searching by tag is the
// one search a narrow key loses, and it has to lose it as an empty list
// rather than as a failed search.
if (url.pathname === '/api/tags') {
if (!authed && !shareAuthed) return send(401, { message: 'Authentication required' });
if (apiKey === KEY_NODL) return send(403, { message: 'Missing required permission: tag.read' });
return send(200, [
{ id: TAG_HOME, name: 'Nhà', value: 'nhà' },
{ id: TAG_TRIP, name: 'du lịch', value: 'du lịch' },
]);
}
if (url.pathname === '/api/search/metadata') {
const all = [asset(1, ALBUM_A), asset(2, ALBUM_A), asset(3, ALBUM_B)];
const filtered = Array.isArray(body.albumIds) ? all.filter((a) => body.albumIds.includes(a.album)) : all;
let filtered = Array.isArray(body.albumIds) ? all.filter((a) => body.albumIds.includes(a.album)) : all;
// The same reading the proxy makes of a search box: every field given is
// one more thing an asset must be.
if (Array.isArray(body.tagIds)) filtered = filtered.filter((a) => body.tagIds.includes(a.tags));
if (typeof body.originalFileName === 'string') {
filtered = filtered.filter((a) => a.originalFileName.includes(body.originalFileName));
}
const size = Number(body.size ?? 100);
const page = Number(body.page ?? 1);
const items = filtered.slice((page - 1) * size, page * size);
@@ -301,6 +324,16 @@ try {
const badAlbum = await user.req(`/immich/assets?server=${serverId}&album=nope`);
check('a malformed album id is refused', badAlbum.status === 400, `got ${badAlbum.status}`);
// ---- the search box ------------------------------------------------------
const byName = await user.req(`/immich/assets?server=${serverId}&q=photo-2`);
check('a search reads the words as a file name', byName.body?.items?.length === 1 && byName.body?.items?.[0]?.name === 'photo-2.jpg', JSON.stringify(byName.body?.items));
const byTag = await user.req(`/immich/assets?server=${serverId}&q=${encodeURIComponent('Nhà')}`);
check('a search that names a tag finds that tag', byTag.body?.items?.length === 1 && byTag.body?.items?.[0]?.name === 'photo-1.jpg', JSON.stringify(byTag.body?.items));
check('the tag ids it found travel as tag ids', seen.at(-1)?.path === '/api/search/metadata', JSON.stringify(seen.at(-1)));
const noAnswer = await user.req(`/immich/assets?server=${serverId}&q=nothing-at-all`);
check('a search that answers nothing is an empty page, not a failure', noAnswer.status === 200 && noAnswer.body?.items?.length === 0 && noAnswer.body?.hasMore === false, JSON.stringify(noAnswer.body));
// ---- the pixels ---------------------------------------------------------
const tile = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`);
check('a tile streams through the proxy', tile.status === 200 && tile.body.equals(THUMB), `got ${tile.status}`);
@@ -329,6 +362,10 @@ try {
check('a key without download is still accepted', noDownload.status === 200 && noDownload.body?.server?.canDownload === false, JSON.stringify(noDownload.body));
const refused = await user.req(`/immich/original?server=${readerId}&id=${assetId(1)}`);
check('its original is refused with the reason', refused.status === 502 && refused.body?.error === 'auth', JSON.stringify(refused.body));
// The same narrow key searching by a tag name it cannot read: the 403 on the
// tag list is an empty list of tags, not a search that came back broken.
const narrowSearch = await user.req(`/immich/assets?server=${readerId}&q=${encodeURIComponent('Nhà')}`);
check('a key without tag.read still searches by file name', narrowSearch.status === 200 && narrowSearch.body?.items?.length === 0, JSON.stringify(narrowSearch.body));
// ---- a share link is one album ------------------------------------------
const shareProbe = await user.post('/immich/probe', { url, key: SHARE_KEY, type: 'share' });
@@ -339,6 +376,8 @@ try {
check('a share server is stored', shareSaved.status === 200 && typeof shareId === 'string', JSON.stringify(shareSaved.body));
const shareAssets = await user.req(`/immich/assets?server=${shareId}&page=1&size=10`);
check('a share key lists its album whole', shareAssets.body?.items?.length === 2, JSON.stringify(shareAssets.body));
const shareSearch = await user.req(`/immich/assets?server=${shareId}&q=photo-5`);
check('a share link searches its one album', shareSearch.body?.items?.length === 1 && shareSearch.body?.items?.[0]?.name === 'photo-5.jpg', JSON.stringify(shareSearch.body?.items));
const shareTile = await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`);
check('a share tile streams too', shareTile.status === 200 && shareTile.body.equals(THUMB), `got ${shareTile.status}`);
check('a share request travels with ?key=', seen.at(-1)?.shareKey === SHARE_KEY, JSON.stringify(seen.at(-1)));