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:
@@ -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