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 ? (
<>