Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,17 @@ MultiCam Planner is designed for quick, intuitive camera planning with essential
goes through the same check as cameras/lenses carried in a project file; one
that fails is skipped and counted in the sync line. The cache lives in local
storage and survives sign-out, so placed library cameras keep working
offline. Under the selector a library entry shows its status, its number of
offline.
- **Offline contract** (shared by every planner, `syncFrom` in the client):
the cache changes only on a successful response — offline, a timeout
(15 s per request, 120 s per upload batch), a server error, an expired
sign-in or signing out leave the last synced devices usable. Every server
address has its own cache slot under the same storage key; a single cache
from an older version is read as the slot of its server. When the server
reports a lower `latestSeq` than remembered (set up anew, restored from a
backup), the whole stand is fetched again and replaces the cache — unless it
is empty: then the sync fails with *server was set up anew … devices were
kept*, and nothing is deleted. Under the selector a library entry shows its status, its number of
confirmations and a link to its page.
- **Library entries travel in the project file**: the ones placed cameras use
are written into the `.mcplan` (and the `.avplan` cameras slot) as
Expand Down Expand Up @@ -130,7 +140,8 @@ MultiCam Planner is designed for quick, intuitive camera planning with essential
address must be added there, otherwise every request is blocked and the
settings report the server as unreachable. Changing the address signs out
at the old server, forgets the token (it must never reach another server)
and starts an empty cache and upload record. Only `https://` is accepted (`http://` for
and switches to that server's cache slot and a fresh upload record; the
previous server's cache stays stored and is back when you switch back. Only `https://` is accepted (`http://` for
localhost).
- Client: `src/utils/deviceLibraryClient.ts`, an unchanged copy of
`larszu/av-device-library` `clients/deviceLibraryClient.ts` — changes go
Expand Down
115 changes: 112 additions & 3 deletions src/__tests__/deviceLibrary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Camera, Lens } from '../types';
import { CAMERAS } from '../data/cameras';
import { LENSES } from '../data/lenses';
import { cameraToFacet, lensToFacet, facetToItem, libraryIdFor, proposalCore } from '../library/facet';
import { emptyCache, mergeSync, readCache } from '../library/sync';
import { emptyCache, mergeSync, readCache, writeCache } from '../library/sync';
import type { SyncDevice, SyncResponse } from '../utils/deviceLibraryClient';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -131,6 +131,17 @@ describe('sync merge', () => {
expect(readCache(a.cache, 'https://b')).toEqual(emptyCache('https://b'));
expect(readCache('kaputt', 'https://b')).toEqual(emptyCache('https://b'));
});

it('every server has its own slot; writing one keeps the others', () => {
const a = mergeSync(emptyCache('https://a'), antwort(1, [geraet('cam', 1, kamera)])).cache;
const b = mergeSync(emptyCache('https://b'), antwort(4, [geraet('lens', 4, optik)])).cache;
const ablage = writeCache(writeCache(null, a), b);
expect(readCache(ablage, 'https://a')).toEqual(a);
expect(readCache(ablage, 'https://b')).toEqual(b);
// Ein alter Einzelstand wird als Platz seines Servers gelesen.
expect(readCache(a, 'https://a')).toEqual(a);
expect(readCache(writeCache(a, b), 'https://a')).toEqual(a);
});
});

