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:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user