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:
@@ -19,6 +19,9 @@ const MAX_KEY = 400;
|
|||||||
const MAX_NAME = 60;
|
const MAX_NAME = 60;
|
||||||
const MAX_ALBUMS = 200;
|
const MAX_ALBUMS = 200;
|
||||||
const MAX_PAGE_SIZE = 200;
|
const MAX_PAGE_SIZE = 200;
|
||||||
|
// What the shelf's search box may carry. Bounded because it goes on to another
|
||||||
|
// box as a query, and a search box is a thing people paste into.
|
||||||
|
const MAX_QUERY = 100;
|
||||||
|
|
||||||
// A server is a box the operator may not control, and a hang there must not
|
// A server is a box the operator may not control, and a hang there must not
|
||||||
// become a hung LIBRARY. Streaming an original is the one call that legitimately
|
// become a hung LIBRARY. Streaming an original is the one call that legitimately
|
||||||
@@ -201,6 +204,18 @@ async function listAlbums(server: ImmichServer): Promise<AlbumRow[] | Failure> {
|
|||||||
return ((await jsonOf<RawAlbum[]>(res)) ?? []).filter((album) => album.id).map((album) => albumRow(album));
|
return ((await jsonOf<RawAlbum[]>(res)) ?? []).filter((album) => album.id).map((album) => albumRow(album));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tags this key can see whose name holds what was typed. Asked for on every
|
||||||
|
// search rather than cached: a tag made on Immich has to be found by the next
|
||||||
|
// one, and the list is small. A key without `tag.read` is answered 403, which is
|
||||||
|
// a key that cannot search by tag — not a search that failed.
|
||||||
|
async function tagIdsMatching(server: ImmichServer, q: string): Promise<string[]> {
|
||||||
|
const res = await call(server, { path: '/tags' });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const tags = (await jsonOf<{ id?: string; name?: string }[]>(res)) ?? [];
|
||||||
|
const needle = q.toLowerCase();
|
||||||
|
return tags.filter((tag) => tag.id && (tag.name ?? '').toLowerCase().includes(needle)).map((tag) => tag.id as string);
|
||||||
|
}
|
||||||
|
|
||||||
async function firstAssetId(server: ImmichServer): Promise<string | null> {
|
async function firstAssetId(server: ImmichServer): Promise<string | null> {
|
||||||
if (server.type === 'share') {
|
if (server.type === 'share') {
|
||||||
const res = await call(server, { path: '/shared-links/me' });
|
const res = await call(server, { path: '/shared-links/me' });
|
||||||
@@ -389,12 +404,19 @@ export function immichRoutes(
|
|||||||
const size = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(Number(q.size ?? 100)) || 100));
|
const size = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(Number(q.size ?? 100)) || 100));
|
||||||
const album = q.album && q.album !== 'all' ? q.album : null;
|
const album = q.album && q.album !== 'all' ? q.album : null;
|
||||||
if (album && !UUID_RE.test(album)) return reply.status(400).send({ error: 'invalid album id' });
|
if (album && !UUID_RE.test(album)) return reply.status(400).send({ error: 'invalid album id' });
|
||||||
|
// What the shelf's search box carries: words to look for in a file's own name,
|
||||||
|
// or the name of a tag. A share link has no search of its own, so its one
|
||||||
|
// album is sifted here instead.
|
||||||
|
const search = (q.q ?? '').trim().slice(0, MAX_QUERY);
|
||||||
|
|
||||||
if (server.type === 'share') {
|
if (server.type === 'share') {
|
||||||
const res = await call(server, { path: '/shared-links/me' });
|
const res = await call(server, { path: '/shared-links/me' });
|
||||||
if (!res.ok) return reply.status(502).send(refusal(res));
|
if (!res.ok) return reply.status(502).send(refusal(res));
|
||||||
const me = await jsonOf<{ assets?: RawAsset[] }>(res);
|
const me = await jsonOf<{ assets?: RawAsset[] }>(res);
|
||||||
const all = (me?.assets ?? []).filter((a) => a.type === 'IMAGE' || a.type === undefined);
|
const needle = search.toLowerCase();
|
||||||
|
const all = (me?.assets ?? [])
|
||||||
|
.filter((a) => a.type === 'IMAGE' || a.type === undefined)
|
||||||
|
.filter((a) => !needle || (a.originalFileName ?? '').toLowerCase().includes(needle));
|
||||||
const start = (page - 1) * size;
|
const start = (page - 1) * size;
|
||||||
return reply.send({
|
return reply.send({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -406,10 +428,24 @@ export function immichRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One query, one answer. Immich reads every field it is handed as one more
|
||||||
|
// thing an asset must be — `originalFileName` with `tagIds` is a file called
|
||||||
|
// this *and* tagged that — so the box is read as a tag when it names one, and
|
||||||
|
// as a file name otherwise, which keeps the page a page of one list.
|
||||||
|
// ponytail: a query that names a tag finds only that tag's frames; fold the
|
||||||
|
// two answers into one list if a reader ever needs both at once.
|
||||||
|
const tagIds = search ? await tagIdsMatching(server, search) : [];
|
||||||
const res = await call(server, {
|
const res = await call(server, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/search/metadata',
|
path: '/search/metadata',
|
||||||
json: { size, page, withExif: true, type: 'IMAGE', ...(album ? { albumIds: [album] } : {}) },
|
json: {
|
||||||
|
size,
|
||||||
|
page,
|
||||||
|
withExif: true,
|
||||||
|
type: 'IMAGE',
|
||||||
|
...(album ? { albumIds: [album] } : {}),
|
||||||
|
...(search ? (tagIds.length ? { tagIds } : { originalFileName: search }) : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) return reply.status(502).send(refusal(res));
|
if (!res.ok) return reply.status(502).send(refusal(res));
|
||||||
const found = await jsonOf<{ assets?: { items?: RawAsset[]; total?: number } }>(res);
|
const found = await jsonOf<{ assets?: { items?: RawAsset[]; total?: number } }>(res);
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ const KEY_NODL = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbb1234'; // the same key without ass
|
|||||||
const SHARE_KEY = 'share-key-1234567890';
|
const SHARE_KEY = 'share-key-1234567890';
|
||||||
const ALBUM_A = '11111111-1111-4111-8111-111111111111';
|
const ALBUM_A = '11111111-1111-4111-8111-111111111111';
|
||||||
const ALBUM_B = '22222222-2222-4222-8222-222222222222';
|
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 assetId = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||||
const THUMB = Buffer.from('RIFF0000WEBPfake-tile-bytes');
|
const THUMB = Buffer.from('RIFF0000WEBPfake-tile-bytes');
|
||||||
const ORIGINAL = Buffer.from('II*\0fake-original-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) => ({
|
const asset = (n, album) => ({
|
||||||
id: assetId(n),
|
id: assetId(n),
|
||||||
type: 'IMAGE',
|
type: 'IMAGE',
|
||||||
@@ -39,6 +43,7 @@ const asset = (n, album) => ({
|
|||||||
height: 3000,
|
height: 3000,
|
||||||
exifInfo: { fileSizeInByte: 1000 * n },
|
exifInfo: { fileSizeInByte: 1000 * n },
|
||||||
album,
|
album,
|
||||||
|
tags: n === 1 ? TAG_HOME : n === 2 ? TAG_TRIP : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let pass = 0;
|
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') {
|
if (url.pathname === '/api/search/metadata') {
|
||||||
const all = [asset(1, ALBUM_A), asset(2, ALBUM_A), asset(3, ALBUM_B)];
|
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 size = Number(body.size ?? 100);
|
||||||
const page = Number(body.page ?? 1);
|
const page = Number(body.page ?? 1);
|
||||||
const items = filtered.slice((page - 1) * size, page * size);
|
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`);
|
const badAlbum = await user.req(`/immich/assets?server=${serverId}&album=nope`);
|
||||||
check('a malformed album id is refused', badAlbum.status === 400, `got ${badAlbum.status}`);
|
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 ---------------------------------------------------------
|
// ---- the pixels ---------------------------------------------------------
|
||||||
const tile = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`);
|
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}`);
|
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));
|
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)}`);
|
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));
|
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 ------------------------------------------
|
// ---- a share link is one album ------------------------------------------
|
||||||
const shareProbe = await user.post('/immich/probe', { url, key: SHARE_KEY, type: 'share' });
|
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));
|
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`);
|
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));
|
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)}`);
|
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 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)));
|
check('a share request travels with ?key=', seen.at(-1)?.shareKey === SHARE_KEY, JSON.stringify(seen.at(-1)));
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
forgetImmichServer,
|
forgetImmichServer,
|
||||||
immichConfig,
|
immichConfig,
|
||||||
saveImmichAlbums,
|
saveImmichAlbums,
|
||||||
|
searchImmichFolder,
|
||||||
syncImmich,
|
syncImmich,
|
||||||
type ImmichServer,
|
type ImmichServer,
|
||||||
} from './engine/immich';
|
} from './engine/immich';
|
||||||
@@ -426,6 +427,11 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
const [year, setYear] = useState(() => recall(YEAR_KEY) || 'all');
|
const [year, setYear] = useState(() => recall(YEAR_KEY) || 'all');
|
||||||
const [from, setFrom] = useState(() => recall(FROM_KEY) || 'any');
|
const [from, setFrom] = useState(() => recall(FROM_KEY) || 'any');
|
||||||
const [to, setTo] = useState(() => recall(TO_KEY) || 'any');
|
const [to, setTo] = useState(() => recall(TO_KEY) || 'any');
|
||||||
|
// What the shelf's search box holds while it is being typed in. It is not a
|
||||||
|
// filter: a filter looks through the frames already here, and the search asks
|
||||||
|
// Immich for frames that are not here at all. Nothing is kept between visits —
|
||||||
|
// the search node it makes outlives the box.
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
useEffect(() => { keep(SORT_BY_KEY, sortBy); }, [sortBy]);
|
useEffect(() => { keep(SORT_BY_KEY, sortBy); }, [sortBy]);
|
||||||
useEffect(() => { keep(SORT_DIR_KEY, sortDir); }, [sortDir]);
|
useEffect(() => { keep(SORT_DIR_KEY, sortDir); }, [sortDir]);
|
||||||
@@ -498,7 +504,15 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
// spellings of every key.
|
// spellings of every key.
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const row of walkedRows) {
|
for (const row of walkedRows) {
|
||||||
for (const [key, n] of Object.entries(row.counts)) counts[key.toLowerCase()] = n;
|
for (const [key, n] of Object.entries(row.counts)) {
|
||||||
|
// A nought from a reading that did not meet the whole roll is a folder it
|
||||||
|
// listed and never opened, not a folder it found empty, and taken in it
|
||||||
|
// draws an empty row over a roll full of frames. Only a reading that says
|
||||||
|
// it walked the whole of it gets to say nought; the rest of the rows fall
|
||||||
|
// back on the catalogue. See `WalkedDirs.full`.
|
||||||
|
if (n === 0 && row.full !== true) continue;
|
||||||
|
counts[key.toLowerCase()] = n;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setWalked(counts);
|
setWalked(counts);
|
||||||
// Folded into whatever the screen already has rather than put in its place: a
|
// Folded into whatever the screen already has rather than put in its place: a
|
||||||
@@ -1204,6 +1218,32 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
[editAlbums, syncServer]
|
[editAlbums, syncServer]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The shelf's search box, run. What is typed is a name or a tag, and Immich is
|
||||||
|
// the one that reads it: a disk's folders are already on the column, and a roll
|
||||||
|
// is read by walking it rather than by calling it names. The server asked is the
|
||||||
|
// one the open node belongs to, or the first the account saved. The answer
|
||||||
|
// becomes a node of its own, because a search is a shelf the reader may come
|
||||||
|
// back to — `engine/immich.ts` files the frames under the node.
|
||||||
|
const findImmich = useCallback(
|
||||||
|
async (query: string) => {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
const open = folders.find((f) => f.name === node)?.server;
|
||||||
|
const server = servers.find((s) => s.id === open) ?? servers[0] ?? null;
|
||||||
|
if (!server) {
|
||||||
|
setNote(t('lib.search.noServer'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = await listFolders();
|
||||||
|
const folder = await searchImmichFolder(server, q, current);
|
||||||
|
setFolders([...current.filter((f) => f.name !== folder.name), folder].sort(bySourceThenName));
|
||||||
|
setReady((prev) => new Set(prev).add(folder.name));
|
||||||
|
setNode(folder.name);
|
||||||
|
await syncServer(folder);
|
||||||
|
},
|
||||||
|
[folders, node, servers, syncServer, t]
|
||||||
|
);
|
||||||
|
|
||||||
// Ngắt kết nối: the key goes and the node goes with it — the address and the
|
// Ngắt kết nối: the key goes and the node goes with it — the address and the
|
||||||
// key are the account's, and a node with neither to read by is a row that could
|
// key are the account's, and a node with neither to read by is a row that could
|
||||||
// only fail. "Xoá khỏi thư viện" (`drop`) is the other half: the row goes and
|
// only fail. "Xoá khỏi thư viện" (`drop`) is the other half: the row goes and
|
||||||
@@ -2573,7 +2613,29 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* 6. Count */}
|
{/* 6. Search: a name or a tag. The one control here that
|
||||||
|
leaves the shelf — the four before it look through the
|
||||||
|
frames already in hand, this one asks Immich for frames
|
||||||
|
that are not. Enter, not every keystroke: a node per
|
||||||
|
prefix of a word would be a column of dead ends. */}
|
||||||
|
<input
|
||||||
|
className="adm-input"
|
||||||
|
data-key="lib-search"
|
||||||
|
type="search"
|
||||||
|
aria-label={t('lib.search.label')}
|
||||||
|
placeholder={t('lib.search.placeholder')}
|
||||||
|
title={t('lib.search.hint')}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
e.preventDefault();
|
||||||
|
void findImmich(search);
|
||||||
|
}}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 7. Count */}
|
||||||
<span className="hint" data-key="lib-listed" style={{ flexShrink: 0, marginLeft: 'auto' }}>
|
<span className="hint" data-key="lib-listed" style={{ flexShrink: 0, marginLeft: 'auto' }}>
|
||||||
{t('lib.count', { n: listed.length })}
|
{t('lib.count', { n: listed.length })}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -183,10 +183,12 @@ export async function saveImmichAlbums(server: string, selected: string[]): Prom
|
|||||||
async function listAssets(
|
async function listAssets(
|
||||||
server: string,
|
server: string,
|
||||||
album: string | null,
|
album: string | null,
|
||||||
page: number
|
page: number,
|
||||||
|
search = ''
|
||||||
): Promise<{ items: ImmichAsset[]; hasMore: boolean }> {
|
): Promise<{ items: ImmichAsset[]; hasMore: boolean }> {
|
||||||
const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) });
|
const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) });
|
||||||
if (album) query.set('album', album);
|
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()}`);
|
const body = await get<{ items?: ImmichAsset[]; hasMore?: boolean }>(`/api/immich/assets?${query.toString()}`);
|
||||||
return { items: body?.items ?? [], hasMore: body?.hasMore === true };
|
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.
|
// 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> {
|
export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResult> {
|
||||||
const server = folder.server ?? '';
|
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 labels = albumLabels(albums);
|
||||||
const chosen = folder.albums ?? [];
|
const chosen = folder.albums ?? [];
|
||||||
// An empty tick list is a decision and not a missing value: it means every album
|
// 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).
|
// 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 rows: LibraryPhoto[] = [];
|
||||||
const dirs: LibraryDir[] = [];
|
const dirs: LibraryDir[] = [];
|
||||||
@@ -249,20 +259,22 @@ export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResul
|
|||||||
const root = normPath(folder.name);
|
const root = normPath(folder.name);
|
||||||
|
|
||||||
for (const album of wanted) {
|
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 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.
|
// the id is the name, so the second of them moves rather than disappears.
|
||||||
const taken = new Set<string>();
|
const taken = new Set<string>();
|
||||||
let page = 1;
|
let page = 1;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (;;) {
|
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) {
|
for (const item of items) {
|
||||||
if (!item.id || !item.name) continue;
|
if (!item.id || !item.name) continue;
|
||||||
const name = frameName(item.name, item.id, taken);
|
const name = frameName(item.name, item.id, taken);
|
||||||
taken.add(item.name);
|
taken.add(item.name);
|
||||||
rows.push({
|
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,
|
folder: folder.name,
|
||||||
dir: label,
|
dir: label,
|
||||||
name,
|
name,
|
||||||
@@ -277,16 +289,22 @@ export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResul
|
|||||||
if (!hasMore || !items.length) break;
|
if (!hasMore || !items.length) break;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
// 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 });
|
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
|
||||||
counts[`${root}/${label}`] = count;
|
counts[`${root}/${label}`] = count;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
counts[root] = rows.length;
|
counts[root] = rows.length;
|
||||||
|
|
||||||
await saveWalkedDirs(root, dirs, counts, true);
|
await saveWalkedDirs(root, dirs, counts, true);
|
||||||
// A frame the server no longer lists is not a frame to keep drawing: the row,
|
// 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
|
// 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.
|
// 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 prunePhotos(root, '', met);
|
||||||
await putPhotos(rows);
|
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
|
// 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
|
// 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.
|
// 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> {
|
export async function addImmichFolder(server: ImmichServer, taken: Iterable<string>): Promise<LibraryFolder> {
|
||||||
const folder: LibraryFolder = {
|
const folder: LibraryFolder = {
|
||||||
name: immichFolderName(taken),
|
name: immichFolderName(taken),
|
||||||
|
|||||||
@@ -95,8 +95,15 @@ export interface LibraryFolder {
|
|||||||
// field's empty state — every album the key can see.
|
// field's empty state — every album the key can see.
|
||||||
albums?: string[];
|
albums?: string[];
|
||||||
// 'album' for a node built from album names, 'share' for one built from a share
|
// '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".
|
// link: a share link *is* one album, so it has no picker and no "Người". 'search'
|
||||||
kind?: 'album' | 'share';
|
// 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 {
|
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
|
// Folder path in the reading's own spelling, lower-cased on the way in, to the
|
||||||
// number of frames the walk met under it.
|
// number of frames the walk met under it.
|
||||||
counts: Record<string, number>;
|
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[]> {
|
export async function listWalkedDirs(): Promise<WalkedDirs[]> {
|
||||||
try {
|
try {
|
||||||
const rows = await ask<WalkedDirs[]>(DIRS, 'readonly', (s) => s.getAll());
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -607,8 +620,12 @@ export async function saveWalkedDirs(
|
|||||||
};
|
};
|
||||||
// The folders this reading was in, before another visit's are folded in.
|
// The folders this reading was in, before another visit's are folded in.
|
||||||
const walked = dirs.slice();
|
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) {
|
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]));
|
const byId = new Map((prev?.dirs ?? []).map((d) => [d.id, d]));
|
||||||
for (const d of dirs) byId.set(d.id, d);
|
for (const d of dirs) byId.set(d.id, d);
|
||||||
dirs = [...byId.values()];
|
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
|
// 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.
|
// catalogue, and a frame taken off the disk keeps its number for good.
|
||||||
const key = (rel: string) => (rel ? `${root}/${normPath(rel)}` : root).toLowerCase();
|
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
|
// Only a reading that met the whole of the roll gets to say nought beside a
|
||||||
// nothing — not even nothing — is what a reading with no word on a folder
|
// name. A reading kept to one folder lists the folders under it before it
|
||||||
// may write beside its name. Written in as a nought it takes the place of
|
// has been into them, and one cut short lists the rest of the roll and opens
|
||||||
// the number the catalogue holds, and a column drawn off a drive that
|
// none of it: written in as noughts they take the place of the numbers the
|
||||||
// hiccupped once reads noughts over folders full of frames until the reader
|
// catalogue holds, and the record is folded back into itself, so the column
|
||||||
// rescans the roll. Left out, the row falls back on the catalogue, which is
|
// of a roll full of frames reads empty until the reader walks it again.
|
||||||
// what the last reading that could open it found.
|
// 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()));
|
const unsaid = new Set([...(unlisted ?? [])].map((r) => normPath(r).toLowerCase()));
|
||||||
for (const d of walked) {
|
for (const d of walked) {
|
||||||
if (unsaid.has(normPath(d.rel).toLowerCase())) continue;
|
if (unsaid.has(normPath(d.rel).toLowerCase())) continue;
|
||||||
counts[key(d.rel)] ??= 0;
|
counts[key(d.rel)] ??= 0;
|
||||||
}
|
}
|
||||||
if (replace) counts[root] ??= 0;
|
counts[root] ??= 0;
|
||||||
|
}
|
||||||
counts = fold(counts);
|
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 {
|
} catch {
|
||||||
// A catalogue that will not keep them is a column drawn from the catalogue, as
|
// A catalogue that will not keep them is a column drawn from the catalogue, as
|
||||||
// it was before.
|
// it was before.
|
||||||
|
|||||||
@@ -293,6 +293,10 @@ export const en: Dict = {
|
|||||||
'lib.rating': 'RATING',
|
'lib.rating': 'RATING',
|
||||||
'lib.ratingAll': 'Any rating',
|
'lib.ratingAll': 'Any rating',
|
||||||
'lib.ratingAtLeast': '{n}★ and up',
|
'lib.ratingAtLeast': '{n}★ and up',
|
||||||
|
'lib.search.label': 'Search Immich',
|
||||||
|
'lib.search.placeholder': 'Name or tag…',
|
||||||
|
'lib.search.hint': 'Immich: type a file name or a tag, then Enter — the answer becomes a node of its own in the tree.',
|
||||||
|
'lib.search.noServer': 'No Immich server yet — add one first.',
|
||||||
'lib.starHint': 'Give this frame {n} stars',
|
'lib.starHint': 'Give this frame {n} stars',
|
||||||
'lib.rotateLeft': 'Turn this frame left',
|
'lib.rotateLeft': 'Turn this frame left',
|
||||||
'lib.rotateRight': 'Turn this frame right',
|
'lib.rotateRight': 'Turn this frame right',
|
||||||
|
|||||||
@@ -305,6 +305,10 @@ export const vi = {
|
|||||||
'lib.rating': 'XẾP HẠNG',
|
'lib.rating': 'XẾP HẠNG',
|
||||||
'lib.ratingAll': 'Mọi mức sao',
|
'lib.ratingAll': 'Mọi mức sao',
|
||||||
'lib.ratingAtLeast': 'Từ {n} sao',
|
'lib.ratingAtLeast': 'Từ {n} sao',
|
||||||
|
'lib.search.label': 'Tìm trên Immich',
|
||||||
|
'lib.search.placeholder': 'Tên ảnh hoặc tag…',
|
||||||
|
'lib.search.hint': 'Immich: gõ tên ảnh hoặc tên tag rồi Enter — kết quả thành một mục riêng trong cây.',
|
||||||
|
'lib.search.noServer': 'Chưa có máy chủ Immich nào — thêm một cái trước.',
|
||||||
'lib.starHint': 'Cho ảnh này {n} sao',
|
'lib.starHint': 'Cho ảnh này {n} sao',
|
||||||
'lib.rotateLeft': 'Xoay ảnh sang trái',
|
'lib.rotateLeft': 'Xoay ảnh sang trái',
|
||||||
'lib.rotateRight': 'Xoay ảnh sang phải',
|
'lib.rotateRight': 'Xoay ảnh sang phải',
|
||||||
|
|||||||
@@ -1291,8 +1291,9 @@ form[data-key='immich-modal'] {
|
|||||||
/* The one line of word a reading has: what it scanned, what it repaired, what it
|
/* The one line of word a reading has: what it scanned, what it repaired, what it
|
||||||
is doing now. It sits at the foot of the column and is always there, so the
|
is doing now. It sits at the foot of the column and is always there, so the
|
||||||
frame above it does not move when a note comes and goes — hence the fixed
|
frame above it does not move when a note comes and goes — hence the fixed
|
||||||
height, and `:empty` hiding the ink rather than the row. */
|
height, and `:empty` hiding the ink rather than the row. Kept to one short line
|
||||||
.lib-root .lib-status { flex: 0 0 auto; min-height: 18px; margin: 6px 0 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
at 12px: it is news, not content, and the wall above it is the point. */
|
||||||
|
.lib-root .lib-status { flex: 0 0 auto; min-height: 14px; margin: 2px 0 6px; font-size: 12px; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.lib-root .lib-status:empty { visibility: hidden; }
|
.lib-root .lib-status:empty { visibility: hidden; }
|
||||||
/* The column: title and the folder's own chips stay put, the split takes what
|
/* The column: title and the folder's own chips stay put, the split takes what
|
||||||
is left, the strip keeps its natural height on the bottom. */
|
is left, the strip keeps its natural height on the bottom. */
|
||||||
@@ -1371,7 +1372,11 @@ form[data-key='immich-modal'] {
|
|||||||
}
|
}
|
||||||
/* All thumbs: the wall scrolls down inside the stage, and its own scrollbar on
|
/* All thumbs: the wall scrolls down inside the stage, and its own scrollbar on
|
||||||
the right edge is what reaches the frames below. */
|
the right edge is what reaches the frames below. */
|
||||||
.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; overflow-anchor: none; }
|
/* `align-content: start`, because the grid is stretched to the stage: a wall with
|
||||||
|
fewer frames than there are columns is one row, and a stretched row makes every
|
||||||
|
card's border that much taller than the picture inside it. Rows keep the height
|
||||||
|
their own frames give them however few of them there are. */
|
||||||
|
.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; overflow-anchor: none; align-content: start; }
|
||||||
/* What the thumbnail wall is read through: the four selects and the count of what
|
/* What the thumbnail wall is read through: the four selects and the count of what
|
||||||
is left. One row above the wall, and it does not scroll with it. */
|
is left. One row above the wall, and it does not scroll with it. */
|
||||||
.lib-root .adm-stage > .lib-filters,
|
.lib-root .adm-stage > .lib-filters,
|
||||||
|
|||||||
Reference in New Issue
Block a user