describe('store against a mocked server', () => {
Expand Down Expand Up @@ -195,7 +206,7 @@ describe('store against a mocked server', () => {
await useDeviceLibrary.getState().syncNow();
expect(fetchMock.mock.calls[2][0]).toBe('https://devices.zumpelars.de/api/sync?planner=multicam&after=7');
expect(getCameraById('devlib-sony-fx9-lib')).toBeUndefined();
expect(JSON.parse(speicher['multicam-device-library-cache']).latestSeq).toBe(9);
expect(JSON.parse(speicher['multicam-device-library-cache']).byServer['https://devices.zumpelars.de'].latestSeq).toBe(9);
});

it('second factor: challenge header goes back with the code', async () => {
Expand Down Expand Up @@ -304,7 +315,7 @@ describe('store against a mocked server', () => {
expect(messageText('guidelines-outdated')).toMatch(/guidelines/);
});

it('a changed server forgets the token and the cache; bad addresses are refused', async () => {
it('a changed server forgets the token; bad addresses are refused', async () => {
const { useDeviceLibrary, normaliseServerUrl } = await import('../library/store');
expect(normaliseServerUrl('http://devices.example.com')).toBeNull();
expect(normaliseServerUrl('http://localhost:8080/')).toBe('http://localhost:8080');
Expand All @@ -318,6 +329,104 @@ describe('store against a mocked server', () => {
expect(JSON.parse(speicher['multicam-device-library-server'])).toBeNull();
});

// ── Offline-Vertrag (`syncFrom` im gemeinsamen Client) ──────────────────
const eineKamera = () => antwort(3, [geraet('sony-fx9-lib', 3, cameraToFacet(eigeneKamera))]);
const cacheVon = (server: string) => readCache(JSON.parse(speicher['multicam-device-library-cache'] ?? 'null'), server);

it('switching the server keeps the other server\'s cache; switching back restores it', async () => {
fetchMock
.mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
.mockResolvedValueOnce(json(eineKamera()));
const { useDeviceLibrary } = await import('../library/store');
const { getCameraById } = await import('../data/cameras');
await useDeviceLibrary.getState().signIn('a@b.de', 'pw');
expect(useDeviceLibrary.getState().cache.entries).toHaveLength(1);

await useDeviceLibrary.getState().setServer('https://devices.example.com');
expect(useDeviceLibrary.getState().cache).toEqual(emptyCache('https://devices.example.com'));
expect(getCameraById('devlib-sony-fx9-lib')).toBeUndefined();
expect(cacheVon('https://devices.zumpelars.de').entries).toHaveLength(1);

await useDeviceLibrary.getState().setServer('https://devices.zumpelars.de');
expect(useDeviceLibrary.getState().cache.latestSeq).toBe(3);
expect(getCameraById('devlib-sony-fx9-lib')?.model).toBe('PXW-FX9 (eigene)');
});

it('a legacy single cache is read as the slot of its server and survives the next save', async () => {
const alt = mergeSync(emptyCache('https://devices.zumpelars.de'), eineKamera()).cache;
speicher['multicam-device-library-cache'] = JSON.stringify(alt);
const { useDeviceLibrary } = await import('../library/store');
expect(useDeviceLibrary.getState().cache).toEqual(alt);
fetchMock
.mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
.mockResolvedValueOnce(json(antwort(3, [])));
await useDeviceLibrary.getState().signIn('a@b.de', 'pw');
expect(fetchMock.mock.calls[1][0]).toContain('after=3');
expect(JSON.parse(speicher['multicam-device-library-cache']).format).toBe('multicam-device-library-caches');
expect(cacheVon('https://devices.zumpelars.de').entries).toHaveLength(1);
});

it('a lower latestSeq fetches everything again and replaces the cache', async () => {
fetchMock
.mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
.mockResolvedValueOnce(json(antwort(10, [geraet('alt', 10, cameraToFacet(eigeneKamera))])));
const { useDeviceLibrary } = await import('../library/store');
await useDeviceLibrary.getState().signIn('a@b.de', 'pw');
fetchMock
.mockResolvedValueOnce(json(antwort(2, [])))
.mockResolvedValueOnce(json(antwort(2, [geraet('neu', 2, lensToFacet(eigeneOptik))])));
await useDeviceLibrary.getState().syncNow();
expect(fetchMock.mock.calls[3][0]).toContain('after=0');
const c = useDeviceLibrary.getState().cache;
expect(c.entries.map((e) => e.slug)).toEqual(['neu']);
expect(c.latestSeq).toBe(2);
expect(cacheVon('https://devices.zumpelars.de').entries.map((e) => e.slug)).toEqual(['neu']);
});

it('an empty new server is an error and keeps the cache; so do offline and sign-out', async () => {
fetchMock
.mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
.mockResolvedValueOnce(json(eineKamera()));
const { useDeviceLibrary } = await import('../library/store');
const { libraryErrorText } = await import('../library/messages');
await useDeviceLibrary.getState().signIn('a@b.de', 'pw');
const vorher = speicher['multicam-device-library-cache'];

fetchMock.mockResolvedValueOnce(json(antwort(0, []))).mockResolvedValueOnce(json(antwort(0, [])));
await useDeviceLibrary.getState().syncNow();
expect(useDeviceLibrary.getState().error).toBe('server-empty');
expect(libraryErrorText((_k, en) => en, 'server-empty')).toMatch(/were kept/);
expect(useDeviceLibrary.getState().cache.entries).toHaveLength(1);
expect(speicher['multicam-device-library-cache']).toBe(vorher);

fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await useDeviceLibrary.getState().syncNow();
expect(useDeviceLibrary.getState().error).toBe('offline');
expect(libraryErrorText((_k, en) => en, 'offline')).toMatch(/last sync stay available/);
expect(useDeviceLibrary.getState().cache.entries).toHaveLength(1);

fetchMock.mockResolvedValueOnce(json({ error: 'x' }, {}, 401));
await useDeviceLibrary.getState().syncNow();
expect(useDeviceLibrary.getState().signedIn).toBe(false);
expect(useDeviceLibrary.getState().cache.entries).toHaveLength(1);
expect(speicher['multicam-device-library-cache']).toBe(vorher);
});

it('sign-out keeps the cache', async () => {
fetchMock
.mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
.mockResolvedValueOnce(json(eineKamera()))
.mockResolvedValueOnce(json({}));
const { useDeviceLibrary } = await import('../library/store');
const { getCameraById } = await import('../data/cameras');
await useDeviceLibrary.getState().signIn('a@b.de', 'pw');
await useDeviceLibrary.getState().signOut();
expect(useDeviceLibrary.getState().signedIn).toBe(false);
expect(useDeviceLibrary.getState().cache.entries).toHaveLength(1);
expect(cacheVon('https://devices.zumpelars.de').entries).toHaveLength(1);
expect(getCameraById('devlib-sony-fx9-lib')?.model).toBe('PXW-FX9 (eigene)');
});

it('guidelines link follows the server address', async () => {
const { guidelinesUrl } = await import('../library/messages');
expect(guidelinesUrl('https://devices.zumpelars.de')).toBe('https://devices.zumpelars.de/guidelines');
Expand Down
5 changes: 2 additions & 3 deletions src/components/Library/LibraryErrorLine.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
// Fehlerzeile der Geraetebibliothek; bei geaenderten Richtlinien mit dem Weg
// dorthin, denn annehmen laesst sie sich nur auf der Website.
import { useTranslation } from '../../i18n';
import type { LibraryErrorCode } from '../../utils/deviceLibraryClient';
import { guidelinesUrl, libraryErrorText } from '../../library/messages';
import { guidelinesUrl, libraryErrorText, type LibraryFehler } from '../../library/messages';
import { useDeviceLibrary } from '../../library/store';

export default function LibraryErrorLine({ code, detail }: { code: LibraryErrorCode; detail?: string }) {
export default function LibraryErrorLine({ code, detail }: { code: LibraryFehler; detail?: string }) {
const { t } = useTranslation();
const server = useDeviceLibrary((s) => s.server);
return (
Expand Down
2 changes: 1 addition & 1 deletion src/components/Settings/DeviceLibrarySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export default function DeviceLibrarySection() {
</p>
)}
<p className="mt-1 text-xs text-bc-muted">
{t('library.server.hint', 'Changing the server signs you out and starts an empty cache: an account and its devices belong to one server.')}
{t('library.server.hint', 'Changing the server signs you out: an account and its devices belong to one server. Each server keeps its own cache — switching back brings its devices back.')}
</p>
</section>

Expand Down
6 changes: 4 additions & 2 deletions src/i18n/de/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const library: Record<string, string> = {
'library.server.custom':
'Nicht der Standard-Server ({url}). Dieser Build erlaubt in seiner Content-Security-Policy nur den Standard; ein anderer Server muss dort eingetragen werden, sonst wird jede Anfrage blockiert.',
'library.server.hint':
'Ein Serverwechsel meldet ab und beginnt mit leerem Zwischenspeicher: Konto und Geräte gehören zu einem Server.',
'Ein Serverwechsel meldet ab: Konto und Geräte gehören zu einem Server. Jeder Server hat seinen eigenen Zwischenspeicher — wer zurückwechselt, hat den alten Stand wieder.',

'library.account': 'Konto',
'library.signedInAs': 'Angemeldet als {name} ({email}).',
Expand Down Expand Up @@ -47,7 +47,9 @@ export const library: Record<string, string> = {
'library.error.rateLimited': 'Zu viele Versuche. Eine Minute warten und erneut versuchen.',
'library.error.notSignedIn': 'Nicht angemeldet (oder die Sitzung ist abgelaufen). Bitte neu anmelden.',
'library.error.offline':
'Der Server ist nicht erreichbar. Verbindung prüfen — und bei einem anderen als dem Standard-Server, ob seine Adresse in der Content-Security-Policy dieses Builds steht.',
'Der Server ist nicht erreichbar. Die Geräte vom letzten Abgleich bleiben verfügbar. Verbindung prüfen — und bei einem anderen als dem Standard-Server, ob seine Adresse in der Content-Security-Policy dieses Builds steht.',
'library.error.serverEmpty':
'Der Server wurde neu aufgesetzt und hat noch keine Geräte. Die Geräte vom letzten Abgleich wurden behalten.',
'library.error.server': 'Der Server hat mit einem Fehler geantwortet. Später erneut versuchen.',

'library.sync': 'Abgleich',
Expand Down
11 changes: 9 additions & 2 deletions src/library/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import type { LibraryErrorCode } from '../utils/deviceLibraryClient';

type T = (key: string, en: string) => string;

/** Die Codes des Clients plus `server-empty`: ein `server`-Fehler mit dieser
* Meldung aus `syncFrom` (neu aufgesetzter Server ohne Geraete). Er bekommt
* einen eigenen Satz, weil er sagen muss, dass die lokalen Geraete bleiben. */
export type LibraryFehler = LibraryErrorCode | 'server-empty';

/** Die Fehlercodes des Clients in Saetzen, die sagen, was jetzt zu tun ist. */
export function libraryErrorText(t: T, code: LibraryErrorCode): string {
export function libraryErrorText(t: T, code: LibraryFehler): string {
switch (code) {
case 'wrong-credentials':
return t('library.error.wrongCredentials', 'Email/username or password is wrong.');
Expand All @@ -20,7 +25,9 @@ export function libraryErrorText(t: T, code: LibraryErrorCode): string {
case 'not-signed-in':
return t('library.error.notSignedIn', 'You are not signed in (or the session has expired). Sign in again.');
case 'offline':
return t('library.error.offline', 'The server cannot be reached. Check the connection — and, for a server other than the default, that its address is allowed in the content security policy of this build.');
return t('library.error.offline', 'The server cannot be reached. The devices from the last sync stay available. Check the connection — and, for a server other than the default, that its address is allowed in the content security policy of this build.');
case 'server-empty':
return t('library.error.serverEmpty', 'The server was set up anew and has no devices yet. The devices from the last sync were kept.');
case 'server':
default:
return t('library.error.server', 'The server answered with an error. Try again later.');
Expand Down
Loading
Loading