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