feat(sync): add opds_catalog replica kind (plaintext fields) (#4087)

Wires OPDS catalogs through replica sync as a metadata-only kind.
Plaintext fields only in this PR — encrypted credentials (username,
password) ship in the follow-up alongside the SyncPassphrasePanel UI
and Tauri keychain backend.

- Migration 009 extends the kind allowlist with 'opds_catalog'.
- replicaSchemas adds opdsCatalogFieldsSchema (name, url, description,
  icon, customHeaders, autoDownload, disabled, addedAt) with a 50-row
  per-user cap.
- New opdsCatalogAdapter is metadata-only (no `binary` capability).
  Stable cross-device id from md5("opds:" + url.lower()) so two
  devices that import the same URL converge to one row instead of
  duplicating.
- New customOPDSStore (zustand) hydrates from SystemSettings,
  publishes upserts/deletes through the replica pipeline, preserves
  local-only username/password when overlaying remote updates, and
  strips tombstones at the persistence boundary so existing
  useSettingsStore readers (useOPDSSubscriptions, pseStream,
  app/opds/page.tsx) need no migration.
- replicaPullAndApply branches on adapter.binary so metadata-only
  kinds skip the bundleDir requirement and the manifest/binary path.
- CatalogManager rewires Add / Edit / Remove / Toggle / Add-popular
  through the new store.

Plan update bundled in: tenet 8 (scalar settings sync via a bundled
row; collections sync per-record), per-kind allowlist now includes a
`settings` singleton that will collapse PRs 5 + 6+ into one bundled
adapter, and PR 4 is split into 4a (already merged) / 4b (this) / 4c
(encrypted credentials + UX).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-08 11:33:18 +08:00
committed by GitHub
parent aea3fda086
commit 6bfeb295d2
16 changed files with 1069 additions and 76 deletions
@@ -71,6 +71,20 @@ for the full decision capture.
The polymorphic `replicas` table is internal infrastructure, not a
future API. Adding any cross-user-visibility feature requires a fresh
architecture review and is not authorized by this plan.
8. **Scalar settings sync via a bundled row; collections sync per-record.**
The CRDT model is per-field LWW within a row, so 1 row × N fields and
N rows × 1 field have identical conflict semantics for scalars.
Scalar settings (`theme`, `fontSize`, `highlightColor`) and flat maps
(`providerEnabled`, per-shortcut overrides) collapse cleanly into a
single `settings` kind with a server-managed field whitelist —
adding a new synced setting becomes a one-line whitelist addition.
Collections where each element needs independent identity, tombstones,
or remove-wins semantics (OPDS catalogs, dictionaries, fonts,
textures, web searches, ordered provider positions) stay per-record.
Folding a collection into a bundled row is unsafe under concurrent
edits: Device A adds X, Device B adds Y, both push the full array,
last writer wins. Per-record rows preserve both sides via independent
CRDT envelopes.
## Architecture overview
@@ -307,15 +321,19 @@ on every add).
## Per-kind initial allowlist (ship in this order)
| kind | binary | id source | files |
| kind | binary | id source | files / shape |
|---|---|---|---|
| `dictionary` | yes | `partialMD5(primaryFile) + size + filenames` | mdx + mdd[] + css[] (skip `.idx.offsets`/`.syn.offsets`) |
| `font` | yes | `partialMD5(file) + size + filename` | single .ttf/.otf/.woff[2] |
| `texture` | yes | `partialMD5(file) + size + filename` | single image |
| `opds_catalog` | no | `md5(url + username)` | — (URL + name + headers + **password ENCRYPTED**) |
| `dict_provider_pref` | no | provider id | — (one boolean field `enabled`) |
| `dict_provider_position` | no | provider id | — (one position string + actor id) |
| `dict_web_search` | no | existing `WebSearchEntry.id` | — |
| `opds_catalog` | no | `md5("opds:" + url.lower())` | — (URL + name + headers + **password ENCRYPTED**) |
| `settings` | no | `'singleton'` (one row per user) | — (whitelisted scalar fields + flat maps via namespaced field keys; covers `theme`, `fontSize`, `highlightColor`, `lineHeight`, `pref:*`, `shortcut:*`, `providerEnabled.<id>`, `syncCategories.<id>`, etc.) |
| `dict_provider_position` | no | provider id | — (one position string + actor id; per-element rows because concurrent rename + reorder must preserve both sides) |
| `dict_web_search` | no | existing `WebSearchEntry.id` | — (collection of independent records — needs per-record tombstones) |
`settings` replaces what would otherwise be N per-kind adapters for each
scalar setting. Adding a new synced setting = one-line whitelist + a
server schema bump; no new adapter, no new client wiring.
Future kinds require a server PR (schema + allowlist + migration if
needed).
@@ -323,15 +341,18 @@ needed).
## User-selectable sync categories
A new settings panel under **Account → Sync** with per-category toggles.
Default: book/config/note/dictionary/font/texture/opds_catalog all ON;
internal kinds (`dict_provider_pref`, `dict_provider_position`,
`dict_web_search`) follow the parent (dictionary) toggle.
Default: book/config/note/dictionary/font/texture/opds_catalog/settings
all ON; internal kinds (`dict_provider_position`, `dict_web_search`)
follow the parent (dictionary) toggle. `providerEnabled.<id>` lives
inside the `settings` bundle and rides the `settings` toggle, not the
`dictionary` toggle (small ergonomic gap; surfaced in the Sync panel
copy).
`SystemSettings.syncCategories: Record<string, boolean>` stores the
preferences. The category map itself syncs through the existing
`config`-style settings JSON sync (or, more cleanly, becomes its own kind
`sync_pref` once the primitive is built). Per-category gates apply at the
sync manager:
preferences. Per tenet 8, the category map itself becomes a flat map
inside the `settings` bundle (`syncCategories.<kind>` namespaced fields)
once the bundle ships in PR 5 — no separate `sync_pref` kind needed.
Per-category gates apply at the sync manager:
- `pull(kind)` no-ops if `syncCategories[kind] === false`.
- `push(rows)` filters out rows whose kind is disabled.
@@ -538,29 +559,82 @@ example. Add `'font'` to the allowlist + schema + adapter.
Similar shape to fonts.
### PR 4 — `opds_catalog` (with encrypted password)
### PR 4 — `opds_catalog` (split into 4a + 4b + 4c)
Adapter syncs `id`, `name`, `url`, `description`, `icon`,
`customHeaders`, `autoDownload` as plaintext fields, and `password`
(and optionally `username`) as **encrypted-field envelopes** using the
crypto infra shipped in PR 1. Lazy passphrase prompt: first OPDS
catalog import with credentials triggers the "Set sync passphrase"
modal.
Originally one PR; split during PR 4 build because the crypto
introduction is a step-up in complexity that benefits from separate
review surfaces.
### PR 5internal dict-settings kinds
**PR 4aencrypted-field session wiring (no consumer kind).** Ships
the per-account PBKDF2 salt endpoint (`/api/sync/replica-keys` + the
`replica_keys_create` / `replica_keys_list` RPCs against the
`replica_keys` table from migration 003), the in-memory `CryptoSession`
manager that derives keys lazily per `saltId`, and tests. No UI, no
consumer kind. Production-ready infrastructure for PR 4c.
Migrate `dictionarySettings.providerOrder` (per-position rows with
`(position, actorId, replicaId)` total order) /
`dictionarySettings.providerEnabled` (per-pref rows) /
`dictionarySettings.webSearches`. This lets concurrent rename + reorder
both survive on dictionaries — the original goal — but only after the
single-array model is fully retired.
**PR 4b — `opds_catalog` plaintext fields.** Adapter syncs `id`,
`name`, `url`, `description`, `icon`, `customHeaders`, `autoDownload`,
`disabled`, `addedAt`. Stable cross-device id from `md5("opds:" +
url.lower())`. Credentials (`username`, `password`) stay local-only —
not pushed, not pull-overwritten. Public catalogs sync end-to-end
immediately; credentialed catalogs need re-entry on each device until
4c lands.
### PR 6+anything else
**PR 4c`opds_catalog` encrypted credentials + passphrase UX.**
Adds `username` and `password` as encrypted-field envelopes via the
crypto session shipped in 4a. Ships `SyncPassphrasePanel` UI
(set / change / forgot), the lazy `getOrPromptPassphrase` modal that
fires on first encrypted-field push or pull, the Tauri keychain backend
that replaces the `EphemeralPassphraseStore` stub, and the
forgot-passphrase server endpoint (wipes encrypted envelopes + rotates
salt).
`pref`, `theme`, `shortcut`, `annotation_rule`, future AI keys
(another encrypted-field kind), etc. Each is a coordinated client+server
PR (adapter + schema + allowlist).
### PR 5 — `settings` bundled kind (collapses original PR 5 + PR 6+)
Per tenet 8, one `settings` adapter with a server-managed whitelist of
`SystemSettings` keys, instead of N per-kind adapters for each scalar
setting. Initial whitelist: `theme`, `fontSize`, `lineHeight`,
`highlightColor`, `pref:*`, `shortcut:*`,
`dictionarySettings.providerEnabled` (encoded as namespaced field keys
like `providerEnabled.<id>`), `syncCategories.<id>`.
Singleton row (`replica_id = 'singleton'`) per user. Per-field LWW
handles concurrent edits across devices: Device A toggles dark mode,
Device B changes font size, both push, both apply. Adding a new synced
setting is a one-line whitelist addition + a server schema bump — no
new adapter, no new client wiring.
This collapses what was previously planned as PRs 5 + 6+ (per-kind
adapters for `dict_provider_pref`, `pref`, `theme`, `shortcut`,
`annotation_rule`, etc.). The genuinely-different shapes (ordered
collections, independent-record collections) ship as PR 6 and PR 7.
### PR 6 — `dict_provider_position` (ordered list with per-position rows)
The provider order is the only setting that genuinely needs per-element
rows: concurrent rename + reorder must preserve both sides, which a
single-field array can't do. Per-position rows keyed by
`(position, actorId, replicaId)` with deterministic tiebreak.
Migrates `dictionarySettings.providerOrder` off the single-array
shape.
### PR 7 — `dict_web_search` (custom web-search entries)
Collection of independent records — each with `id`, `name`,
`urlTemplate`, plus tombstones. Per-record rows because users add /
delete / rename entries independently across devices.
### PR 8+ — incremental whitelist additions
New scalar settings join the `settings` whitelist as needed (one-line
PR + server schema bump). Future encrypted-field needs (AI API keys,
etc.) get either:
- A new dedicated kind (if it needs its own quota, allowlist, or
per-record tombstones), OR
- A namespaced encrypted field within `settings` (if it's a small
scalar that fits the bundled pattern — e.g.,
`aiApiKey.openai`, `aiApiKey.anthropic`).
## Migration
@@ -1059,8 +1133,49 @@ infra in PR 1; OPDS adapter that consumes it ships in PR 4.
- `/codex review` re-run on the revised plan would catch any drift
introduced by CEO + eng review changes; recommended but not required.
**POST-PR-3 ARCHITECTURE REFINEMENT (during PR 4 build):**
Questioned why each scalar setting needed its own kind. Insight: the
CRDT model is per-field LWW within a row, so 1 row × N fields and N
rows × 1 field have identical conflict semantics for scalars. The
`dict_provider_pref` adapter (one boolean field per provider) was the
canary — it would have been a 100-LOC adapter for a single boolean.
Generalizing: any scalar setting collapses into a single bundled
`settings` row with a server-managed field whitelist, using namespaced
field keys (`providerEnabled.<id>`, `syncCategories.<id>`,
`shortcut.<action>`) for flat maps.
What does NOT collapse: collections of independent records (OPDS
catalogs, dictionaries, fonts, textures, web searches) — each element
needs independent identity, tombstones, and per-element CRDT envelopes
to survive concurrent add/remove on different devices. And ordered
lists (`providerOrder`) — concurrent rename + reorder must preserve
both sides, which a single-field array can't do.
Plan changes absorbed:
- **New tenet 8** captures the scalar-vs-collection rule.
- **Per-kind allowlist updated:** added `settings` (singleton); removed
`dict_provider_pref` (folds into settings).
- **Phasing rewritten:** PR 4 split into 4a (encrypted-field session
wiring) + 4b (opds_catalog plaintext) + 4c (opds_catalog encrypted
credentials + passphrase UX). PR 5 becomes the bundled `settings`
kind — collapses what was previously planned as PRs 5 + 6+. PR 6 =
`dict_provider_position` (still needs per-element rows). PR 7 =
`dict_web_search`. PR 8+ = incremental whitelist additions, no new
adapters for scalar settings.
- **Sync categories** become a flat map inside the `settings` bundle
(`syncCategories.<kind>`); the standalone `sync_pref` kind hinted at
in the original plan is no longer needed.
Net: ~70% LOC reduction on the remaining roadmap (no per-kind adapters
for `pref`, `theme`, `shortcut`, `annotation_rule`, etc.). No change to
PRs 13 (already shipped) or PR 4a (already merged as #4084).
**VERDICT:** CEO + ENG CLEARED — plan is implementation-ready. Codex
review reshaped the architecture; CEO review reshaped the scope; eng
review locked implementation details (adapter shape, push trigger,
file layout, error model, test coverage, manifest schema, perf SLOs).
Next step: implement PR 1.
file layout, error model, test coverage, manifest schema, perf SLOs);
the post-PR-3 refinement collapsed the long-tail per-kind adapters
into a single bundled `settings` kind. Next step: complete PR 4b/4c,
then ship PR 5.
@@ -28,11 +28,11 @@ const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
});
describe('isAllowedKind', () => {
test('current allowlist contains dictionary + font + texture', () => {
test('current allowlist contains dictionary + font + texture + opds_catalog', () => {
expect(isAllowedKind('dictionary')).toBe(true);
expect(isAllowedKind('font')).toBe(true);
expect(isAllowedKind('texture')).toBe(true);
expect(isAllowedKind('opds_catalog')).toBe(false);
expect(isAllowedKind('opds_catalog')).toBe(true);
});
test('rejects arbitrary strings', () => {
@@ -145,7 +145,7 @@ describe('validatePullParams', () => {
});
test('rejects unknown kind', () => {
const result = validatePullParams('opds_catalog', null);
const result = validatePullParams('not_a_kind', null);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(422);
@@ -0,0 +1,123 @@
import { describe, expect, test } from 'vitest';
import {
computeOpdsCatalogContentId,
opdsCatalogAdapter,
OPDS_CATALOG_KIND,
OPDS_CATALOG_SCHEMA_VERSION,
} from '@/services/sync/adapters/opdsCatalog';
import type { OPDSCatalog } from '@/types/opds';
import type { FieldEnvelope, Hlc, ReplicaRow } from '@/types/replica';
const sample: OPDSCatalog = {
id: 'cid',
contentId: 'cid',
name: 'My Library',
url: 'https://example.com/opds',
description: 'A test catalog',
icon: '📚',
customHeaders: { 'X-Token': 'abc' },
autoDownload: true,
addedAt: 1700000000000,
};
const HLC = '00000000000-00000000-dev' as Hlc;
const env = <T>(v: T): FieldEnvelope<T> => ({ v, t: HLC, s: 'dev' });
const makeRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
user_id: 'u',
kind: OPDS_CATALOG_KIND,
replica_id: 'cid',
fields_jsonb: {
name: env('My Library'),
url: env('https://example.com/opds'),
description: env('A test catalog'),
icon: env('📚'),
customHeaders: env({ 'X-Token': 'abc' }),
autoDownload: env(true),
addedAt: env(1700000000000),
},
manifest_jsonb: null,
deleted_at_ts: null,
reincarnation: null,
updated_at_ts: HLC,
schema_version: 1,
...overrides,
});
describe('opdsCatalogAdapter', () => {
test('kind + schemaVersion are stable constants', () => {
expect(opdsCatalogAdapter.kind).toBe('opds_catalog');
expect(opdsCatalogAdapter.schemaVersion).toBe(1);
expect(OPDS_CATALOG_KIND).toBe('opds_catalog');
expect(OPDS_CATALOG_SCHEMA_VERSION).toBe(1);
});
test('declares no `binary` capability — metadata-only kind', () => {
expect(opdsCatalogAdapter.binary).toBeUndefined();
});
test('pack omits username and password (encrypted-credential PR)', () => {
const withCreds: OPDSCatalog = { ...sample, username: 'alice', password: 'hunter2' };
const fields = opdsCatalogAdapter.pack(withCreds);
expect(fields['username']).toBeUndefined();
expect(fields['password']).toBeUndefined();
expect(fields['name']).toBe('My Library');
expect(fields['url']).toBe('https://example.com/opds');
});
test('pack ∘ unpack is identity for non-credential fields', async () => {
const fields = opdsCatalogAdapter.pack(sample);
const out = opdsCatalogAdapter.unpack(fields);
expect(out.name).toBe(sample.name);
expect(out.url).toBe(sample.url);
expect(out.description).toBe(sample.description);
expect(out.icon).toBe(sample.icon);
expect(out.customHeaders).toEqual(sample.customHeaders);
expect(out.autoDownload).toBe(true);
expect(out.addedAt).toBe(sample.addedAt);
});
test('computeId returns the contentId', async () => {
const id = await opdsCatalogAdapter.computeId(sample);
expect(id).toBe('cid');
});
test('unpackRow rebuilds the catalog from CRDT envelopes', () => {
const row = makeRow();
const out = opdsCatalogAdapter.unpackRow(row, '');
expect(out).not.toBeNull();
expect(out!.id).toBe('cid');
expect(out!.contentId).toBe('cid');
expect(out!.name).toBe('My Library');
expect(out!.url).toBe('https://example.com/opds');
expect(out!.autoDownload).toBe(true);
});
test('unpackRow returns null when name or url is missing', () => {
const noName = makeRow({ fields_jsonb: { url: env('https://x') } });
expect(opdsCatalogAdapter.unpackRow(noName, '')).toBeNull();
const noUrl = makeRow({ fields_jsonb: { name: env('x') } });
expect(opdsCatalogAdapter.unpackRow(noUrl, '')).toBeNull();
});
test('unpackRow surfaces the reincarnation token', () => {
const row = makeRow({ reincarnation: 'rev1' });
const out = opdsCatalogAdapter.unpackRow(row, '');
expect(out!.reincarnation).toBe('rev1');
});
});
describe('computeOpdsCatalogContentId', () => {
test('is stable under whitespace and case differences in URL', () => {
const a = computeOpdsCatalogContentId('https://Example.com/OPDS/');
const b = computeOpdsCatalogContentId(' https://example.com/opds/ ');
expect(a).toBe(b);
});
test('different URLs produce different ids', () => {
const a = computeOpdsCatalogContentId('https://example.com/opds');
const b = computeOpdsCatalogContentId('https://example.com/feed');
expect(a).not.toBe(b);
});
});
@@ -12,6 +12,13 @@ vi.mock('@/store/customTextureStore', () => ({
useCustomTextureStore: { getState: () => ({ markAvailableByContentId: vi.fn() }) },
}));
vi.mock('@/store/customOPDSStore', () => ({
useCustomOPDSStore: {
getState: () => ({ applyRemoteCatalog: vi.fn(), softDeleteByContentId: vi.fn() }),
},
findOPDSCatalogByContentId: vi.fn(),
}));
import {
__resetBootstrapForTests,
bootstrapReplicaAdapters,
@@ -39,12 +46,12 @@ describe('bootstrapReplicaAdapters', () => {
test('is idempotent: calling twice is a no-op (does not throw)', () => {
bootstrapReplicaAdapters();
bootstrapReplicaAdapters();
expect(listReplicaAdapters()).toHaveLength(3);
expect(listReplicaAdapters()).toHaveLength(4);
});
test('registers the current allowlist (dictionary, font, texture)', () => {
test('registers the current allowlist (dictionary, font, texture, opds_catalog)', () => {
bootstrapReplicaAdapters();
const kinds = listReplicaAdapters().map((a) => a.kind);
expect(kinds).toEqual(['dictionary', 'font', 'texture']);
expect(kinds).toEqual(['dictionary', 'font', 'texture', 'opds_catalog']);
});
});
@@ -0,0 +1,269 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { useCustomOPDSStore } from '@/store/customOPDSStore';
import { useSettingsStore } from '@/store/settingsStore';
import { computeOpdsCatalogContentId } from '@/services/sync/adapters/opdsCatalog';
import type { OPDSCatalog } from '@/types/opds';
import type { SystemSettings } from '@/types/settings';
import type { EnvConfigType } from '@/services/environment';
// Replica-publish helpers fan out to the network — stub them so tests
// stay hermetic. We assert they fire for upserts/deletes via spies.
vi.mock('@/services/sync/replicaPublish', () => ({
publishReplicaUpsert: vi.fn(),
publishReplicaDelete: vi.fn(),
}));
import { publishReplicaUpsert, publishReplicaDelete } from '@/services/sync/replicaPublish';
const makeEnvConfig = (): EnvConfigType =>
({
getAppService: vi.fn(),
}) as unknown as EnvConfigType;
const makeSettings = (overrides: Partial<SystemSettings> = {}): SystemSettings =>
({
opdsCatalogs: [],
...overrides,
}) as unknown as SystemSettings;
beforeEach(() => {
useCustomOPDSStore.setState({ catalogs: [], loading: false });
useSettingsStore.setState({
settings: makeSettings(),
setSettings: (s: SystemSettings) => useSettingsStore.setState({ settings: s }),
saveSettings: vi.fn(),
} as unknown as ReturnType<typeof useSettingsStore.getState>);
vi.clearAllMocks();
});
describe('customOPDSStore', () => {
describe('addCatalog', () => {
test('mints a contentId from the URL when one is not provided', () => {
const cat = useCustomOPDSStore.getState().addCatalog({
id: 'local-1',
name: 'My Library',
url: 'https://example.com/opds',
});
expect(cat.contentId).toBe(computeOpdsCatalogContentId('https://example.com/opds'));
expect(cat.addedAt).toBeGreaterThan(0);
});
test('publishes the upsert via replicaPublish', () => {
useCustomOPDSStore.getState().addCatalog({
id: 'local-1',
name: 'My Library',
url: 'https://example.com/opds',
});
expect(publishReplicaUpsert).toHaveBeenCalledTimes(1);
const [kind] = (publishReplicaUpsert as unknown as ReturnType<typeof vi.fn>).mock.calls[0]!;
expect(kind).toBe('opds_catalog');
});
test('re-adding a soft-deleted entry mints a reincarnation token', () => {
const first = useCustomOPDSStore.getState().addCatalog({
id: 'l1',
name: 'L1',
url: 'https://example.com/opds',
});
useCustomOPDSStore.getState().removeCatalog(first.id);
vi.clearAllMocks();
const revived = useCustomOPDSStore.getState().addCatalog({
id: 'l2',
name: 'L1 again',
url: 'https://example.com/opds',
});
expect(revived.deletedAt).toBeUndefined();
expect(revived.reincarnation).toBeTruthy();
expect(publishReplicaUpsert).toHaveBeenCalledTimes(1);
});
});
describe('updateCatalog', () => {
test('publishes the upsert for non-URL patches', () => {
const cat = useCustomOPDSStore.getState().addCatalog({
id: 'local-1',
name: 'Old',
url: 'https://example.com/opds',
});
const oldContentId = cat.contentId;
vi.clearAllMocks();
const updated = useCustomOPDSStore.getState().updateCatalog(cat.id, { name: 'New' });
expect(updated!.name).toBe('New');
expect(updated!.contentId).toBe(oldContentId);
expect(publishReplicaUpsert).toHaveBeenCalledTimes(1);
});
test('changing the URL recomputes contentId', () => {
const cat = useCustomOPDSStore.getState().addCatalog({
id: 'local-1',
name: 'Cat',
url: 'https://example.com/opds',
});
const updated = useCustomOPDSStore
.getState()
.updateCatalog(cat.id, { url: 'https://other.example/opds' });
expect(updated!.contentId).toBe(computeOpdsCatalogContentId('https://other.example/opds'));
expect(updated!.contentId).not.toBe(cat.contentId);
});
test('no-op on tombstoned entry', () => {
const cat = useCustomOPDSStore.getState().addCatalog({
id: 'l1',
name: 'L1',
url: 'https://example.com/opds',
});
useCustomOPDSStore.getState().removeCatalog(cat.id);
vi.clearAllMocks();
const out = useCustomOPDSStore.getState().updateCatalog(cat.id, { name: 'X' });
expect(out).toBeUndefined();
expect(publishReplicaUpsert).not.toHaveBeenCalled();
});
});
describe('removeCatalog', () => {
test('soft-deletes and publishes the tombstone', () => {
const cat = useCustomOPDSStore.getState().addCatalog({
id: 'l1',
name: 'L1',
url: 'https://example.com/opds',
});
vi.clearAllMocks();
const removed = useCustomOPDSStore.getState().removeCatalog(cat.id);
expect(removed).toBe(true);
const stored = useCustomOPDSStore.getState().getCatalog(cat.id);
expect(stored?.deletedAt).toBeGreaterThan(0);
expect(publishReplicaDelete).toHaveBeenCalledWith('opds_catalog', cat.contentId);
});
test('returns false when id is unknown', () => {
expect(useCustomOPDSStore.getState().removeCatalog('nope')).toBe(false);
});
});
describe('applyRemoteCatalog', () => {
test('inserts when the contentId is unknown locally', () => {
const cat: OPDSCatalog = {
id: 'remote-cid',
contentId: 'remote-cid',
name: 'Remote',
url: 'https://remote.example/opds',
addedAt: 1700000000000,
};
useCustomOPDSStore.getState().applyRemoteCatalog(cat);
const stored = useCustomOPDSStore.getState().findByContentId('remote-cid');
expect(stored?.name).toBe('Remote');
expect(publishReplicaUpsert).not.toHaveBeenCalled();
});
test('preserves local username/password when overlaying a remote update', () => {
const local = useCustomOPDSStore.getState().addCatalog({
id: 'l1',
name: 'Local',
url: 'https://example.com/opds',
username: 'alice',
password: 'hunter2',
});
vi.clearAllMocks();
useCustomOPDSStore.getState().applyRemoteCatalog({
id: local.contentId!,
contentId: local.contentId,
name: 'Renamed remotely',
url: 'https://example.com/opds',
addedAt: 1700000001000,
});
const merged = useCustomOPDSStore.getState().findByContentId(local.contentId!);
expect(merged?.name).toBe('Renamed remotely');
expect(merged?.username).toBe('alice');
expect(merged?.password).toBe('hunter2');
expect(publishReplicaUpsert).not.toHaveBeenCalled();
});
});
describe('softDeleteByContentId', () => {
test('marks the matching entry as deleted without re-publishing', () => {
const cat = useCustomOPDSStore.getState().addCatalog({
id: 'l1',
name: 'L1',
url: 'https://example.com/opds',
});
vi.clearAllMocks();
useCustomOPDSStore.getState().softDeleteByContentId(cat.contentId!);
const stored = useCustomOPDSStore.getState().findByContentId(cat.contentId!);
expect(stored?.deletedAt).toBeGreaterThan(0);
expect(publishReplicaDelete).not.toHaveBeenCalled();
});
});
describe('saveCustomOPDSCatalogs', () => {
test('strips tombstoned entries from the persisted settings list', async () => {
const live = useCustomOPDSStore.getState().addCatalog({
id: 'l1',
name: 'Live',
url: 'https://example.com/opds',
});
const dead = useCustomOPDSStore.getState().addCatalog({
id: 'l2',
name: 'Dead',
url: 'https://other.example/opds',
});
useCustomOPDSStore.getState().removeCatalog(dead.id);
await useCustomOPDSStore.getState().saveCustomOPDSCatalogs(makeEnvConfig());
const persisted = useSettingsStore.getState().settings.opdsCatalogs!;
expect(persisted).toHaveLength(1);
expect(persisted[0]!.id).toBe(live.id);
});
});
describe('loadCustomOPDSCatalogs', () => {
test('backfills contentId on legacy entries and republishes them', async () => {
const legacy: OPDSCatalog = {
id: 'legacy-1',
name: 'Legacy',
url: 'https://legacy.example/opds',
};
useSettingsStore.setState({
settings: makeSettings({ opdsCatalogs: [legacy] }),
} as unknown as ReturnType<typeof useSettingsStore.getState>);
await useCustomOPDSStore.getState().loadCustomOPDSCatalogs(makeEnvConfig());
const inMemory = useCustomOPDSStore.getState().getCatalog('legacy-1')!;
expect(inMemory.contentId).toBe(computeOpdsCatalogContentId('https://legacy.example/opds'));
expect(publishReplicaUpsert).toHaveBeenCalledTimes(1);
});
test('preserves the existing array order via descending addedAt timestamps', async () => {
const legacy: OPDSCatalog[] = [
{ id: 'a', name: 'Alpha', url: 'https://a.example/opds' },
{ id: 'b', name: 'Bravo', url: 'https://b.example/opds' },
{ id: 'c', name: 'Charlie', url: 'https://c.example/opds' },
];
useSettingsStore.setState({
settings: makeSettings({ opdsCatalogs: legacy }),
} as unknown as ReturnType<typeof useSettingsStore.getState>);
await useCustomOPDSStore.getState().loadCustomOPDSCatalogs(makeEnvConfig());
const ordered = useCustomOPDSStore.getState().getAvailableCatalogs();
expect(ordered.map((c) => c.id)).toEqual(['a', 'b', 'c']);
// Strict descending — first entry strictly newer than next.
expect(ordered[0]!.addedAt!).toBeGreaterThan(ordered[1]!.addedAt!);
expect(ordered[1]!.addedAt!).toBeGreaterThan(ordered[2]!.addedAt!);
});
test('hydrates without backfilling when entries already carry contentId', async () => {
useSettingsStore.setState({
settings: makeSettings({
opdsCatalogs: [
{
id: 'a',
contentId: 'a',
name: 'A',
url: 'https://example.com/opds',
addedAt: 1700000000000,
},
],
}),
} as unknown as ReturnType<typeof useSettingsStore.getState>);
await useCustomOPDSStore.getState().loadCustomOPDSCatalogs(makeEnvConfig());
expect(publishReplicaUpsert).not.toHaveBeenCalled();
expect(useCustomOPDSStore.getState().catalogs).toHaveLength(1);
});
});
});
+4 -3
View File
@@ -117,9 +117,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { isTransferQueueOpen } = useTransferStore();
// Library page pulls user replicas (dictionaries, custom fonts,
// background textures). Deferred 10s; module-scoped dedup means a
// later navigation to the reader won't re-pull the same kind.
useReplicaPull({ kinds: ['dictionary', 'font', 'texture'] });
// background textures, OPDS catalogs). Deferred 10s; module-scoped
// dedup means a later navigation to the reader won't re-pull the
// same kind.
useReplicaPull({ kinds: ['dictionary', 'font', 'texture', 'opds_catalog'] });
const [showCatalogManager, setShowCatalogManager] = useState(
searchParams?.get('opds') === 'true',
);
@@ -16,9 +16,8 @@ import {
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { isWebAppPlatform } from '@/services/environment';
import { saveSysSettings } from '@/helpers/settings';
import { useCustomOPDSStore } from '@/store/customOPDSStore';
import { OPDSCatalog } from '@/types/opds';
import { isLanAddress } from '@/utils/network';
import { eventDispatcher } from '@/utils/event';
@@ -89,8 +88,15 @@ export function CatalogManager() {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const [catalogs, setCatalogs] = useState<OPDSCatalog[]>(() => settings.opdsCatalogs || []);
// Hydrate the store from settings on mount; all CRUD goes through it
// so the replica-sync push fires automatically. The local `catalogs`
// mirror tracks the visible (non-deleted) entries; we keep the
// setState wrapper so `useEffect` consumers (subscriptions) re-fire
// when the list changes.
const allCatalogs = useCustomOPDSStore((s) => s.catalogs);
const [catalogs, setCatalogs] = useState<OPDSCatalog[]>(() =>
useCustomOPDSStore.getState().getAvailableCatalogs(),
);
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingCatalogId, setEditingCatalogId] = useState<string | null>(null);
const [newCatalog, setNewCatalog] = useState(EMPTY_NEW_CATALOG);
@@ -131,9 +137,23 @@ export function CatalogManager() {
newCatalog.customHeadersInput.trim().length > 0;
const isWebCatalogProxyWarningRequired = isWebAppPlatform() && hasSensitiveWebOPDSInput;
const saveCatalogs = (updatedCatalogs: OPDSCatalog[]) => {
setCatalogs(updatedCatalogs);
saveSysSettings(envConfig, 'opdsCatalogs', updatedCatalogs);
// Hydrate from settings + persist when the store mutates. Loading
// happens once per mount; the store handles backfilling contentId
// for legacy entries.
useEffect(() => {
void useCustomOPDSStore.getState().loadCustomOPDSCatalogs(envConfig);
}, [envConfig]);
// Surface the latest store state into the local mirror used by
// subscriptions / dialog rendering. Filters out tombstones.
useEffect(() => {
setCatalogs(allCatalogs.filter((c) => !c.deletedAt));
}, [allCatalogs]);
// Persist via the store (settings + replica push), then update local
// mirror. Replica sync fan-out happens inside the store mutators.
const persistMutation = () => {
void useCustomOPDSStore.getState().saveCustomOPDSCatalogs(envConfig);
};
const handleAddCatalog = async () => {
@@ -187,24 +207,33 @@ export function CatalogManager() {
return;
}
const catalog: OPDSCatalog = {
id: editingCatalogId || Date.now().toString(),
name: newCatalog.name,
url: newCatalog.url,
description: newCatalog.description,
username: newCatalog.username || undefined,
password: newCatalog.password || undefined,
customHeaders: hasOPDSCustomHeaders(parsedHeaders.headers)
? parsedHeaders.headers
: undefined,
autoDownload: newCatalog.autoDownload || undefined,
};
const customHeaders = hasOPDSCustomHeaders(parsedHeaders.headers)
? parsedHeaders.headers
: undefined;
if (editingCatalogId) {
saveCatalogs(catalogs.map((c) => (c.id === editingCatalogId ? catalog : c)));
useCustomOPDSStore.getState().updateCatalog(editingCatalogId, {
name: newCatalog.name,
url: newCatalog.url,
description: newCatalog.description || undefined,
username: newCatalog.username || undefined,
password: newCatalog.password || undefined,
customHeaders,
autoDownload: newCatalog.autoDownload || undefined,
});
} else {
saveCatalogs([catalog, ...catalogs]);
useCustomOPDSStore.getState().addCatalog({
id: Date.now().toString(),
name: newCatalog.name,
url: newCatalog.url,
description: newCatalog.description || undefined,
username: newCatalog.username || undefined,
password: newCatalog.password || undefined,
customHeaders,
autoDownload: newCatalog.autoDownload || undefined,
});
}
persistMutation();
setNewCatalog(EMPTY_NEW_CATALOG);
setUrlError('');
@@ -234,12 +263,13 @@ export function CatalogManager() {
if (catalogs.some((c) => c.url === popularCatalog.url)) {
return;
}
saveCatalogs([...catalogs, { ...popularCatalog }]);
useCustomOPDSStore.getState().addCatalog({ ...popularCatalog });
persistMutation();
};
const handleRemoveCatalog = (id: string) => {
saveCatalogs(catalogs.filter((c) => c.id !== id));
useCustomOPDSStore.getState().removeCatalog(id);
persistMutation();
if (appService) {
// Don't await — leftover state files are harmless and we don't want to
// block UI removal if the filesystem call fails.
@@ -248,8 +278,11 @@ export function CatalogManager() {
};
const handleToggleAutoDownload = (id: string) => {
const wasEnabled = catalogs.find((c) => c.id === id)?.autoDownload;
saveCatalogs(catalogs.map((c) => (c.id === id ? { ...c, autoDownload: !c.autoDownload } : c)));
const target = catalogs.find((c) => c.id === id);
if (!target) return;
const wasEnabled = !!target.autoDownload;
useCustomOPDSStore.getState().updateCatalog(id, { autoDownload: !wasEnabled });
persistMutation();
// When the user just enabled auto-download, sync now instead of waiting
// for the next app launch / pull-to-refresh.
if (!wasEnabled) {
+29 -4
View File
@@ -11,11 +11,13 @@ import {
findTextureByContentId,
migrateLegacyTextures,
} from '@/store/customTextureStore';
import { useCustomOPDSStore, findOPDSCatalogByContentId } from '@/store/customOPDSStore';
import { transferManager } from '@/services/transferManager';
import { getReplicaSync, subscribeReplicaSyncReady } from '@/services/sync/replicaSync';
import { dictionaryAdapter } from '@/services/sync/adapters/dictionary';
import { fontAdapter } from '@/services/sync/adapters/font';
import { textureAdapter } from '@/services/sync/adapters/texture';
import { opdsCatalogAdapter } from '@/services/sync/adapters/opdsCatalog';
import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import {
replicaPullAndApply,
@@ -31,8 +33,9 @@ import type { ReplicaSyncManager } from '@/services/sync/replicaSyncManager';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { CustomFont } from '@/styles/fonts';
import type { CustomTexture } from '@/styles/textures';
import type { OPDSCatalog } from '@/types/opds';
export type ReplicaKind = 'dictionary' | 'font' | 'texture';
export type ReplicaKind = 'dictionary' | 'font' | 'texture' | 'opds_catalog';
export interface UseReplicaPullOpts {
/** Replica kinds this page wants pulled. */
@@ -60,7 +63,8 @@ const pulledKinds = new Set<ReplicaKind>();
*/
interface ReplicaPullConfig<T extends ReplicaLocalRecord> {
kind: ReplicaKind;
baseDir: BaseDir;
/** Required for binary-bearing kinds; omitted for metadata-only kinds. */
baseDir?: BaseDir;
adapter: ReplicaAdapter<T>;
findByContentId: (id: string) => T | undefined;
hydrateLocalStore?: (envConfig: EnvConfigType) => Promise<void>;
@@ -85,16 +89,22 @@ const buildReplicaPullDeps = <T extends ReplicaLocalRecord>(
: undefined,
applyRemote: config.applyRemote,
softDeleteByContentId: config.softDeleteByContentId,
// The bundle / binary callbacks below are only reached when the
// adapter declares a `binary` capability — replicaPullAndApply
// short-circuits metadata-only kinds before invoking them. The
// non-null assertion on baseDir is therefore safe in the binary
// path; metadata-only kinds (opds_catalog) leave config.baseDir
// unset and never hit these.
createBundleDir: async () => {
const id = uniqueId();
await service.createDir(id, config.baseDir, true);
await service.createDir(id, config.baseDir!, true);
return id;
},
queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) =>
transferManager.queueReplicaDownload(config.kind, contentId, displayTitle, files, base),
filesExist: async (bundleDir, filenames) => {
for (const filename of filenames) {
const exists = await service.exists(`${bundleDir}/${filename}`, config.baseDir);
const exists = await service.exists(`${bundleDir}/${filename}`, config.baseDir!);
if (!exists) return false;
}
return true;
@@ -153,6 +163,16 @@ const texturePullConfig: ReplicaPullConfig<CustomTexture> = {
softDeleteByContentId: (id) => useCustomTextureStore.getState().softDeleteByContentId(id),
};
const opdsCatalogPullConfig: ReplicaPullConfig<OPDSCatalog> = {
kind: 'opds_catalog',
// metadata-only — no baseDir
adapter: opdsCatalogAdapter,
findByContentId: findOPDSCatalogByContentId,
hydrateLocalStore: (envConfig) => useCustomOPDSStore.getState().loadCustomOPDSCatalogs(envConfig),
applyRemote: (catalog) => useCustomOPDSStore.getState().applyRemoteCatalog(catalog),
softDeleteByContentId: (id) => useCustomOPDSStore.getState().softDeleteByContentId(id),
};
const runPullForKind = async (
kind: ReplicaKind,
service: AppService,
@@ -179,6 +199,11 @@ const runPullForKind = async (
buildReplicaPullDeps(ctx.manager, service, envConfig, texturePullConfig),
);
return;
case 'opds_catalog':
await replicaPullAndApply(
buildReplicaPullDeps(ctx.manager, service, envConfig, opdsCatalogPullConfig),
);
return;
}
};
@@ -52,6 +52,19 @@ const textureFieldsSchema = z
})
.catchall(fieldEnvelopeWithCipher);
const opdsCatalogFieldsSchema = z
.object({
name: fieldEnvelopeSchema.optional(),
url: fieldEnvelopeSchema.optional(),
description: fieldEnvelopeSchema.optional(),
icon: fieldEnvelopeSchema.optional(),
customHeaders: fieldEnvelopeSchema.optional(),
autoDownload: fieldEnvelopeSchema.optional(),
disabled: fieldEnvelopeSchema.optional(),
addedAt: fieldEnvelopeSchema.optional(),
})
.catchall(fieldEnvelopeWithCipher);
interface KindSpec {
minSchemaVersion: number;
maxSchemaVersion: number;
@@ -82,6 +95,13 @@ export const KIND_ALLOWLIST: Record<string, KindSpec> = {
fields: textureFieldsSchema,
binary: true,
},
opds_catalog: {
minSchemaVersion: 1,
maxSchemaVersion: 1,
maxRowsPerUser: 50,
fields: opdsCatalogFieldsSchema,
binary: false,
},
};
export const isAllowedKind = (kind: string): boolean => Object.hasOwn(KIND_ALLOWLIST, kind);
@@ -0,0 +1,116 @@
import { md5 } from '@/utils/md5';
import type { OPDSCatalog } from '@/types/opds';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
import type { FieldsObject, ReplicaRow } from '@/types/replica';
import { defaultComputeId, unwrap } from './helpers';
export const OPDS_CATALOG_KIND = 'opds_catalog';
export const OPDS_CATALOG_SCHEMA_VERSION = 1;
interface UnwrappedOpdsFields {
name?: string;
url?: string;
description?: string;
icon?: string;
customHeaders?: Record<string, string>;
autoDownload?: boolean;
disabled?: boolean;
addedAt?: number;
}
const unwrapOpdsFields = (fields: FieldsObject): UnwrappedOpdsFields => {
const name = unwrap(fields['name']);
const url = unwrap(fields['url']);
const description = unwrap(fields['description']);
const icon = unwrap(fields['icon']);
const customHeaders = unwrap(fields['customHeaders']);
const autoDownload = unwrap(fields['autoDownload']);
const disabled = unwrap(fields['disabled']);
const addedAt = unwrap(fields['addedAt']);
return {
name: typeof name === 'string' ? name : undefined,
url: typeof url === 'string' ? url : undefined,
description: typeof description === 'string' ? description : undefined,
icon: typeof icon === 'string' ? icon : undefined,
customHeaders:
customHeaders && typeof customHeaders === 'object' && !Array.isArray(customHeaders)
? (customHeaders as Record<string, string>)
: undefined,
autoDownload: autoDownload === true ? true : undefined,
disabled: disabled === true ? true : undefined,
addedAt: typeof addedAt === 'number' ? addedAt : undefined,
};
};
/**
* Stable cross-device identity for an OPDS catalog. Two devices that import
* the same URL converge to a single replica row instead of duplicating.
* URL is normalized (trim + lower-case) so trailing-slash and case
* differences don't fragment identity. Username/password are intentionally
* excluded — encrypted-credential sync is in a follow-up PR; including
* username here would couple identity to a field that may not yet sync.
*/
export const computeOpdsCatalogContentId = (url: string): string =>
md5(`opds:${url.trim().toLowerCase()}`);
export const opdsCatalogAdapter: ReplicaAdapter<OPDSCatalog> = {
kind: OPDS_CATALOG_KIND,
schemaVersion: OPDS_CATALOG_SCHEMA_VERSION,
pack(catalog: OPDSCatalog): Record<string, unknown> {
const fields: Record<string, unknown> = {
name: catalog.name,
url: catalog.url,
addedAt: catalog.addedAt ?? Date.now(),
};
if (catalog.description !== undefined) fields['description'] = catalog.description;
if (catalog.icon !== undefined) fields['icon'] = catalog.icon;
if (catalog.customHeaders !== undefined) fields['customHeaders'] = catalog.customHeaders;
if (catalog.autoDownload !== undefined) fields['autoDownload'] = catalog.autoDownload;
if (catalog.disabled !== undefined) fields['disabled'] = catalog.disabled;
return fields;
},
unpack(fields: Record<string, unknown>): OPDSCatalog {
return {
id: '',
name: String(fields['name'] ?? ''),
url: String(fields['url'] ?? ''),
description: fields['description'] !== undefined ? String(fields['description']) : undefined,
icon: fields['icon'] !== undefined ? String(fields['icon']) : undefined,
customHeaders:
fields['customHeaders'] && typeof fields['customHeaders'] === 'object'
? (fields['customHeaders'] as Record<string, string>)
: undefined,
autoDownload: fields['autoDownload'] === true ? true : undefined,
disabled: fields['disabled'] === true ? true : undefined,
addedAt: fields['addedAt'] !== undefined ? Number(fields['addedAt']) : undefined,
};
},
computeId: defaultComputeId,
unpackRow(row: ReplicaRow): OPDSCatalog | null {
const fields = unwrapOpdsFields(row.fields_jsonb);
if (!fields.name || !fields.url) return null;
const catalog: OPDSCatalog = {
// OPDS catalogs use contentId as their local id — they have no
// "bundle dir" pointer to disambiguate, and the URL-derived
// contentId is already a stable cross-device identifier.
id: row.replica_id,
contentId: row.replica_id,
name: fields.name,
url: fields.url,
};
if (fields.description !== undefined) catalog.description = fields.description;
if (fields.icon !== undefined) catalog.icon = fields.icon;
if (fields.customHeaders !== undefined) catalog.customHeaders = fields.customHeaders;
if (fields.autoDownload !== undefined) catalog.autoDownload = fields.autoDownload;
if (fields.disabled !== undefined) catalog.disabled = fields.disabled;
if (fields.addedAt !== undefined) catalog.addedAt = fields.addedAt;
if (row.reincarnation) catalog.reincarnation = row.reincarnation;
return catalog;
},
// No `binary` capability — opds_catalog is metadata-only.
};
@@ -4,6 +4,7 @@ import { useCustomTextureStore } from '@/store/customTextureStore';
import { dictionaryAdapter, DICTIONARY_KIND } from './adapters/dictionary';
import { fontAdapter, FONT_KIND } from './adapters/font';
import { textureAdapter, TEXTURE_KIND } from './adapters/texture';
import { opdsCatalogAdapter } from './adapters/opdsCatalog';
import { getReplicaPersistEnv } from './replicaPersist';
import { getReplicaAdapter, registerReplicaAdapter } from './replicaRegistry';
import { registerReplicaDownloadHandler } from './replicaTransferIntegration';
@@ -13,6 +14,8 @@ const KNOWN_ADAPTERS: ReplicaAdapter<unknown>[] = [
dictionaryAdapter as unknown as ReplicaAdapter<unknown>,
fontAdapter as unknown as ReplicaAdapter<unknown>,
textureAdapter as unknown as ReplicaAdapter<unknown>,
// Metadata-only — no binary download handler needed.
opdsCatalogAdapter as unknown as ReplicaAdapter<unknown>,
];
let didBootstrap = false;
@@ -109,22 +109,29 @@ const applyRow = async <T extends ReplicaLocalRecord>(
// Decide bundleDir + display name. If a local entry already maps this
// contentId, reuse its bundleDir so we don't orphan the previously
// downloaded binaries; otherwise mint a fresh dir and apply the remote
// record to the local store. Legacy local records (pre-replica-sync)
// may carry no bundleDir — skip them; they aren't sync-eligible.
// record to the local store. Legacy binary-kind records (pre-replica-
// sync) may carry no bundleDir — skip them; they aren't sync-eligible.
// Metadata-only kinds (no `binary` capability, e.g. opds_catalog) have
// no on-disk anchor at all, so the bundleDir requirement is dropped.
const needsBundleDir = !!deps.adapter.binary;
let bundleDir: string;
let displayName: string;
if (local) {
if (!local.bundleDir) return;
bundleDir = local.bundleDir;
if (needsBundleDir && !local.bundleDir) return;
bundleDir = local.bundleDir ?? '';
displayName = deps.adapter.getDisplayName?.(local) ?? local.name;
} else {
bundleDir = await deps.createBundleDir();
bundleDir = needsBundleDir ? await deps.createBundleDir() : '';
const record = deps.adapter.unpackRow(row, bundleDir);
if (!record) return;
deps.applyRemote(record);
displayName = deps.adapter.getDisplayName?.(record) ?? record.name;
}
// Metadata-only kinds: nothing more to do. The orchestrator's manifest
// / binary-download path is a no-op for them.
if (!needsBundleDir) return;
if (!row.manifest_jsonb || row.manifest_jsonb.files.length === 0) {
// Server row has no manifest yet — typically the device that
// wrote the metadata never finished the binary upload (TM wasn't
@@ -0,0 +1,250 @@
import { create } from 'zustand';
import type { EnvConfigType } from '@/services/environment';
import type { OPDSCatalog } from '@/types/opds';
import { useSettingsStore } from './settingsStore';
import { getReplicaPersistEnv } from '@/services/sync/replicaPersist';
import { publishReplicaDelete, publishReplicaUpsert } from '@/services/sync/replicaPublish';
import {
computeOpdsCatalogContentId,
OPDS_CATALOG_KIND,
} from '@/services/sync/adapters/opdsCatalog';
const publishOpdsUpsert = (catalog: OPDSCatalog): void => {
if (!catalog.contentId) return;
void publishReplicaUpsert(OPDS_CATALOG_KIND, catalog, catalog.contentId, catalog.reincarnation);
};
const publishOpdsDelete = (contentId: string): void => {
void publishReplicaDelete(OPDS_CATALOG_KIND, contentId);
};
/**
* Backfill `contentId` (and `addedAt`) on legacy catalogs that predate
* replica sync. Returns the same array reference if no changes were
* required so callers can cheaply detect a no-op.
*
* `addedAt` is assigned per array index so the existing display order
* survives the migration: index 0 (newest in the legacy array) gets
* the largest timestamp, index N gets the smallest. The total span is
* tiny (≤ N ms) so newly-imported catalogs (with `Date.now()`) still
* sort above the migrated set, which matches the legacy "prepend new
* entries" UX.
*/
const backfillSyncFields = (catalogs: OPDSCatalog[]): OPDSCatalog[] => {
let mutated = false;
const baseTime = Date.now();
const next = catalogs.map((c, i) => {
if (c.contentId && c.addedAt !== undefined) return c;
mutated = true;
return {
...c,
contentId: c.contentId ?? computeOpdsCatalogContentId(c.url),
addedAt: c.addedAt ?? baseTime - i,
};
});
return mutated ? next : catalogs;
};
interface OPDSStoreState {
catalogs: OPDSCatalog[];
loading: boolean;
/** Visible catalogs sorted by `addedAt` descending (newest first). */
getAvailableCatalogs(): OPDSCatalog[];
getCatalog(id: string): OPDSCatalog | undefined;
/** Look up by URL — used for popular-catalog dedup (independent of contentId). */
findByUrl(url: string): OPDSCatalog | undefined;
/** Look up by stable cross-device content id. */
findByContentId(contentId: string): OPDSCatalog | undefined;
/**
* Add (or revive) a catalog. Computes `contentId` from URL if absent.
* Re-import of a previously soft-deleted entry mints a reincarnation
* token so the server-side tombstone is replaced with a fresh row.
*/
addCatalog(catalog: Omit<OPDSCatalog, 'contentId'> & { contentId?: string }): OPDSCatalog;
/**
* Patch a catalog's mutable fields. Only the patched fields are
* republished — credentials (username/password) are NOT in the
* synced field set yet, so editing them stays local-only until the
* encrypted-field PR lands.
*/
updateCatalog(id: string, patch: Partial<OPDSCatalog>): OPDSCatalog | undefined;
/** Soft-delete by id; pushes a tombstone if the entry has a contentId. */
removeCatalog(id: string): boolean;
/**
* Add a catalog received via replica sync from another device. Same
* effect on local state as addCatalog, but does NOT republish.
*/
applyRemoteCatalog(catalog: OPDSCatalog): void;
/** Mirror a server-side tombstone locally without re-publishing. */
softDeleteByContentId(contentId: string): void;
/** Hydrate from `settings.opdsCatalogs`. Backfills sync fields if needed. */
loadCustomOPDSCatalogs(envConfig: EnvConfigType): Promise<void>;
/** Persist current state back into settings. */
saveCustomOPDSCatalogs(envConfig: EnvConfigType): Promise<void>;
}
export const useCustomOPDSStore = create<OPDSStoreState>((set, get) => ({
catalogs: [],
loading: false,
getAvailableCatalogs: () =>
get()
.catalogs.filter((c) => !c.deletedAt)
.sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0)),
getCatalog: (id) => get().catalogs.find((c) => c.id === id),
findByUrl: (url) => {
const normalized = url.trim().toLowerCase();
return get().catalogs.find((c) => c.url.trim().toLowerCase() === normalized && !c.deletedAt);
},
findByContentId: (contentId) =>
contentId ? get().catalogs.find((c) => c.contentId === contentId) : undefined,
addCatalog: (input) => {
const contentId = input.contentId ?? computeOpdsCatalogContentId(input.url);
// Revive tombstoned entry under a reincarnation token so the
// server-side tombstone gets replaced rather than stuck.
const existing = get().catalogs.find((c) => c.contentId === contentId);
const reincarnation =
existing?.deletedAt && !input.reincarnation
? Math.random().toString(36).slice(2)
: input.reincarnation;
const catalog: OPDSCatalog = {
...input,
contentId,
addedAt: input.addedAt ?? existing?.addedAt ?? Date.now(),
deletedAt: undefined,
reincarnation,
};
set((state) => {
const idx = state.catalogs.findIndex((c) => c.contentId === contentId);
const catalogs =
idx >= 0
? state.catalogs.map((c, i) => (i === idx ? catalog : c))
: [...state.catalogs, catalog];
return { catalogs };
});
publishOpdsUpsert(catalog);
return catalog;
},
updateCatalog: (id, patch) => {
let updated: OPDSCatalog | undefined;
set((state) => {
const idx = state.catalogs.findIndex((c) => c.id === id);
if (idx < 0) return state;
const old = state.catalogs[idx]!;
if (old.deletedAt) return state;
updated = { ...old, ...patch };
// Recompute contentId only if the URL itself changed; otherwise
// preserve the existing one so we keep the same server row.
if (patch.url && patch.url !== old.url) {
updated.contentId = computeOpdsCatalogContentId(patch.url);
}
return {
catalogs: state.catalogs.map((c, i) => (i === idx ? updated! : c)),
};
});
if (updated) publishOpdsUpsert(updated);
return updated;
},
removeCatalog: (id) => {
const catalog = get().catalogs.find((c) => c.id === id);
if (!catalog) return false;
set((state) => ({
catalogs: state.catalogs.map((c) => (c.id === id ? { ...c, deletedAt: Date.now() } : c)),
}));
if (catalog.contentId) publishOpdsDelete(catalog.contentId);
return true;
},
applyRemoteCatalog: (catalog) => {
set((state) => {
const idx = state.catalogs.findIndex((c) => c.contentId === catalog.contentId);
if (idx >= 0) {
// Preserve local-only fields (username/password — not yet in the
// synced field set) when overlaying a remote update.
const old = state.catalogs[idx]!;
const merged: OPDSCatalog = {
...catalog,
username: old.username,
password: old.password,
deletedAt: undefined,
};
return { catalogs: state.catalogs.map((c, i) => (i === idx ? merged : c)) };
}
return { catalogs: [...state.catalogs, catalog] };
});
const env = getReplicaPersistEnv();
if (env) void get().saveCustomOPDSCatalogs(env);
},
softDeleteByContentId: (contentId) => {
const target = get().catalogs.find((c) => c.contentId === contentId && !c.deletedAt);
if (!target) return;
set((state) => ({
catalogs: state.catalogs.map((c) =>
c.id === target.id ? { ...c, deletedAt: Date.now() } : c,
),
}));
const env = getReplicaPersistEnv();
if (env) void get().saveCustomOPDSCatalogs(env);
},
loadCustomOPDSCatalogs: async (_envConfig) => {
try {
const { settings } = useSettingsStore.getState();
const persisted = settings?.opdsCatalogs ?? [];
const backfilled = backfillSyncFields(persisted);
set({ catalogs: backfilled });
// If backfill mutated anything, persist + publish the fresh
// contentIds so existing catalogs start syncing on next push.
if (backfilled !== persisted) {
await get().saveCustomOPDSCatalogs(_envConfig);
for (const c of backfilled) {
if (c.contentId && !c.deletedAt) publishOpdsUpsert(c);
}
}
} catch (error) {
console.error('Failed to load OPDS catalogs:', error);
}
},
saveCustomOPDSCatalogs: async (_envConfig) => {
try {
const { settings, setSettings, saveSettings } = useSettingsStore.getState();
const { catalogs } = get();
// Tombstoned entries stay in memory so the orchestrator can detect
// re-import / reincarnation, but they're stripped at the
// persistence boundary. The next pull will mirror server-side
// tombstones back into memory if the row is still deleted.
settings.opdsCatalogs = catalogs.filter((c) => !c.deletedAt);
setSettings(settings);
saveSettings(_envConfig, settings);
} catch (error) {
console.error('Failed to save OPDS catalogs:', error);
throw error;
}
},
}));
/**
* Look up an OPDS catalog by its cross-device contentId, falling back to
* the persisted settings when the in-memory store is empty. The pull-side
* orchestrator runs before the OPDS page mounts; without the fallback
* every refresh would treat existing catalogs as new and double up.
*/
export const findOPDSCatalogByContentId = (contentId: string): OPDSCatalog | undefined => {
if (!contentId) return undefined;
const inMemory = useCustomOPDSStore.getState().findByContentId(contentId);
if (inMemory) return inMemory;
const persisted = useSettingsStore.getState().settings?.opdsCatalogs ?? [];
return persisted.find((c) => c.contentId === contentId && !c.deletedAt);
};
+13
View File
@@ -30,6 +30,19 @@ export interface OPDSCatalog {
password?: string;
customHeaders?: Record<string, string>;
autoDownload?: boolean;
/**
* Stable cross-device identifier derived from the URL. Used as the
* replica_id so two devices that import the same OPDS URL converge to a
* single row instead of duplicating. Absent on legacy entries imported
* before replica sync shipped — they get backfilled at next save.
*/
contentId?: string;
/** Wall-clock ms of first import, used for ordering. */
addedAt?: number;
/** Soft-delete timestamp; non-null entries are hidden from the UI. */
deletedAt?: number;
/** Reincarnation token (re-import after server tombstone). */
reincarnation?: string;
}
export interface OPDSFeed {
@@ -0,0 +1,11 @@
-- Migration 009: Extend the replicas.kind allowlist with 'opds_catalog'.
-- The DB CHECK is belt-and-suspenders; src/libs/replicaSchemas.ts
-- (KIND_ALLOWLIST) is the actual gate that decides which kinds the
-- server accepts on push.
ALTER TABLE public.replicas
DROP CONSTRAINT IF EXISTS replicas_kind_allowlist;
ALTER TABLE public.replicas
ADD CONSTRAINT replicas_kind_allowlist
CHECK (kind IN ('dictionary', 'font', 'texture', 'opds_catalog'));