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
+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;
}
};