diff --git a/docker/frontend/scripts/immich-check.mjs b/docker/frontend/scripts/immich-check.mjs index 3ed0a83..8de0460 100644 --- a/docker/frontend/scripts/immich-check.mjs +++ b/docker/frontend/scripts/immich-check.mjs @@ -142,21 +142,29 @@ await context.route('**/api/**', (route) => { return send(200, { ok: true, servers: saved.map(view) }); } const b = body(); - if (!b.url || !b.key) return send(400, { ok: false, error: 'invalid' }); - const box = HOSTS[b.url]; + // An edit names the node and says only what changes — a new key, or nothing + // at all — so what it does not repeat is what the node already has. + const at = b.id ? saved.findIndex((s) => s.id === b.id) : -1; + if (b.id && at < 0) return send(404, { ok: false, error: 'invalid' }); + const had = at < 0 ? null : saved[at]; + const url0 = b.url ?? had?.url; + const key0 = b.key ?? (had ? HOSTS[had.url]?.key : undefined); + if (!url0 || !key0) return send(400, { ok: false, error: 'invalid' }); + const box = HOSTS[url0]; if (!box) return send(200, { ok: false, error: 'unreachable' }); - if (box.key !== b.key) return send(200, { ok: false, error: 'auth' }); + if (box.key !== key0) return send(200, { ok: false, error: 'auth' }); const server = { - id: `s${++minted}`, - name: String(b.name ?? '').trim() || new URL(b.url).hostname, - url: b.url, - type: b.type === 'share' ? 'share' : 'api', + id: had?.id ?? `s${++minted}`, + name: String(b.name ?? had?.name ?? '').trim() || new URL(url0).hostname, + url: url0, + type: (b.type ?? had?.type) === 'share' ? 'share' : 'api', version: box.version, canDownload: box.canDownload, - albums: cleanAlbums(b.albums), - keyMasked: `••••${String(b.key).slice(-4)}`, + albums: had && b.albums === undefined ? had.albums : cleanAlbums(b.albums), + keyMasked: `••••${String(key0).slice(-4)}`, }; - saved.push(server); + if (had) saved[at] = server; + else saved.push(server); return send(200, { ok: true, server: view(server) }); } @@ -299,15 +307,31 @@ await page.waitForSelector('[data-key="immich-status"]'); const saidOk = await page.$eval('[data-key="immich-status"]', (e) => !e.className.includes('err')); step('a key that answers is said to be good', saidOk); step( - 'and what it offers is that server’s albums and no other server’s', - (await page.$('[data-key="immich-album-' + A1 + '"]')) !== null && - (await page.$('[data-key="immich-album-' + A2 + '"]')) !== null && - (await page.$('[data-key="immich-album-' + B1 + '"]')) === null + 'and the door asks for nothing else: a fresh server is every album its key can see', + (await page.$('[data-key="immich-albums"]')) === null && + (await page.$('[data-key="immich-album-' + A1 + '"]')) === null +); +// The ID field is the only place an album can be named at the door, and it says +// what it made of the lines: one with no id in it, and one this key never named. +await page.fill('[data-key="immich-ids"]', `khong-phai-uuid\n${B1}`); +await page.waitForSelector('[data-key="immich-bad-ids"]', { timeout: 5_000 }).catch(() => {}); +step( + 'a line with no album id in it is a line the field says it dropped', + (await page.$('[data-key="immich-bad-ids"]')) !== null && + (await page.$eval('[data-key="immich-bad-ids"]', (e) => e.textContent)).includes('1'), + await page.$eval('[data-key="immich-bad-ids"]', (e) => e.textContent).catch(() => 'no warning') ); step( - 'with every one of them ticked to begin with', - (await page.isChecked('[data-key="immich-album-' + A1 + '"]')) && - (await page.isChecked('[data-key="immich-album-' + A2 + '"]')) + 'and an id this key cannot see is an id the field says it does not know', + (await page.$('[data-key="immich-unknown-ids"]')) !== null, + await page.$eval('[data-key="immich-unknown-ids"]', (e) => e.textContent).catch(() => 'no warning') +); +await page.fill('[data-key="immich-ids"]', ''); +await page.waitForTimeout(200); +step( + 'and clearing the field takes both warnings with it', + (await page.$('[data-key="immich-bad-ids"]')) === null && + (await page.$('[data-key="immich-unknown-ids"]')) === null ); await page.click('[data-key="immich-save"]'); await settle('Immich', 5); @@ -365,6 +389,32 @@ step( JSON.stringify(await counts()) ); +// --- a key given to a node that is already there ----------------------------- +// The one place a node's key is replaced: the same dialog that narrows its +// albums. It is probed before it is stored, so a key that does not answer is a +// dialog still open on a node unchanged. +await editDialog('Immich'); +await page.fill('[data-key="immich-newkey"]', 'not-the-key'); +await page.click('[data-key="immich-save"]'); +await page.waitForSelector('[data-key="immich-error"]', { timeout: 5_000 }).catch(() => {}); +step( + 'a key given in place of the one it has is refused out loud, dialog still open', + (await page.$('[data-key="immich-error"]')) !== null && + (await page.$('[data-key="immich-modal"]')) !== null, + await page.$eval('[data-key="immich-error"]', (e) => e.textContent).catch(() => 'no word said') +); +await page.fill('[data-key="immich-newkey"]', 'good-key'); +await page.click('[data-key="immich-save"]'); +await page.waitForSelector('[data-key="immich-modal"]', { state: 'detached', timeout: 10_000 }).catch(() => {}); +await settle('Immich', 5); +await open('Immich'); +step( + 'and one that answers is taken, the node and its albums exactly as they were', + (await page.$('[data-key="immich-modal"]')) === null && + same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }), + JSON.stringify(await counts()) +); + // --- a share link ------------------------------------------------------------ await addDialog('https://share.example.com', 'share-xyz', true); step('a share link is not offered an album picker: it is one album', (await page.$('[data-key="immich-albums"]')) === null); diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 3971896..8545afd 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -355,6 +355,9 @@ export const en: Dict = { 'lib.immich.albumsPick': 'Tick the albums to keep in the library. Ticking none means all of them.', 'lib.immich.albumIds': 'Or paste album IDs', 'lib.immich.albumIdsHint': 'One ID per line, or a whole …/albums/ address', + 'lib.immich.badIds': '{n} line(s) with no album ID in them were skipped.', + 'lib.immich.unknownAlbum': 'This key sees no album {id}.', + 'lib.immich.newKey': 'New key (blank to keep the current one)', 'lib.immich.noDownload': 'This key cannot download originals — frames can be viewed and graded from their previews only.', 'lib.immich.sync': 'READ AGAIN', 'lib.immich.disconnect': 'DISCONNECT', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 0a55101..03485a5 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -367,6 +367,9 @@ export const vi = { 'lib.immich.albumsPick': 'Tick những album đưa vào thư viện. Không tick gì nghĩa là lấy tất cả.', 'lib.immich.albumIds': 'Hoặc dán ID album', 'lib.immich.albumIdsHint': 'Mỗi dòng một ID, hoặc dán cả đường dẫn …/albums/', + 'lib.immich.badIds': 'Bỏ qua {n} dòng không có ID album.', + 'lib.immich.unknownAlbum': 'Khoá này không thấy album {id}.', + 'lib.immich.newKey': 'Khoá mới (bỏ trống nếu giữ nguyên)', 'lib.immich.noDownload': 'Khoá này không có quyền tải ảnh gốc — chỉ xem và chỉnh được bản xem trước.', 'lib.immich.sync': 'ĐỌC LẠI', 'lib.immich.disconnect': 'NGẮT KẾT NỐI', diff --git a/docker/frontend/src/ui/ImmichDialog.tsx b/docker/frontend/src/ui/ImmichDialog.tsx index b41afcb..3b85561 100644 --- a/docker/frontend/src/ui/ImmichDialog.tsx +++ b/docker/frontend/src/ui/ImmichDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useI18n } from '../i18n/I18nProvider'; import { listImmichAlbums, @@ -15,15 +15,21 @@ import { // but the backend's own call, which probes before it stores, so a saved server is // one that answered. // -// `server` is the node being edited — its albums, read live, so one added on -// Immich since the last visit is a row to tick. Without it the dialog adds: the -// address, the key, "check the key" (which stores nothing, so a typo is never a -// row), the albums, and the id field for the albums a key is too narrow to list. +// Adding asks for as little as it can: the name, the address, the key, and +// "check the key" (which stores nothing, so a typo is never a row). A fresh +// server is **every** album its key can see — an empty album list is that +// decision, not a missing value — so there is nothing to tick; a reader who wants +// fewer names them in the album-ID field. The tick list belongs to the node that +// is already there, where narrowing is a change rather than a choice at the door. // // ponytail: album ticks and the id field are one list — a tick is an id, and the // field is the way in for an album the API will not name. The field takes a whole // `…/albums/` address as readily as the uuid, since that is where a reader // copies one from. +// The shape an album id has, wherever a reader copied it from: an id, or the +// whole `…/albums/` address they were looking at when they copied it. +const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + export function ImmichDialog({ server, defaultUrl, @@ -40,6 +46,10 @@ export function ImmichDialog({ const [name, setName] = useState(server?.name ?? ''); const [url, setUrl] = useState(server?.url ?? defaultUrl); const [key, setKey] = useState(''); + // The key a node is being given in place of the one it has. Blank means the + // question was not asked — the node keeps the key it already answered with — so + // a reader who only came to retick an album never has to paste one again. + const [newKey, setNewKey] = useState(''); const [type, setType] = useState<'api' | 'share'>(server?.type ?? 'api'); const [albums, setAlbums] = useState(null); const [ticked, setTicked] = useState>(() => new Set(server?.albums ?? [])); @@ -70,7 +80,13 @@ export function ImmichDialog({ if (!server) return; let alive = true; void listImmichAlbums(server.id).then((rows) => { - if (alive && rows.length) setAlbums(rows); + if (!alive || !rows.length) return; + setAlbums(rows); + // A node that keeps every album keeps the empty list — that is what "all of + // them" is stored as — so the ticks it opens with are every name the key has, + // not none. Without this, opening the dialog to change one album would mean + // "untick all", which is the same list only spelled the other way. + if (!server.albums?.length) setTicked(new Set(rows.map((a) => a.id))); }); return () => { alive = false; @@ -81,13 +97,33 @@ export function ImmichDialog({ // carries — a whole address, a list on one line, or both. const chosen = (): string[] => { const out = new Set(ticked); - for (const raw of ids.split(/[\s,;]+/)) { - const hit = raw.trim().match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i); - if (hit) out.add(hit[0].toLowerCase()); - } + for (const hit of idField.hits) out.add(hit); return [...out]; }; + // What the field says, read once for both the answer and the two things worth + // saying about it. Ids the key cannot see are kept rather than dropped — a + // reader who pasted one knows something the list does not, and a key too narrow + // to read albums can only be told by the reader which albums it may open. + const idField = useMemo(() => { + const lines = ids + .split(/[\s,;]+/) + .map((s) => s.trim()) + .filter(Boolean); + const hits = lines.map((s) => s.match(UUID)?.[0].toLowerCase()).filter((v): v is string => Boolean(v)); + const seen = new Set((albums ?? []).map((a) => a.id.toLowerCase())); + return { + hits, + // A line with no id in it at all is a line that will not be part of the + // answer — said out loud, because a silently dropped pasting is a reader + // watching an album not appear. + bad: lines.length - hits.length, + // Only when the key's own list is in hand: an album a key cannot list is one + // this dialog has no word on, and a warning about it would be a guess. + unknown: albums?.length ? [...new Set(hits)].filter((id) => !seen.has(id)) : [], + }; + }, [albums, ids]); + const check = async () => { setBusy(true); setProblem(null); @@ -101,11 +137,10 @@ export function ImmichDialog({ } setCheckedAt('ok'); setCanDownload(res.canDownload); + // The list is not shown at the door — a fresh server keeps everything its key + // can see — it is only here so an id the reader pasted can be told apart from + // one this key never named. setAlbums(res.albums); - // A fresh server keeps everything its key can see until the reader says - // otherwise: an empty tick list means "all of them", so ticking them is the - // same decision spelled out where it can be changed. - if (!share) setTicked(new Set(res.albums.map((a) => a.id))); }; const save = async (e: React.FormEvent) => { @@ -114,8 +149,20 @@ export function ImmichDialog({ if (editing && server) { setBusy(true); try { + // The key, when one was given, goes up on its own call — the backend + // probes before it stores, so a key that does not answer leaves this open + // with the node it had. + let saved: ImmichServer | null = null; + if (newKey.trim()) { + const res = await saveImmichServer({ id: server.id, key: newKey.trim() }); + if (!res.ok) { + setProblem(why(res.error)); + return; + } + saved = res.server; + } const kept = await saveImmichAlbums(server.id, chosen()); - onSaved({ ...server, albums: kept }, false); + onSaved({ ...(saved ?? server), albums: kept }, false); } finally { setBusy(false); } @@ -220,7 +267,7 @@ export function ImmichDialog({ )} - {albums && !share ? ( + {editing && albums && !share ? ( <>

{albums.length ? t('lib.immich.albumsPick') : t('lib.immich.empty')} @@ -264,6 +311,33 @@ export function ImmichDialog({ placeholder={t('lib.immich.albumIdsHint')} onChange={(e) => setIds(e.target.value)} /> + {idField.bad > 0 ? ( +

+ {t('lib.immich.badIds', { n: idField.bad })} +

+ ) : null} + {idField.unknown.length ? ( +

+ {t('lib.immich.unknownAlbum', { id: idField.unknown[0].slice(0, 8) })} +

+ ) : null} + + ) : null} + + {editing && server ? ( + <> + + setNewKey(e.target.value)} + /> ) : null} @@ -272,7 +346,7 @@ export function ImmichDialog({ {t('lib.immich.noDownload')}

) : null} - {problem && checkedAt === 'none' ? ( + {problem && checkedAt !== 'fail' ? (

{problem}