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