feat(immich): ask nothing of a server at the door

Adding a server no longer opens with a tick list: a fresh node keeps every
album its key can see — the empty album list the backend stores for "all of
them" — and narrowing is a change made on the node that is already there.
The album-ID field carries the warnings that were silent before it: a line
with no id in it, and an id this key never named, both said where they are
typed. A node's key can now be replaced in the dialog that narrows its
albums; the backend probes it first, so a key that does not answer leaves
the node and the dialog as they were.
This commit is contained in:
2026-10-10 16:38:28 +07:00
parent e27998d820
commit 8901e0c378
4 changed files with 164 additions and 34 deletions
+67 -17
View File
@@ -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);
+3
View File
@@ -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/<id> 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',
+3
View File
@@ -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/<id>',
'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',
+91 -17
View File
@@ -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/<uuid>` 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/<id>` 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<ImmichAlbum[] | null>(null);
const [ticked, setTicked] = useState<Set<string>>(() => 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 ? (
<>
<p className="hint" data-key="immich-albums-hint">
{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 ? (
<p className="err" data-key="immich-bad-ids">
{t('lib.immich.badIds', { n: idField.bad })}
</p>
) : null}
{idField.unknown.length ? (
<p className="err" data-key="immich-unknown-ids">
{t('lib.immich.unknownAlbum', { id: idField.unknown[0].slice(0, 8) })}
</p>
) : null}
</>
) : null}
{editing && server ? (
<>
<label className="hint" htmlFor="immich-newkey">
{t('lib.immich.newKey')}
</label>
<input
id="immich-newkey"
className="field"
data-key="immich-newkey"
type="password"
value={newKey}
autoComplete="off"
onChange={(e) => setNewKey(e.target.value)}
/>
</>
) : null}
@@ -272,7 +346,7 @@ export function ImmichDialog({
{t('lib.immich.noDownload')}
</p>
) : null}
{problem && checkedAt === 'none' ? (
{problem && checkedAt !== 'fail' ? (
<p className="err" data-key="immich-error">
{problem}
</p>