From b955bdb3a3f603ad47dd6e43b4e7f97f81feadf8 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Thu, 23 Apr 2026 12:24:22 -0700 Subject: [PATCH 01/65] docs: spec filesystem cloud provider Add design doc for a File System Access API backed sync provider. Feature-gated on Chromium browsers; handle persisted in a dedicated IndexedDB database owned by the provider module. --- .../2026-04-23-filesystem-provider-design.md | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-23-filesystem-provider-design.md diff --git a/docs/superpowers/specs/2026-04-23-filesystem-provider-design.md b/docs/superpowers/specs/2026-04-23-filesystem-provider-design.md new file mode 100644 index 00000000..0b4c811f --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-filesystem-provider-design.md @@ -0,0 +1,138 @@ +# Filesystem Cloud Provider — Design + +## Summary + +Add a new cloud sync provider that uses the browser **File System Access API** to treat a local directory handle as a "cloud" endpoint. Users pick a folder once; subsequent sessions reuse the handle (permission permitting). Provider is hidden on browsers that don't support the API. + +## Goals + +- Offline, account-less backup target that fits the existing `SyncProvider` contract +- Zero changes to sync orchestration (`unified-sync-service`, `backup-queue`, workers) beyond wiring up the new type +- No read-only mode — the provider is either fully connected with read/write, or not connected at all +- No dependence on the main Dexie database — all filesystem-provider state lives in a dedicated IndexedDB database owned by the provider module + +## Non-goals + +- Worker-pool offload. The provider runs entirely on the main thread (`supportsWorkerDownload = false`). Local disk throughput does not benefit from worker parallelism here and it avoids structured-cloning `FileSystemDirectoryHandle` instances across postMessage. +- Syncing to arbitrary filesystem locations across the web; one handle per session, owned by the origin. +- Cross-origin use — origin-bound by API design. + +## Browser support + +The File System Access API ships in Chromium-based browsers (Chrome, Edge, Opera, Arc, Brave). Firefox and Safari expose neither `showDirectoryPicker` nor `FileSystemDirectoryHandle`. + +Feature detection: `typeof window !== 'undefined' && 'showDirectoryPicker' in window`. + +When unsupported: +- `CloudView.svelte` does not render the "Local Folder" option +- `loadProvider('filesystem')` throws with a clear message (defensive — should be unreachable when UI is gated) +- `provider-detection.ts` does not return `'filesystem'` from legacy detection (unsupported browsers can't have previously registered a handle) + +## Authentication model + +There is no credential string. "Login" = hold a valid `FileSystemDirectoryHandle` with `'granted'` readwrite permission. + +### Login flow +1. User clicks "Connect Local Folder" in `CloudView` +2. Provider calls `window.showDirectoryPicker({ mode: 'readwrite' })` +3. On success, provider calls `handle.requestPermission({ mode: 'readwrite' })` to confirm readwrite — browsers sometimes grant the handle but not yet the permission +4. If permission is `'granted'`: persist the handle to IDB, set `active_cloud_provider = 'filesystem'`, mark authenticated +5. If permission is anything else, or the picker is cancelled: throw — no partial/read-only state is stored + +### Session restore +On app startup with `active_cloud_provider === 'filesystem'`: +1. Open provider's IDB database, read the stored handle +2. Call `handle.queryPermission({ mode: 'readwrite' })` +3. If `'granted'`: mark authenticated, done +4. If `'prompt'`: leave provider in "configured but not connected" state. `CloudView` shows a **Reconnect folder** button that calls `handle.requestPermission({ mode: 'readwrite' })` inside the user-gesture handler. On grant → authenticated. On deny → treat as logout. +5. If `'denied'` or handle is missing/invalid: clear stored handle and `active_cloud_provider`, surface as logged-out + +### Logout +- Delete handle from IDB +- Clear `active_cloud_provider` +- Null out in-memory state + +## File layout + +``` +{pickedRoot}/ + volume-data.json + profiles.json + {SeriesTitle}/ + {VolumeTitle}.cbz + {VolumeTitle}.mokuro.gz (sidecar) + {VolumeTitle}.webp (thumbnail sidecar) +``` + +- No nested `mokuro-reader/` subfolder — the user already explicitly picked this directory. The provider treats the picked handle as the root. +- Sidecar filtering on list matches the WebDAV provider: include `.cbz`, `.mokuro`, `.mokuro.gz`, `.webp`, plus the two JSON config files at root. + +## `SyncProvider` contract + +| Field / method | Value | +|---|---| +| `type` | `'filesystem'` | +| `name` | `'Local Folder'` | +| `supportsWorkerDownload` | `false` | +| `uploadConcurrencyLimit` | `4` (main-thread bound; same order as MEGA) | +| `downloadConcurrencyLimit` | `4` | +| `isAuthenticated()` | `rootHandle !== null` | +| `getStatus()` | reports `isAuthenticated`, `hasStoredCredentials` (handle present in IDB), `isReadOnly` omitted | +| `login(credentials?)` | ignores credentials; triggers picker (credentials param exists for interface conformance) | +| `logout()` | delete IDB handle, null state | +| `listCloudVolumes()` | recursive walk via `directoryHandle.values()` | +| `uploadFile(path, blob)` | traverse / create subdirs, `getFileHandle(name, { create: true })`, write via writable stream | +| `downloadFile(file)` | resolve stored file handle (or re-resolve from path), `handle.getFile()` → Blob | +| `deleteFile(file)` | parent `.removeEntry(name)` | +| `renameFile` | copy + delete (no native rename in API) | +| `renameFolder` | recursive copy + recursive remove | +| `deleteSeriesFolder` | `rootHandle.removeEntry(seriesTitle, { recursive: true })` | +| `getStorageQuota()` | `navigator.storage.estimate()` — origin quota, not disk free | +| `getWorkerUploadCredentials` / `getWorkerDownloadCredentials` | not implemented (worker download disabled) | + +`CloudFileMetadata.fileId` for this provider: the POSIX-style path relative to the picked root (e.g. `"SeriesTitle/Volume.cbz"`). The path is used to re-resolve file handles on demand; we don't cache live `FileSystemFileHandle`s in the metadata because they can become invalid if the user swaps the handle. + +### Storage quota caveat + +`navigator.storage.estimate()` reports the origin's persistent storage quota (typically a percentage of free disk, not the full disk). It does not reflect free space on the actual device. Document this in the provider info blurb so users don't expect "20 GB free" to mean their SSD has 20 GB. + +## Files to add + +``` +src/lib/util/sync/providers/filesystem/ + filesystem-provider.ts # implements SyncProvider + filesystem-cache.ts # Map, mirrors webdav-cache + handle-store.ts # Dedicated IDB: open('mokuro-filesystem-provider', 1), one object store 'handles', single row keyed 'root' + feature-detect.ts # isFilesystemProviderSupported() +``` + +All IDB interaction for this provider lives in `handle-store.ts`. The main Dexie database is untouched. The file exposes: +- `saveRootHandle(handle: FileSystemDirectoryHandle): Promise` +- `loadRootHandle(): Promise` +- `clearRootHandle(): Promise` + +## Files to modify + +| File | Change | +|---|---| +| `src/lib/util/sync/provider-interface.ts` | Add `'filesystem'` to `ProviderType` union; add `isRealProvider` branch; add `FilesystemFileMetadata extends CloudFileMetadata` (no extra fields needed beyond base, but declared for discriminated-union completeness) | +| `src/lib/util/sync/provider-detection.ts` | Add `'filesystem'` to union guard in `getActiveProviderKey()`; no legacy detection branch (new provider, no migration path) | +| `src/lib/util/sync/provider-manager.ts` | Add `'filesystem': null` to status store's `providers` record in constructor and `updateStatus()` | +| `src/lib/util/sync/init-providers.ts` | New case in `loadProvider()` (dynamic import, feature-gated at call site); restore-credentials behavior matches MEGA/WebDAV (`await provider.whenReady()`) | +| `src/lib/views/CloudView.svelte` | New "Local Folder" button, gated on `isFilesystemProviderSupported()`; entries in `providerNames` and `providerInfo`; derived `filesystemAuth`; connected-state messaging; reconnect button when status is "configured but not connected" | + +## Implementation detail: recursive listing + +Unlike WebDAV (`Depth: infinity`), File System Access API requires manual recursion via `for await (const entry of handle.values())`. The provider exposes a single recursive walker that yields file metadata for any matching entry. Cache is repopulated from scratch each `fetch()` — no incremental invalidation for v1. + +## Testing + +- Unit tests for path parsing and layout helpers (pure functions, no API mocking needed) +- Manual browser testing: Chromium (happy path + revoked permission + picker cancelled); Firefox (verify button hidden); Safari (verify button hidden) +- Regression: ensure switching between filesystem ↔ WebDAV ↔ Google Drive still triggers correct logout of the previous provider + +## Out of scope for this spec + +- Syncing multiple folders simultaneously +- File watching / external-change detection (user edits files outside the app): cache is refreshed on next manual sync, matching existing providers +- Migration from WebDAV/MEGA to filesystem From 91bf41a931c0346389a0a907c1b76b402a870eef Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Thu, 23 Apr 2026 12:32:14 -0700 Subject: [PATCH 02/65] docs: plan filesystem cloud provider implementation --- .../plans/2026-04-23-filesystem-provider.md | 1750 +++++++++++++++++ 1 file changed, 1750 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-filesystem-provider.md diff --git a/docs/superpowers/plans/2026-04-23-filesystem-provider.md b/docs/superpowers/plans/2026-04-23-filesystem-provider.md new file mode 100644 index 00000000..5f83a032 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-filesystem-provider.md @@ -0,0 +1,1750 @@ +# Filesystem Cloud Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new `ProviderType = 'filesystem'` cloud sync provider that uses the File System Access API to back up and sync volumes to a user-chosen local directory, feature-gated on Chromium browsers. + +**Architecture:** A new provider module under `src/lib/util/sync/providers/filesystem/` implements the existing `SyncProvider` interface. The picked `FileSystemDirectoryHandle` is persisted to a **dedicated IndexedDB database** owned by `handle-store.ts` — the main Dexie database is untouched. Existing sync orchestration (`unified-sync-service`, `backup-queue`, `cache-manager`) does not branch on this provider — it wires in through the same extension points as MEGA/WebDAV. + +**Tech Stack:** SvelteKit 5, TypeScript, File System Access API (`window.showDirectoryPicker`), native IndexedDB (no Dexie here — isolated DB per spec), Vitest, Flowbite Svelte. + +--- + +## File Structure + +### Create +- `src/lib/util/sync/providers/filesystem/feature-detect.ts` — tiny pure function `isFilesystemProviderSupported()` +- `src/lib/util/sync/providers/filesystem/handle-store.ts` — dedicated IDB (`mokuro-filesystem-provider`, store `handles`, row key `'root'`), exposes `saveRootHandle` / `loadRootHandle` / `clearRootHandle` +- `src/lib/util/sync/providers/filesystem/filesystem-paths.ts` — pure path helpers (split path into segments, file-pattern matching for `.cbz` / `.mokuro` / `.mokuro.gz` / `.webp` / `volume-data.json` / `profiles.json`) +- `src/lib/util/sync/providers/filesystem/filesystem-provider.ts` — the `SyncProvider` implementation +- `src/lib/util/sync/providers/filesystem/filesystem-cache.ts` — `CloudCache` wrapper, mirrors `webdav-cache.ts` +- `src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts` — unit tests for path helpers +- `src/lib/util/sync/providers/filesystem/__tests__/feature-detect.test.ts` — unit tests for feature detection +- `src/lib/util/sync/providers/filesystem/__tests__/handle-store.test.ts` — integration tests using `fake-indexeddb` + +### Modify +- `src/lib/util/sync/provider-interface.ts` — extend `ProviderType`, add `FilesystemFileMetadata`, add `AnyCloudFileMetadata` union entry, extend `isRealProvider` +- `src/lib/util/sync/provider-detection.ts` — extend `'filesystem'` into `getActiveProviderKey`'s type guard +- `src/lib/util/sync/provider-manager.ts` — add `'filesystem': null` to status-store provider records (2 spots) +- `src/lib/util/sync/init-providers.ts` — add `'filesystem'` case in `loadProvider` (lazy import); extend whenReady branch to cover filesystem +- `src/lib/views/CloudView.svelte` — add provider button gated on `isFilesystemProviderSupported()`, add `filesystemAuth` derived, add `providerNames` + `providerInfo` entries, add login/reconnect/logout handlers, connected-state rendering +- `src/lib/components/BackupButton.svelte`, `src/lib/components/PlaceholderVolumeItem.svelte`, `src/lib/components/VolumeItem.svelte`, `src/lib/components/NavBar.svelte`, `src/lib/components/Catalog.svelte`, `src/lib/views/SeriesView.svelte` — _only_ if they pattern-match on specific provider literals in ways that would break with the new value; Task 11 inspects and touches them as needed. + +--- + +## Task 1: Add `'filesystem'` to `ProviderType` union and related types + +**Files:** +- Modify: `src/lib/util/sync/provider-interface.ts` + +- [ ] **Step 1: Extend `ProviderType` and `isRealProvider`** + +Open `src/lib/util/sync/provider-interface.ts`. Replace line 8: + +```typescript +export type ProviderType = 'google-drive' | 'mega' | 'webdav'; +``` + +with: + +```typescript +export type ProviderType = 'google-drive' | 'mega' | 'webdav' | 'filesystem'; +``` + +And on line 27-29, replace `isRealProvider`: + +```typescript +export function isRealProvider(provider: BackupProviderType): provider is ProviderType { + return ( + provider === 'google-drive' || + provider === 'mega' || + provider === 'webdav' || + provider === 'filesystem' + ); +} +``` + +- [ ] **Step 2: Add `FilesystemFileMetadata` and extend `AnyCloudFileMetadata`** + +After the `WebDAVFileMetadata` interface (around line 187), add: + +```typescript +/** + * Filesystem (File System Access API) specific metadata + * Extends base with no additional fields — path acts as the identifier. + */ +export interface FilesystemFileMetadata extends CloudFileMetadata { + provider: 'filesystem'; +} +``` + +Replace the `AnyCloudFileMetadata` union: + +```typescript +export type AnyCloudFileMetadata = + | DriveFileMetadata + | MegaFileMetadata + | WebDAVFileMetadata + | FilesystemFileMetadata; +``` + +- [ ] **Step 3: Type-check** + +Run: `npm run check` +Expected: passes. If it fails on `provider-manager.ts` or `provider-detection.ts` because of exhaustive Record keys, the later tasks cover those; proceed anyway but capture the error list. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/util/sync/provider-interface.ts +git commit -m "feat(sync): add 'filesystem' to ProviderType union" +``` + +--- + +## Task 2: Extend provider-detection for 'filesystem' + +**Files:** +- Modify: `src/lib/util/sync/provider-detection.ts` + +- [ ] **Step 1: Extend the type guard in `getActiveProviderKey`** + +Open `src/lib/util/sync/provider-detection.ts`. Replace the `if` block on line 32: + +```typescript + if (value === 'google-drive' || value === 'mega' || value === 'webdav') { + return value; + } +``` + +with: + +```typescript + if ( + value === 'google-drive' || + value === 'mega' || + value === 'webdav' || + value === 'filesystem' + ) { + return value; + } +``` + +Do not add a legacy-detection branch inside `detectProviderFromCredentials()` — filesystem has no pre-existing localStorage credentials to migrate from. + +- [ ] **Step 2: Type-check** + +Run: `npm run check` +Expected: passes. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/util/sync/provider-detection.ts +git commit -m "feat(sync): recognize filesystem provider in active-provider detection" +``` + +--- + +## Task 3: Add `'filesystem': null` to provider-manager status records + +**Files:** +- Modify: `src/lib/util/sync/provider-manager.ts` + +- [ ] **Step 1: Extend initial status-store record (constructor)** + +Find the `writable` initialization around line 27-36. Replace the `providers` record: + +```typescript + providers: { + 'google-drive': null, + mega: null, + webdav: null + }, +``` + +with: + +```typescript + providers: { + 'google-drive': null, + mega: null, + webdav: null, + filesystem: null + }, +``` + +- [ ] **Step 2: Extend `updateStatus()` record (second occurrence)** + +Find the identical `providers` initialization inside `updateStatus()` around line 214-218. Apply the same change. + +- [ ] **Step 3: Type-check** + +Run: `npm run check` +Expected: passes. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/util/sync/provider-manager.ts +git commit -m "feat(sync): add filesystem slot to provider-manager status records" +``` + +--- + +## Task 4: Feature detection module + tests + +**Files:** +- Create: `src/lib/util/sync/providers/filesystem/feature-detect.ts` +- Create: `src/lib/util/sync/providers/filesystem/__tests__/feature-detect.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/util/sync/providers/filesystem/__tests__/feature-detect.test.ts`: + +```typescript +import { describe, it, expect, afterEach } from 'vitest'; +import { isFilesystemProviderSupported } from '../feature-detect'; + +describe('isFilesystemProviderSupported', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'showDirectoryPicker'); + + afterEach(() => { + if (originalDescriptor) { + Object.defineProperty(window, 'showDirectoryPicker', originalDescriptor); + } else { + // @ts-expect-error — cleanup + delete window.showDirectoryPicker; + } + }); + + it('returns true when showDirectoryPicker is present on window', () => { + Object.defineProperty(window, 'showDirectoryPicker', { + value: () => {}, + configurable: true + }); + expect(isFilesystemProviderSupported()).toBe(true); + }); + + it('returns false when showDirectoryPicker is absent', () => { + // @ts-expect-error — deliberate delete for test + delete window.showDirectoryPicker; + expect(isFilesystemProviderSupported()).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/util/sync/providers/filesystem/__tests__/feature-detect.test.ts` +Expected: FAIL — `Cannot find module '../feature-detect'`. + +- [ ] **Step 3: Implement `feature-detect.ts`** + +Create `src/lib/util/sync/providers/filesystem/feature-detect.ts`: + +```typescript +/** + * Returns true when the current environment supports the File System Access API + * (specifically `window.showDirectoryPicker`). Chromium-based browsers only. + */ +export function isFilesystemProviderSupported(): boolean { + return typeof window !== 'undefined' && 'showDirectoryPicker' in window; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/util/sync/providers/filesystem/__tests__/feature-detect.test.ts` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/util/sync/providers/filesystem/feature-detect.ts src/lib/util/sync/providers/filesystem/__tests__/feature-detect.test.ts +git commit -m "feat(filesystem-provider): add feature-detect helper" +``` + +--- + +## Task 5: Path helpers + tests + +**Files:** +- Create: `src/lib/util/sync/providers/filesystem/filesystem-paths.ts` +- Create: `src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts` + +Sidecar filter list and `splitPathSegments` are shared across provider operations. Extract first so later tasks can import. + +- [ ] **Step 1: Write the failing tests** + +Create `src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { + splitPathSegments, + isSyncableFile, + getParentPath, + getBasename +} from '../filesystem-paths'; + +describe('splitPathSegments', () => { + it('splits a typical volume path', () => { + expect(splitPathSegments('Series/Volume.cbz')).toEqual(['Series', 'Volume.cbz']); + }); + + it('handles single-segment paths', () => { + expect(splitPathSegments('volume-data.json')).toEqual(['volume-data.json']); + }); + + it('trims leading and trailing slashes', () => { + expect(splitPathSegments('/Series/Volume.cbz/')).toEqual(['Series', 'Volume.cbz']); + }); + + it('drops empty segments from duplicate slashes', () => { + expect(splitPathSegments('Series//Volume.cbz')).toEqual(['Series', 'Volume.cbz']); + }); + + it('returns empty array for empty string', () => { + expect(splitPathSegments('')).toEqual([]); + }); +}); + +describe('isSyncableFile', () => { + it.each([ + ['Series/Volume.cbz', true], + ['Series/Volume.mokuro', true], + ['Series/Volume.mokuro.gz', true], + ['Series/Volume.webp', true], + ['volume-data.json', true], + ['profiles.json', true], + ['Series/cover.jpg', false], + ['.DS_Store', false], + ['Series/Notes.txt', false], + ['random.json', false] + ])('%s -> %s', (name, expected) => { + expect(isSyncableFile(name)).toBe(expected); + }); + + it('is case-insensitive on extensions', () => { + expect(isSyncableFile('Series/Volume.CBZ')).toBe(true); + expect(isSyncableFile('Series/Volume.Mokuro.GZ')).toBe(true); + }); +}); + +describe('getParentPath / getBasename', () => { + it('splits a nested path', () => { + expect(getParentPath('Series/Volume.cbz')).toBe('Series'); + expect(getBasename('Series/Volume.cbz')).toBe('Volume.cbz'); + }); + + it('handles root-level files', () => { + expect(getParentPath('volume-data.json')).toBe(''); + expect(getBasename('volume-data.json')).toBe('volume-data.json'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `filesystem-paths.ts`** + +Create `src/lib/util/sync/providers/filesystem/filesystem-paths.ts`: + +```typescript +/** + * Pure path helpers for the filesystem provider. + * Paths are POSIX-style, relative to the picked root directory. + */ + +export function splitPathSegments(path: string): string[] { + return path.split('/').filter((segment) => segment.length > 0); +} + +export function getBasename(path: string): string { + const segments = splitPathSegments(path); + return segments.length === 0 ? '' : segments[segments.length - 1]; +} + +export function getParentPath(path: string): string { + const segments = splitPathSegments(path); + return segments.slice(0, -1).join('/'); +} + +const SYNCABLE_EXTENSIONS = ['.cbz', '.mokuro', '.mokuro.gz', '.webp']; +const SYNCABLE_ROOT_FILENAMES = new Set(['volume-data.json', 'profiles.json']); + +export function isSyncableFile(path: string): boolean { + const basename = getBasename(path).toLowerCase(); + if (SYNCABLE_ROOT_FILENAMES.has(basename)) { + return true; + } + return SYNCABLE_EXTENSIONS.some((ext) => basename.endsWith(ext)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/util/sync/providers/filesystem/filesystem-paths.ts src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts +git commit -m "feat(filesystem-provider): add path helpers" +``` + +--- + +## Task 6: Dedicated IDB handle store + tests + +**Files:** +- Create: `src/lib/util/sync/providers/filesystem/handle-store.ts` +- Create: `src/lib/util/sync/providers/filesystem/__tests__/handle-store.test.ts` + +This owns a standalone IndexedDB database (no Dexie) so the main app DB stays clean. + +- [ ] **Step 1: Install fake-indexeddb if not already present** + +Run: `npm ls fake-indexeddb 2>/dev/null | grep fake-indexeddb || npm install --save-dev fake-indexeddb` +Expected: either already installed or installed now. Check the added devDependency — if a new install, it will appear in `package.json`. + +- [ ] **Step 2: Write the failing tests** + +Create `src/lib/util/sync/providers/filesystem/__tests__/handle-store.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { saveRootHandle, loadRootHandle, clearRootHandle } from '../handle-store'; + +// Minimal fake that satisfies structured clone +function makeFakeHandle(name: string): FileSystemDirectoryHandle { + return { + kind: 'directory' as const, + name, + // structured-clonable no-op methods not required for the test; + // fake-indexeddb only needs the object to be structured-clonable + } as unknown as FileSystemDirectoryHandle; +} + +describe('handle-store', () => { + beforeEach(async () => { + // Reset the fake IDB for each test + const { IDBFactory } = await import('fake-indexeddb'); + // @ts-expect-error — replace globally for isolation + globalThis.indexedDB = new IDBFactory(); + }); + + it('returns null when no handle has been saved', async () => { + expect(await loadRootHandle()).toBeNull(); + }); + + it('round-trips a saved handle', async () => { + const handle = makeFakeHandle('Pictures'); + await saveRootHandle(handle); + const loaded = await loadRootHandle(); + expect(loaded).not.toBeNull(); + expect(loaded?.name).toBe('Pictures'); + }); + + it('overwrites the previous handle on re-save', async () => { + await saveRootHandle(makeFakeHandle('First')); + await saveRootHandle(makeFakeHandle('Second')); + const loaded = await loadRootHandle(); + expect(loaded?.name).toBe('Second'); + }); + + it('clears a saved handle', async () => { + await saveRootHandle(makeFakeHandle('Pictures')); + await clearRootHandle(); + expect(await loadRootHandle()).toBeNull(); + }); + + it('clear is idempotent when nothing is stored', async () => { + await expect(clearRootHandle()).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run src/lib/util/sync/providers/filesystem/__tests__/handle-store.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `handle-store.ts`** + +Create `src/lib/util/sync/providers/filesystem/handle-store.ts`: + +```typescript +const DB_NAME = 'mokuro-filesystem-provider'; +const DB_VERSION = 1; +const STORE_NAME = 'handles'; +const ROOT_KEY = 'root'; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function withStore( + mode: IDBTransactionMode, + fn: (store: IDBObjectStore) => IDBRequest +): Promise { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, mode); + const store = tx.objectStore(STORE_NAME); + const request = fn(store); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } finally { + db.close(); + } +} + +export async function saveRootHandle(handle: FileSystemDirectoryHandle): Promise { + await withStore('readwrite', (store) => store.put(handle, ROOT_KEY)); +} + +export async function loadRootHandle(): Promise { + const result = await withStore('readonly', (store) => store.get(ROOT_KEY)); + return (result as FileSystemDirectoryHandle | undefined) ?? null; +} + +export async function clearRootHandle(): Promise { + await withStore('readwrite', (store) => store.delete(ROOT_KEY)); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run src/lib/util/sync/providers/filesystem/__tests__/handle-store.test.ts` +Expected: all 5 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/util/sync/providers/filesystem/handle-store.ts src/lib/util/sync/providers/filesystem/__tests__/handle-store.test.ts package.json package-lock.json +git commit -m "feat(filesystem-provider): add dedicated IDB handle store" +``` + +--- + +## Task 7: FilesystemCacheManager (cache wrapper) + +**Files:** +- Create: `src/lib/util/sync/providers/filesystem/filesystem-cache.ts` + +This is a structural copy of `mega-cache.ts` / `webdav-cache.ts`. It has no provider-specific logic — it just groups metadata by series and exposes reactive stores. No new tests: it is covered by the existing cache-manager test suite once the filesystem provider is registered. + +- [ ] **Step 1: Create `filesystem-cache.ts` by adapting the webdav-cache pattern** + +Create `src/lib/util/sync/providers/filesystem/filesystem-cache.ts`: + +```typescript +import { writable } from 'svelte/store'; +import type { CloudCache } from '../../cloud-cache-interface'; +import type { CloudFileMetadata } from '../../provider-interface'; +import { filesystemProvider } from './filesystem-provider'; + +/** + * Filesystem Cache Wrapper + * + * Returns Map for efficient series-based operations. + * Cache is grouped by series folder names extracted from file paths. + */ +class FilesystemCacheManager implements CloudCache { + private cache = writable>(new Map()); + private isFetchingStore = writable(false); + private fetchingFlag = false; + private loadedFlag = false; + + get store() { + return this.cache; + } + + get isFetchingState() { + return this.isFetchingStore; + } + + async fetch(): Promise { + if (this.fetchingFlag) { + console.log('Filesystem cache fetch already in progress'); + return; + } + + if (!filesystemProvider.isAuthenticated()) { + console.log('Filesystem not authenticated, skipping cache fetch'); + return; + } + + this.fetchingFlag = true; + this.isFetchingStore.set(true); + try { + const volumes = await filesystemProvider.listCloudVolumes(); + + const cacheMap = new Map(); + for (const volume of volumes) { + const seriesTitle = volume.path.split('/')[0]; + const existing = cacheMap.get(seriesTitle); + if (existing) { + existing.push(volume); + } else { + cacheMap.set(seriesTitle, [volume]); + } + } + + this.cache.set(cacheMap); + this.loadedFlag = true; + console.log( + `✅ Filesystem cache populated with ${volumes.length} files in ${cacheMap.size} series` + ); + } catch (error) { + console.error('Failed to fetch filesystem cache:', error); + } finally { + this.fetchingFlag = false; + this.isFetchingStore.set(false); + } + } + + has(path: string): boolean { + let currentCache: Map = new Map(); + this.cache.subscribe((value) => { + currentCache = value; + })(); + const seriesTitle = path.split('/')[0]; + const seriesFiles = currentCache.get(seriesTitle); + return seriesFiles?.some((f) => f.path === path) || false; + } + + get(path: string): CloudFileMetadata | null { + let currentCache: Map = new Map(); + this.cache.subscribe((value) => { + currentCache = value; + })(); + const seriesTitle = path.split('/')[0]; + const seriesFiles = currentCache.get(seriesTitle); + return seriesFiles?.find((f) => f.path === path) || null; + } + + getAll(path: string): CloudFileMetadata[] { + let currentCache: Map = new Map(); + this.cache.subscribe((value) => { + currentCache = value; + })(); + const seriesTitle = path.split('/')[0]; + const seriesFiles = currentCache.get(seriesTitle); + return seriesFiles?.filter((f) => f.path === path) || []; + } + + getBySeries(seriesTitle: string): CloudFileMetadata[] { + let currentCache: Map = new Map(); + this.cache.subscribe((value) => { + currentCache = value; + })(); + const result: CloudFileMetadata[] = []; + for (const files of currentCache.values()) { + result.push(...files.filter((file) => file.path.startsWith(`${seriesTitle}/`))); + } + return result; + } + + getAllFiles(): CloudFileMetadata[] { + let currentCache: Map = new Map(); + this.cache.subscribe((value) => { + currentCache = value; + })(); + const result: CloudFileMetadata[] = []; + for (const files of currentCache.values()) { + result.push(...files); + } + return result; + } + + clear(): void { + this.cache.set(new Map()); + this.loadedFlag = false; + } + + isFetching(): boolean { + return this.fetchingFlag; + } + + isLoaded(): boolean { + return this.loadedFlag; + } + + add(path: string, metadata: CloudFileMetadata): void { + this.cache.update((cache) => { + const newCache = new Map(cache); + const seriesTitle = path.split('/')[0]; + const existing = newCache.get(seriesTitle); + if (existing) { + const index = existing.findIndex((f) => f.fileId === metadata.fileId); + if (index >= 0) { + newCache.set(seriesTitle, [ + ...existing.slice(0, index), + metadata, + ...existing.slice(index + 1) + ]); + } else { + newCache.set(seriesTitle, [...existing, metadata]); + } + } else { + newCache.set(seriesTitle, [metadata]); + } + return newCache; + }); + } + + removeById(fileId: string): void { + this.cache.update((cache) => { + const newCache = new Map(cache); + for (const [path, files] of newCache.entries()) { + const filtered = files.filter((f) => f.fileId !== fileId); + if (filtered.length === 0) { + newCache.delete(path); + } else if (filtered.length !== files.length) { + newCache.set(path, filtered); + } + } + return newCache; + }); + } + + update(fileId: string, updates: Partial): void { + this.cache.update((cache) => { + const newCache = new Map(cache); + for (const [path, files] of newCache.entries()) { + const updated = files.map((file) => + file.fileId === fileId ? { ...file, ...updates } : file + ); + newCache.set(path, updated); + } + return newCache; + }); + } +} + +export const filesystemCache = new FilesystemCacheManager(); +``` + +- [ ] **Step 2: Type-check** + +Run: `npm run check` +Expected: will currently fail because `./filesystem-provider` doesn't exist yet. That is fine — Task 8 creates it and the check will pass after Task 8's type-check step. Commit now anyway; this is intentional: cache depends on provider, provider depends on cache, they self-register as a pair in Task 9. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/util/sync/providers/filesystem/filesystem-cache.ts +git commit -m "feat(filesystem-provider): add cache wrapper" +``` + +--- + +## Task 8: FilesystemProvider — class skeleton + login/logout/auth + +**Files:** +- Create: `src/lib/util/sync/providers/filesystem/filesystem-provider.ts` + +- [ ] **Step 1: Create the provider file with auth-only methods first** + +Create `src/lib/util/sync/providers/filesystem/filesystem-provider.ts`: + +```typescript +import { browser } from '$app/environment'; +import type { + SyncProvider, + ProviderCredentials, + ProviderStatus, + CloudFileMetadata, + StorageQuota, + UploadPayload +} from '../../provider-interface'; +import { ProviderError } from '../../provider-interface'; +import { setActiveProviderKey, clearActiveProviderKey } from '../../provider-detection'; +import { isFilesystemProviderSupported } from './feature-detect'; +import { saveRootHandle, loadRootHandle, clearRootHandle } from './handle-store'; +import { splitPathSegments, isSyncableFile, getBasename, getParentPath } from './filesystem-paths'; + +export class FilesystemProvider implements SyncProvider { + readonly type = 'filesystem' as const; + readonly name = 'Local Folder'; + readonly supportsWorkerDownload = false; + readonly uploadConcurrencyLimit = 4; + readonly downloadConcurrencyLimit = 4; + + private rootHandle: FileSystemDirectoryHandle | null = null; + private hasStoredHandle = false; + private initPromise: Promise; + + constructor() { + if (browser && isFilesystemProviderSupported()) { + this.initPromise = this.restoreHandle(); + } else { + this.initPromise = Promise.resolve(); + } + } + + async whenReady(): Promise { + await this.initPromise; + } + + isAuthenticated(): boolean { + return this.rootHandle !== null; + } + + getStatus(): ProviderStatus { + return { + isAuthenticated: this.isAuthenticated(), + hasStoredCredentials: this.hasStoredHandle, + needsAttention: this.hasStoredHandle && !this.isAuthenticated(), + statusMessage: this.isAuthenticated() + ? `Connected to folder "${this.rootHandle?.name ?? ''}"` + : this.hasStoredHandle + ? 'Folder permission needs to be reconnected' + : 'Not configured' + }; + } + + async login(_credentials?: ProviderCredentials): Promise { + if (!browser || !isFilesystemProviderSupported()) { + throw new ProviderError( + 'File System Access API is not available in this browser', + 'filesystem', + 'UNSUPPORTED' + ); + } + + let handle: FileSystemDirectoryHandle; + try { + // @ts-expect-error — File System Access API is Chromium-only, no lib.dom typing in all TS targets + handle = await window.showDirectoryPicker({ mode: 'readwrite' }); + } catch (error) { + // User cancelled the picker or permission dismissed + const message = error instanceof Error ? error.message : 'Folder selection cancelled'; + throw new ProviderError(message, 'filesystem', 'PICKER_CANCELLED'); + } + + const permission = await handle.requestPermission({ mode: 'readwrite' }); + if (permission !== 'granted') { + throw new ProviderError( + 'Read-write permission was not granted for the selected folder', + 'filesystem', + 'PERMISSION_DENIED' + ); + } + + this.rootHandle = handle; + this.hasStoredHandle = true; + await saveRootHandle(handle); + setActiveProviderKey('filesystem'); + console.log(`✅ Filesystem provider connected to folder "${handle.name}"`); + } + + async logout(): Promise { + this.rootHandle = null; + this.hasStoredHandle = false; + await clearRootHandle(); + clearActiveProviderKey(); + console.log('Filesystem provider logged out'); + } + + /** + * Re-attempt to acquire read-write permission on the previously stored handle. + * Must be called from a user-gesture event handler (button click). + * Returns true on success, false if the user denied or the handle is invalid. + */ + async reauthenticate(): Promise { + if (!this.hasStoredHandle) { + throw new ProviderError( + 'No stored folder to reconnect', + 'filesystem', + 'NOT_CONFIGURED' + ); + } + const stored = await loadRootHandle(); + if (!stored) { + this.hasStoredHandle = false; + throw new ProviderError( + 'Stored folder reference is missing', + 'filesystem', + 'NOT_CONFIGURED' + ); + } + const permission = await stored.requestPermission({ mode: 'readwrite' }); + if (permission !== 'granted') { + // Keep the stored handle — user may grant on a later attempt + throw new ProviderError( + 'Permission was not granted', + 'filesystem', + 'PERMISSION_DENIED' + ); + } + this.rootHandle = stored; + setActiveProviderKey('filesystem'); + console.log(`✅ Filesystem provider reconnected to folder "${stored.name}"`); + } + + private async restoreHandle(): Promise { + try { + const stored = await loadRootHandle(); + if (!stored) return; + this.hasStoredHandle = true; + const permission = await stored.queryPermission({ mode: 'readwrite' }); + if (permission === 'granted') { + this.rootHandle = stored; + console.log(`✅ Filesystem provider restored folder "${stored.name}"`); + } else if (permission === 'denied') { + // Clear on outright denial + this.hasStoredHandle = false; + await clearRootHandle(); + clearActiveProviderKey(); + } + // 'prompt' → leave rootHandle null; UI will show "Reconnect" + } catch (error) { + console.warn('Failed to restore filesystem handle:', error); + } + } + + // Placeholder bodies — filled in by Task 9 + async listCloudVolumes(): Promise { + throw new Error('not implemented yet'); + } + + async uploadFile( + _path: string, + _blob: UploadPayload, + _description?: string, + _onProgress?: (loaded: number, total: number) => void + ): Promise { + throw new Error('not implemented yet'); + } + + async downloadFile( + _file: CloudFileMetadata, + _onProgress?: (loaded: number, total: number) => void + ): Promise { + throw new Error('not implemented yet'); + } + + async deleteFile(_file: CloudFileMetadata): Promise { + throw new Error('not implemented yet'); + } + + async renameFile(_file: CloudFileMetadata, _newPath: string): Promise { + throw new Error('not implemented yet'); + } + + async renameFolder(_oldPath: string, _newPath: string): Promise { + throw new Error('not implemented yet'); + } + + async deleteSeriesFolder(_seriesTitle: string): Promise { + throw new Error('not implemented yet'); + } + + async getStorageQuota(): Promise { + if (typeof navigator === 'undefined' || !navigator.storage?.estimate) { + return { used: 0, total: null, available: null }; + } + const estimate = await navigator.storage.estimate(); + const used = estimate.usage ?? 0; + const total = estimate.quota ?? null; + const available = total !== null ? total - used : null; + return { used, total, available }; + } +} + +export const filesystemProvider = new FilesystemProvider(); + +// Self-register cache when module is loaded (same pattern as MEGA/WebDAV) +import { cacheManager } from '../../cache-manager'; +import { filesystemCache } from './filesystem-cache'; +cacheManager.registerCache('filesystem', filesystemCache); +``` + +- [ ] **Step 2: Type-check** + +Run: `npm run check` +Expected: passes. + +- [ ] **Step 3: Verify the test suite still passes (sanity check — nothing references the provider yet beyond itself)** + +Run: `npm test -- --run` +Expected: PASS — existing tests plus the new feature-detect / paths / handle-store suites. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/util/sync/providers/filesystem/filesystem-provider.ts +git commit -m "feat(filesystem-provider): scaffold provider with auth/restore/logout" +``` + +--- + +## Task 9: Implement file operations on FilesystemProvider + +**Files:** +- Modify: `src/lib/util/sync/providers/filesystem/filesystem-provider.ts` + +All of these methods share two private helpers: `resolveDirectoryHandle(path, { create })` and `resolveFileHandle(path, { create })`. Add those first, then replace each placeholder method body. + +- [ ] **Step 1: Replace the placeholder implementations** + +Open `src/lib/util/sync/providers/filesystem/filesystem-provider.ts`. Before the placeholder method bodies (i.e., immediately after the `restoreHandle` method), add these private helpers: + +```typescript + private requireRoot(): FileSystemDirectoryHandle { + if (!this.rootHandle) { + throw new ProviderError( + 'Filesystem provider is not connected', + 'filesystem', + 'NOT_AUTHENTICATED', + true + ); + } + return this.rootHandle; + } + + private async resolveDirectoryHandle( + relativePath: string, + options: { create: boolean } + ): Promise { + const segments = splitPathSegments(relativePath); + let handle: FileSystemDirectoryHandle = this.requireRoot(); + for (const segment of segments) { + handle = await handle.getDirectoryHandle(segment, { create: options.create }); + } + return handle; + } + + private async resolveFileHandle( + relativePath: string, + options: { create: boolean } + ): Promise { + const parentPath = getParentPath(relativePath); + const filename = getBasename(relativePath); + if (!filename) { + throw new ProviderError( + `Invalid file path '${relativePath}'`, + 'filesystem', + 'INVALID_PATH' + ); + } + const parent = parentPath + ? await this.resolveDirectoryHandle(parentPath, { create: options.create }) + : this.requireRoot(); + return parent.getFileHandle(filename, { create: options.create }); + } + + private async *walkDirectory( + dir: FileSystemDirectoryHandle, + prefix: string + ): AsyncGenerator<{ path: string; fileHandle: FileSystemFileHandle }> { + // @ts-expect-error — values() is defined on FileSystemDirectoryHandle at runtime; TS lib may not have it + for await (const entry of dir.values()) { + const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.kind === 'directory') { + yield* this.walkDirectory(entry as FileSystemDirectoryHandle, entryPath); + } else if (entry.kind === 'file') { + yield { path: entryPath, fileHandle: entry as FileSystemFileHandle }; + } + } + } +``` + +Now replace each placeholder method body in turn. + +**`listCloudVolumes`:** + +```typescript + async listCloudVolumes(): Promise { + const root = this.requireRoot(); + const results: CloudFileMetadata[] = []; + for await (const { path, fileHandle } of this.walkDirectory(root, '')) { + if (!isSyncableFile(path)) continue; + const file = await fileHandle.getFile(); + results.push({ + provider: 'filesystem', + fileId: path, + path, + modifiedTime: new Date(file.lastModified).toISOString(), + size: file.size + }); + } + console.log(`✅ Listed ${results.length} files from filesystem provider`); + return results; + } +``` + +**`uploadFile`:** + +```typescript + async uploadFile( + path: string, + blob: UploadPayload, + _description?: string, + onProgress?: (loaded: number, total: number) => void + ): Promise { + this.requireRoot(); + const fileHandle = await this.resolveFileHandle(path, { create: true }); + const writable = await fileHandle.createWritable(); + try { + const payload = + blob instanceof Blob + ? blob + : blob instanceof ArrayBuffer + ? new Blob([blob]) + : new Blob([blob]); + await writable.write(payload); + onProgress?.(payload.size, payload.size); + } finally { + await writable.close(); + } + console.log(`✅ Uploaded ${path} to filesystem`); + return path; + } +``` + +**`downloadFile`:** + +```typescript + async downloadFile( + file: CloudFileMetadata, + onProgress?: (loaded: number, total: number) => void + ): Promise { + this.requireRoot(); + const fileHandle = await this.resolveFileHandle(file.fileId, { create: false }); + const data = await fileHandle.getFile(); + onProgress?.(data.size, data.size); + console.log(`✅ Downloaded ${file.path} from filesystem`); + return data; + } +``` + +**`deleteFile`:** + +```typescript + async deleteFile(file: CloudFileMetadata): Promise { + this.requireRoot(); + const parentPath = getParentPath(file.fileId); + const filename = getBasename(file.fileId); + const parent = parentPath + ? await this.resolveDirectoryHandle(parentPath, { create: false }) + : this.requireRoot(); + await parent.removeEntry(filename); + console.log(`✅ Deleted ${file.path} from filesystem`); + } +``` + +**`renameFile`:** + +The File System Access API has no native rename. Copy the bytes to the new path, then remove the old entry. + +```typescript + async renameFile(file: CloudFileMetadata, newPath: string): Promise { + this.requireRoot(); + const normalizedNewPath = newPath.replace(/^\/+|\/+$/g, ''); + if (file.path === normalizedNewPath) { + return file; + } + + // Read source + const sourceHandle = await this.resolveFileHandle(file.fileId, { create: false }); + const sourceFile = await sourceHandle.getFile(); + + // Write to destination + const destHandle = await this.resolveFileHandle(normalizedNewPath, { create: true }); + const writable = await destHandle.createWritable(); + try { + await writable.write(sourceFile); + } finally { + await writable.close(); + } + + // Delete source + const sourceParentPath = getParentPath(file.fileId); + const sourceParent = sourceParentPath + ? await this.resolveDirectoryHandle(sourceParentPath, { create: false }) + : this.requireRoot(); + await sourceParent.removeEntry(getBasename(file.fileId)); + + console.log(`✅ Renamed ${file.path} → ${normalizedNewPath} in filesystem`); + const destFile = await destHandle.getFile(); + return { + provider: 'filesystem', + fileId: normalizedNewPath, + path: normalizedNewPath, + modifiedTime: new Date(destFile.lastModified).toISOString(), + size: destFile.size + }; + } +``` + +**`renameFolder`:** + +```typescript + async renameFolder(oldPath: string, newPath: string): Promise { + this.requireRoot(); + const normalizedOld = oldPath.replace(/^\/+|\/+$/g, ''); + const normalizedNew = newPath.replace(/^\/+|\/+$/g, ''); + if (normalizedOld === normalizedNew) { + const all = await this.listCloudVolumes(); + return all.filter((f) => f.path.startsWith(`${normalizedOld}/`)); + } + + // List all files currently under the old folder and rename each one + const all = await this.listCloudVolumes(); + const affected = all.filter((f) => f.path.startsWith(`${normalizedOld}/`)); + const renamed: CloudFileMetadata[] = []; + for (const file of affected) { + const suffix = file.path.slice(normalizedOld.length); + const target = `${normalizedNew}${suffix}`; + renamed.push(await this.renameFile(file, target)); + } + + // Best-effort cleanup of the now-empty old folder + try { + const parentPath = getParentPath(normalizedOld); + const parent = parentPath + ? await this.resolveDirectoryHandle(parentPath, { create: false }) + : this.requireRoot(); + await parent.removeEntry(getBasename(normalizedOld), { recursive: true }); + } catch { + // Already gone or never existed — fine + } + + console.log(`✅ Renamed folder ${normalizedOld} → ${normalizedNew} in filesystem`); + return renamed; + } +``` + +**`deleteSeriesFolder`:** + +```typescript + async deleteSeriesFolder(seriesTitle: string): Promise { + const root = this.requireRoot(); + const normalized = seriesTitle.replace(/^\/+|\/+$/g, ''); + if (!normalized) return; + try { + await root.removeEntry(normalized, { recursive: true }); + console.log(`✅ Deleted series folder '${seriesTitle}' from filesystem`); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + if (/NotFoundError/.test(message)) { + console.log(`Series folder '${seriesTitle}' not found in filesystem`); + return; + } + throw new ProviderError( + `Failed to delete series folder: ${message}`, + 'filesystem', + 'DELETE_FAILED' + ); + } + } +``` + +- [ ] **Step 2: Type-check** + +Run: `npm run check` +Expected: passes. + +- [ ] **Step 3: Run full test suite** + +Run: `npm test -- --run` +Expected: all existing tests plus the three new filesystem suites pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/util/sync/providers/filesystem/filesystem-provider.ts +git commit -m "feat(filesystem-provider): implement list/upload/download/delete/rename" +``` + +--- + +## Task 10: Wire filesystem provider into `init-providers.ts` + +**Files:** +- Modify: `src/lib/util/sync/init-providers.ts` + +- [ ] **Step 1: Add the dynamic-import case to `loadProvider`** + +Open `src/lib/util/sync/init-providers.ts`. Inside the `switch (type)` block (around lines 16-31), add a new case: + +```typescript + case 'filesystem': { + const { filesystemProvider } = await import( + './providers/filesystem/filesystem-provider' + ); + return filesystemProvider; + } +``` + +Place it alphabetically — after the `'webdav'` case is fine, or rearrange if you prefer strict alpha. Keep it before the closing brace. + +- [ ] **Step 2: Extend the `whenReady` branch to include filesystem** + +Find the block starting `} else if (activeProviderType === 'mega' || activeProviderType === 'webdav') {` (around line 100). Replace it with: + +```typescript + } else if ( + activeProviderType === 'mega' || + activeProviderType === 'webdav' || + activeProviderType === 'filesystem' + ) { + // MEGA, WebDAV, and filesystem restore credentials in their constructors via whenReady() + console.log(`⏳ Waiting for ${activeProviderType} to restore credentials...`); + await (activeProvider as any).whenReady(); + console.log(`✅ ${activeProviderType} credentials restored`); + } +``` + +- [ ] **Step 3: Type-check** + +Run: `npm run check` +Expected: passes. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/util/sync/init-providers.ts +git commit -m "feat(sync): lazy-load filesystem provider on startup" +``` + +--- + +## Task 11: Audit other files for literal provider-type matching + +**Files (read-only audit first; may be modified):** +- `src/lib/components/Catalog.svelte` +- `src/lib/components/NavBar.svelte` +- `src/lib/components/PlaceholderVolumeItem.svelte` +- `src/lib/components/BackupButton.svelte` +- `src/lib/components/VolumeItem.svelte` +- `src/lib/components/UploadModal.svelte` +- `src/lib/components/AddLibraryModal.svelte` +- `src/lib/components/WebDAVErrorModal.svelte` +- `src/lib/views/LibraryManagerView.svelte` +- `src/lib/views/SeriesView.svelte` +- `src/lib/util/cloud-fields.ts` +- `src/lib/util/download-queue.ts` +- `src/lib/util/libraries/library-placeholders.ts` +- `src/lib/util/libraries/library-webdav-client.ts` +- `src/lib/import/types.ts` +- `src/lib/util/sync/unified-sync-service.ts` + +- [ ] **Step 1: Audit** + +Run the following: + +```bash +grep -n "'mega'\|'webdav'\|'google-drive'" \ + src/lib/components/Catalog.svelte \ + src/lib/components/NavBar.svelte \ + src/lib/components/PlaceholderVolumeItem.svelte \ + src/lib/components/BackupButton.svelte \ + src/lib/components/VolumeItem.svelte \ + src/lib/components/UploadModal.svelte \ + src/lib/components/AddLibraryModal.svelte \ + src/lib/views/LibraryManagerView.svelte \ + src/lib/views/SeriesView.svelte \ + src/lib/util/cloud-fields.ts \ + src/lib/util/download-queue.ts \ + src/lib/util/libraries/library-placeholders.ts \ + src/lib/import/types.ts \ + src/lib/util/sync/unified-sync-service.ts +``` + +For every match, determine whether the code is provider-agnostic (calling methods on a `SyncProvider`) or pattern-matching on a specific literal. If it is provider-agnostic, no change needed. If it is matching literals, decide per site: + +- **If the code handles provider-specific UX (e.g., "Google Drive" label, error formatting like `WebDAVErrorModal`)** — do nothing; filesystem does not need any of this. +- **If the code has an exhaustive `switch`/`if` over `ProviderType` that will miss `'filesystem'`** — add a filesystem branch matching the default/least-specific existing branch (usually the MEGA/WebDAV branch). + +- [ ] **Step 2: For each file requiring a change, apply the minimum change** + +Make those edits one file at a time. For each edit, type-check after: + +```bash +npm run check +``` + +- [ ] **Step 3: Run the full test suite** + +Run: `npm test -- --run` +Expected: all tests pass. If any existing test fails because its mock data didn't include `'filesystem'`, update the test to include the new key with a `null` value (matching MEGA/WebDAV null-value patterns in the same test). + +- [ ] **Step 4: Commit** + +Only run this step if any file was actually modified. + +```bash +git add +git commit -m "feat(sync): include filesystem in exhaustive provider-type branches" +``` + +If no file was modified, skip this commit. + +--- + +## Task 12: Wire filesystem into CloudView UI + +**Files:** +- Modify: `src/lib/views/CloudView.svelte` + +This is the main UX entry point. It needs: +1. A feature-detection import +2. A new provider button, hidden when unsupported +3. `providerNames` and `providerInfo` entries +4. Auth-state derived +5. Login / reconnect / logout handlers +6. Connected-state rendering for the new provider (simple — no file-picker, no RAM toggle, etc.) + +- [ ] **Step 1: Add imports** + +Near the top of the ` + + From b768c8f8c450276e4df087b264de2afa2f4f81f0 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Mon, 27 Apr 2026 15:04:41 -0700 Subject: [PATCH 41/65] fix(onedrive): share PKCE verifier between opener and popup via localStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSAL v5 stores temporary auth artifacts (PKCE code verifier, pending request state) in sessionStorage by default, regardless of cacheLocation. Because sessionStorage is per-window, the popup callback can't see what the opener wrote when it called loginPopup() — handleRedirectPromise() then throws no_token_request_cache_error. Set temporaryCacheLocation: 'localStorage' on both the opener's MSAL config and the popup callback's config. Pin the popup's CDN MSAL to v5.8.0 so the cache schema matches the npm version exactly. --- src/lib/util/sync/providers/onedrive/token-manager.ts | 6 +++++- static/onedrive-callback.html | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/lib/util/sync/providers/onedrive/token-manager.ts b/src/lib/util/sync/providers/onedrive/token-manager.ts index ff5c035e..dd647e3b 100644 --- a/src/lib/util/sync/providers/onedrive/token-manager.ts +++ b/src/lib/util/sync/providers/onedrive/token-manager.ts @@ -64,7 +64,11 @@ class OneDriveTokenManager { redirectUri }, cache: { - cacheLocation: 'localStorage' + cacheLocation: 'localStorage', + // Default is sessionStorage which is per-window and unreachable + // from the popup callback. We need both opener and popup to share + // the PKCE verifier and pending-request entries. + temporaryCacheLocation: 'localStorage' } }; this.instance = new this.msal.PublicClientApplication(config); diff --git a/static/onedrive-callback.html b/static/onedrive-callback.html index 58d4e0f9..ff91b216 100644 --- a/static/onedrive-callback.html +++ b/static/onedrive-callback.html @@ -45,7 +45,7 @@

Connecting to OneDrive…

// postMessage. MSAL closes this popup itself once the message is sent. try { const { PublicClientApplication } = await import( - 'https://cdn.jsdelivr.net/npm/@azure/msal-browser@5/+esm' + 'https://cdn.jsdelivr.net/npm/@azure/msal-browser@5.8.0/+esm' ); // Pull the client ID from a meta tag we inject from the parent app at @@ -80,7 +80,12 @@

Connecting to OneDrive…

authority: 'https://login.microsoftonline.com/common', redirectUri: window.location.origin + '/onedrive-callback.html' }, - cache: { cacheLocation: 'localStorage' } + cache: { + cacheLocation: 'localStorage', + // Must match the opener's config so the PKCE verifier (stored + // when loginPopup() was called) is reachable from this window. + temporaryCacheLocation: 'localStorage' + } }); await msal.initialize(); await msal.handleRedirectPromise(); From fafb19f1026a29bd936675f79498154b637650e7 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Mon, 27 Apr 2026 15:07:39 -0700 Subject: [PATCH 42/65] fix(onedrive): switch from popup to redirect-based MSAL flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popup approach kept hitting cross-window state issues — MSAL v5 stores the PKCE verifier and pending-request state in sessionStorage even when cacheLocation is set to localStorage, and the popup window can't see the opener's sessionStorage. Setting temporaryCacheLocation isn't an option either; that field doesn't exist in MSAL v5. Switch to the redirect flow: - loginRedirect navigates the entire window to login.microsoftonline.com. - After auth, the user lands back on the app origin with the auth code in the URL query string (not the fragment, so the hash router is fine). - Set active_cloud_provider = 'onedrive' BEFORE the redirect so init-providers eagerly loads OneDrive on the return trip; provider initialize() then calls handleRedirectPromise() to complete the sign-in. - onedrive-provider.login() detects the post-redirect "already authenticated" state and skips the redirect, completing the connection inline. Drop the static callback HTML page — no longer needed. Drop the popup-window detection in init-providers — no longer applicable. --- src/lib/util/sync/init-providers.ts | 20 --- .../providers/onedrive/onedrive-provider.ts | 30 ++++- .../sync/providers/onedrive/token-manager.ts | 123 ++++++++---------- static/onedrive-callback.html | 101 -------------- 4 files changed, 79 insertions(+), 195 deletions(-) delete mode 100644 static/onedrive-callback.html diff --git a/src/lib/util/sync/init-providers.ts b/src/lib/util/sync/init-providers.ts index 6921fdfb..2efa1863 100644 --- a/src/lib/util/sync/init-providers.ts +++ b/src/lib/util/sync/init-providers.ts @@ -48,26 +48,6 @@ export async function loadProvider(type: ProviderType): Promise { * - Provider modules self-register their caches when loaded */ export async function initializeProviders(): Promise { - // OneDrive popup-flow callback: when MSAL.loginPopup() opens a popup, the - // popup is redirected back to our app's origin (the registered SPA redirect - // URI). The popup loads this same app, but no provider is active yet, so - // OneDrive's MSAL would never initialize and the popup would hang. When we - // detect we're running inside a popup window, eagerly load the OneDrive - // provider so MSAL processes the redirect and posts the result back to the - // opener window via window.opener.postMessage. MSAL closes the popup itself - // once it's finished. - if (typeof window !== 'undefined' && window.opener && window.opener !== window) { - console.log('🪟 Detected popup window — loading MSAL to handle OneDrive auth redirect'); - try { - const { onedriveProvider } = await import('./providers/onedrive/onedrive-provider'); - await onedriveProvider.whenReady(); - console.log('✅ MSAL initialized in popup; waiting for it to close itself'); - } catch (error) { - console.error('Failed to handle OneDrive popup redirect:', error); - } - return; - } - // Check which provider (if any) is active const activeProviderType = getConfiguredProviderType(); diff --git a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts index b5a575e0..5f8dafe3 100644 --- a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts +++ b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts @@ -92,12 +92,34 @@ export class OneDriveProvider implements SyncProvider { if (!browser) { throw new ProviderError('OneDrive only works in browser', 'onedrive', 'BROWSER_ONLY'); } + // If the user just returned from a redirect-flow login, MSAL has already + // populated the account during initialize(). Skip the redirect dance and + // finalize the connection. + await onedriveTokenManager.initialize(); + if (onedriveTokenManager.isAuthenticated()) { + try { + await this.ensureMokuroFolder(); + setActiveProviderKey('onedrive'); + console.log('✅ OneDrive login completed (post-redirect)'); + return; + } catch (error) { + throw new ProviderError( + `OneDrive login failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'onedrive', + 'LOGIN_FAILED', + true + ); + } + } + // Set the active provider key BEFORE the redirect so on return the + // provider lazy-loads via the active_cloud_provider key path and + // initialize() (which calls handleRedirectPromise) runs to complete auth. + setActiveProviderKey('onedrive'); try { - await onedriveTokenManager.login(); - await this.ensureMokuroFolder(); - setActiveProviderKey('onedrive'); - console.log('✅ OneDrive login successful'); + await onedriveTokenManager.login(); // Navigates the window away } catch (error) { + // Roll back the active key if the redirect itself failed + clearActiveProviderKey(); throw new ProviderError( `OneDrive login failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'onedrive', diff --git a/src/lib/util/sync/providers/onedrive/token-manager.ts b/src/lib/util/sync/providers/onedrive/token-manager.ts index dd647e3b..564e7630 100644 --- a/src/lib/util/sync/providers/onedrive/token-manager.ts +++ b/src/lib/util/sync/providers/onedrive/token-manager.ts @@ -3,15 +3,16 @@ import { writable, type Readable } from 'svelte/store'; import type { PublicClientApplication, AccountInfo, - AuthenticationResult, Configuration, - PopupRequest, + RedirectRequest, SilentRequest } from '@azure/msal-browser'; import { ONEDRIVE_CONFIG } from './constants'; type Msal = typeof import('@azure/msal-browser'); +const PENDING_LOGIN_KEY = 'onedrive_login_pending'; + class OneDriveTokenManager { private instance: PublicClientApplication | null = null; private account: AccountInfo | null = null; @@ -32,6 +33,8 @@ class OneDriveTokenManager { /** * Initialize MSAL. Safe to call multiple times — returns the same promise. + * Uses redirect-based auth (not popup): cleaner cross-window state, no + * popup blockers, and avoids the need for a separate callback page. */ async initialize(): Promise { if (!browser) return; @@ -47,45 +50,37 @@ class OneDriveTokenManager { this.msal = await import('@azure/msal-browser'); - // Use a dedicated static HTML callback page that bypasses SvelteKit's - // hash-based router. Our app would otherwise try to interpret MSAL's - // fragment-mode auth response as a route, dropping the auth code before - // MSAL can read it. The static page is served from /static/ directly. - const redirectUri = `${window.location.origin}/onedrive-callback.html`; - - // Stash the client ID where the static callback page can find it. - // Same-origin sessionStorage is shared between opener and popup. - sessionStorage.setItem('onedrive_client_id', clientId); - const config: Configuration = { auth: { clientId, authority: ONEDRIVE_CONFIG.AUTHORITY, - redirectUri + redirectUri: window.location.origin }, cache: { - cacheLocation: 'localStorage', - // Default is sessionStorage which is per-window and unreachable - // from the popup callback. We need both opener and popup to share - // the PKCE verifier and pending-request entries. - temporaryCacheLocation: 'localStorage' + cacheLocation: 'localStorage' } }; this.instance = new this.msal.PublicClientApplication(config); await this.instance.initialize(); - // Drain any pending interaction state from a previous (possibly - // abandoned) popup, and process popup-flow redirects when this code - // runs inside a popup window. + // If we just returned from a redirect-flow login, this completes it. try { const result = await this.instance.handleRedirectPromise(); if (result?.account) { this.account = result.account; this.instance.setActiveAccount(result.account); this.tokenStore.set(result.accessToken); + localStorage.setItem(ONEDRIVE_CONFIG.STORAGE_KEYS.HAS_AUTHENTICATED, 'true'); + localStorage.removeItem(PENDING_LOGIN_KEY); + } else if (localStorage.getItem(PENDING_LOGIN_KEY) === 'true') { + // We were waiting for a redirect that never produced a result — + // either the user navigated away or auth failed silently. Clear + // the flag so the user can retry. + localStorage.removeItem(PENDING_LOGIN_KEY); } } catch (error) { console.warn('OneDrive handleRedirectPromise failed:', error); + localStorage.removeItem(PENDING_LOGIN_KEY); } // Restore account from MSAL cache (if a previous session exists) @@ -101,6 +96,18 @@ class OneDriveTokenManager { return this.initPromise; } + /** + * Returns true when the app booted from a OneDrive redirect callback that + * the user is currently waiting on. Init-providers uses this to skip + * unrelated bootstrap work and let the UI surface "connected" instead. + */ + hasPendingRedirect(): boolean { + if (!browser) return false; + if (localStorage.getItem(PENDING_LOGIN_KEY) !== 'true') return false; + const url = new URL(window.location.href); + return url.searchParams.has('code') || url.searchParams.has('error'); + } + isAuthenticated(): boolean { return this.account !== null && !!this.instance; } @@ -114,55 +121,34 @@ class OneDriveTokenManager { return this.account?.name ?? this.account?.username ?? null; } + /** + * Start the redirect-based login flow. The whole window navigates to + * Microsoft's login page; after auth the user lands back on the app's + * origin and `initialize()` calls `handleRedirectPromise()` to complete + * the sign-in. Because this navigates the window, this method does not + * resolve in the normal sense — the caller's await never returns from + * the user's perspective; the next page load is the post-auth state. + */ async login(): Promise { await this.initialize(); if (!this.instance || !this.msal) { throw new Error('MSAL instance not initialized'); } + // Mark the redirect as in-flight so init-providers can detect the + // callback path on the next page load. + localStorage.setItem(PENDING_LOGIN_KEY, 'true'); - const request: PopupRequest = { scopes: ONEDRIVE_CONFIG.SCOPES as unknown as string[] }; - - let result: AuthenticationResult; - try { - result = await this.instance.loginPopup(request); - } catch (error) { - // Stale interaction state from a previous popup that didn't complete. - // Clear it and retry once. - const code = (error as { errorCode?: string })?.errorCode; - if (code === 'interaction_in_progress') { - try { - await this.instance.handleRedirectPromise(); - } catch { - /* ignore */ - } - // MSAL stores the interaction status under a key in localStorage. - // Clearing it lets the next loginPopup() proceed. - if (browser) { - for (const key of Object.keys(localStorage)) { - if (key.startsWith('msal.interaction.status') || key.endsWith('.interaction.status')) { - localStorage.removeItem(key); - } - } - } - result = await this.instance.loginPopup(request); - } else { - throw error; - } - } - - this.account = result.account; - this.instance.setActiveAccount(result.account); - this.tokenStore.set(result.accessToken); - this.needsAttentionStore.set(false); - - localStorage.setItem(ONEDRIVE_CONFIG.STORAGE_KEYS.HAS_AUTHENTICATED, 'true'); + const request: RedirectRequest = { + scopes: ONEDRIVE_CONFIG.SCOPES as unknown as string[] + }; + await this.instance.loginRedirect(request); } async logout(): Promise { if (this.instance && this.account) { - await this.instance.logoutPopup({ + await this.instance.logoutRedirect({ account: this.account, - mainWindowRedirectUri: window.location.origin + postLogoutRedirectUri: window.location.origin }); } this.account = null; @@ -170,13 +156,13 @@ class OneDriveTokenManager { this.needsAttentionStore.set(false); if (browser) { localStorage.removeItem(ONEDRIVE_CONFIG.STORAGE_KEYS.HAS_AUTHENTICATED); + localStorage.removeItem(PENDING_LOGIN_KEY); } } /** - * Acquire an access token. Uses the silent cache first, throws if MSAL - * signals interaction required (the caller should prompt the user via - * reauthenticate()). + * Acquire an access token silently. Throws if MSAL signals interaction + * required (the caller should prompt the user via reauthenticate()). */ async getAccessToken(): Promise { await this.initialize(); @@ -201,23 +187,20 @@ class OneDriveTokenManager { } /** - * Popup-based re-authentication. Used by the UI when silent refresh fails - * and the user clicks a "reconnect" action. + * Redirect-based re-authentication. Used by the UI when silent refresh + * fails and the user clicks a "reconnect" action. */ async reauthenticate(): Promise { await this.initialize(); if (!this.instance) { throw new Error('MSAL not initialized'); } - const request: PopupRequest = { + localStorage.setItem(PENDING_LOGIN_KEY, 'true'); + const request: RedirectRequest = { scopes: ONEDRIVE_CONFIG.SCOPES as unknown as string[], account: this.account ?? undefined }; - const result = await this.instance.acquireTokenPopup(request); - this.account = result.account; - this.instance.setActiveAccount(result.account); - this.tokenStore.set(result.accessToken); - this.needsAttentionStore.set(false); + await this.instance.acquireTokenRedirect(request); } } diff --git a/static/onedrive-callback.html b/static/onedrive-callback.html deleted file mode 100644 index ff91b216..00000000 --- a/static/onedrive-callback.html +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - Connecting to OneDrive… - - - -
-

Connecting to OneDrive…

-

This window should close on its own.

-
- - - From cb23d7b60d51bdf1ab5c3d16dccf9d88d90166ea Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Mon, 27 Apr 2026 21:53:05 -0700 Subject: [PATCH 43/65] fix(onedrive): anchor uploads under /mokuro-reader/ Worker uploads were landing at the OneDrive root. The cause: the worker calls into onedrive-core with the bare series title from BackupQueueItem (e.g., "Cowboy Bebop"), but only the provider's main-thread uploadFile prepended the mokuro-reader prefix. The worker path bypassed that. Move the prefix into onedrive-core so every upload anchors under /mokuro-reader/{series}/{filename} regardless of code path. Add a prepareUploadTarget on the OneDrive provider that ensures the destination folder exists before the worker's createUploadSession call (avoids 404 on missing parent). Update onedrive-core tests to assert the full path and add coverage for root-level files (volume-data.json, profiles.json). --- .../providers/__tests__/onedrive-core.test.ts | 39 +++++++++++++++---- .../util/sync/core/providers/onedrive-core.ts | 10 ++++- .../providers/onedrive/onedrive-provider.ts | 18 ++++++++- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts b/src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts index e5bbf3ff..cadac35a 100644 --- a/src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts +++ b/src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts @@ -7,10 +7,7 @@ describe('onedriveCore', () => { }); describe('uploadFile', () => { - it('creates an upload session and PUTs the full payload in one chunk', async () => { - const seriesFolderPath = 'mokuro-reader/Series'; - const filename = 'v1.cbz'; - + it('creates an upload session under mokuro-reader/{series} and PUTs full payload in one chunk', async () => { // 1st call: createUploadSession vi.mocked(fetch).mockResolvedValueOnce({ ok: true, @@ -21,20 +18,22 @@ describe('onedriveCore', () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 201, - json: async () => ({ id: 'new-item-id', name: filename }) + json: async () => ({ id: 'new-item-id', name: 'v1.cbz' }) } as Response); const blob = new Blob([new Uint8Array(1000)]); const id = await onedriveCore.uploadFile({ - seriesTitle: seriesFolderPath, - filename, + seriesTitle: 'Series', // bare title — core anchors it under mokuro-reader + filename: 'v1.cbz', blob, credentials: { accessToken: 'TOKEN' } }); expect(id).toBe('new-item-id'); const initCall = vi.mocked(fetch).mock.calls[0]; - expect(initCall[0]).toContain(':/createUploadSession'); + expect(initCall[0]).toBe( + 'https://graph.microsoft.com/v1.0/me/drive/root:/mokuro-reader/Series/v1.cbz:/createUploadSession' + ); const putCall = vi.mocked(fetch).mock.calls[1]; expect(putCall[0]).toBe('https://upload.example/xyz'); expect((putCall[1] as RequestInit).method).toBe('PUT'); @@ -44,6 +43,30 @@ describe('onedriveCore', () => { }); }); + it('places root-level files (no series) directly under mokuro-reader', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ uploadUrl: 'https://upload.example/xyz' }) + } as Response); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: 'config-id' }) + } as Response); + + await onedriveCore.uploadFile({ + seriesTitle: '', // no series — like volume-data.json or profiles.json + filename: 'volume-data.json', + blob: new Blob([new Uint8Array(10)]), + credentials: { accessToken: 'TOKEN' } + }); + + const initCall = vi.mocked(fetch).mock.calls[0]; + expect(initCall[0]).toBe( + 'https://graph.microsoft.com/v1.0/me/drive/root:/mokuro-reader/volume-data.json:/createUploadSession' + ); + }); + it('splits payload into multiple chunks when larger than chunk size', async () => { // Session init vi.mocked(fetch).mockResolvedValueOnce({ diff --git a/src/lib/util/sync/core/providers/onedrive-core.ts b/src/lib/util/sync/core/providers/onedrive-core.ts index f30f1a66..2e57519b 100644 --- a/src/lib/util/sync/core/providers/onedrive-core.ts +++ b/src/lib/util/sync/core/providers/onedrive-core.ts @@ -46,7 +46,15 @@ export const onedriveCore: CloudProviderCore = { 'OneDrive access token' ); - const targetPath = seriesTitle ? `${seriesTitle}/${filename}` : filename; + // The worker calls in with the bare series title (e.g. "Cowboy Bebop"). + // Anchor the upload under our app's mokuro-reader folder, matching the + // layout used by Drive and WebDAV. The provider's main-thread uploadFile + // ensures the parent folder exists ahead of time via prepareUploadTarget; + // worker uploads ride on that same precondition. + const folderPath = seriesTitle + ? `${ONEDRIVE_CONFIG.MOKURO_FOLDER}/${seriesTitle}` + : ONEDRIVE_CONFIG.MOKURO_FOLDER; + const targetPath = `${folderPath}/${filename}`; const sessionResponse = await fetch( `${BASE}/me/drive/root:/${encodePath(targetPath)}:/createUploadSession`, diff --git a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts index 5f8dafe3..a62257d6 100644 --- a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts +++ b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts @@ -219,7 +219,9 @@ export class OneDriveProvider implements SyncProvider { ? new Blob([blob]) : new Blob([new Uint8Array(blob).buffer as ArrayBuffer]); const fileId = await this.cloudCore.uploadFile({ - seriesTitle: `${ONEDRIVE_CONFIG.MOKURO_FOLDER}${seriesTitle ? `/${seriesTitle}` : ''}`, + // onedrive-core prefixes its own mokuro-reader root, so pass just the + // bare series title here. + seriesTitle, filename, blob: blobToUpload, credentials, @@ -369,6 +371,20 @@ export class OneDriveProvider implements SyncProvider { }; } + /** + * Called by backup-queue before worker upload starts. We ensure the + * destination series folder exists so the worker's createUploadSession + * call doesn't 404 on a missing parent. + */ + async prepareUploadTarget(seriesTitle: string): Promise { + if (!this.isAuthenticated()) return; + if (seriesTitle) { + await this.ensureSeriesFolder(seriesTitle); + } else { + await this.ensureMokuroFolder(); + } + } + async getWorkerUploadCredentials(): Promise> { const accessToken = await onedriveTokenManager.getAccessToken(); return { accessToken }; From 49afe2d1a3544c8b0fc04ecbb92c742e6ed75b8c Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Tue, 2 Jun 2026 16:21:12 -0700 Subject: [PATCH 44/65] fix(onedrive): paginate listChildren via @odata.nextLink Graph pages children at ~200 per response; the previous single-fetch implementation silently dropped the rest, so libraries with >200 series (or a folder with >200 files) lost volumes from the catalog and from rename result sets. Follow @odata.nextLink until exhausted. Co-Authored-By: Claude Opus 4.8 --- .../onedrive/__tests__/graph-client.test.ts | 29 +++++++++++++++++++ .../sync/providers/onedrive/graph-client.ts | 18 ++++++++---- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/lib/util/sync/providers/onedrive/__tests__/graph-client.test.ts b/src/lib/util/sync/providers/onedrive/__tests__/graph-client.test.ts index 3a3d0756..acbdd6c9 100644 --- a/src/lib/util/sync/providers/onedrive/__tests__/graph-client.test.ts +++ b/src/lib/util/sync/providers/onedrive/__tests__/graph-client.test.ts @@ -81,6 +81,35 @@ describe('graph-client', () => { const call = vi.mocked(fetch).mock.calls[0]; expect(call[0]).toContain('Test%20Series'); }); + + it('follows @odata.nextLink to page through large folders', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + value: [{ id: '1', name: 'a.cbz', file: {} }], + '@odata.nextLink': `${BASE}/me/drive/root:/x:/children?$skiptoken=PAGE2` + }) + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ value: [{ id: '2', name: 'b.cbz', file: {} }] }) + } as Response); + + const items = await listChildren('TOKEN', 'mokuro-reader'); + + // All children across both pages are returned, in order. + expect(items.map((i) => i.id)).toEqual(['1', '2']); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + // The second request goes to the nextLink URL verbatim... + expect(vi.mocked(fetch).mock.calls[1][0]).toBe( + `${BASE}/me/drive/root:/x:/children?$skiptoken=PAGE2` + ); + // ...and still carries the auth header. + expect((vi.mocked(fetch).mock.calls[1][1] as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer TOKEN' + }); + }); }); describe('getItemByPath', () => { diff --git a/src/lib/util/sync/providers/onedrive/graph-client.ts b/src/lib/util/sync/providers/onedrive/graph-client.ts index 6bd015cd..6b6cff0b 100644 --- a/src/lib/util/sync/providers/onedrive/graph-client.ts +++ b/src/lib/util/sync/providers/onedrive/graph-client.ts @@ -40,13 +40,21 @@ export async function getDriveQuota(accessToken: string): Promise { } export async function listChildren(accessToken: string, path: string): Promise { - const url = path + // Graph pages children (default ~200 per response); follow @odata.nextLink so + // large folders (a library with hundreds of series, or a series with many + // files) are not silently truncated. + let url: string = path ? `${BASE}/me/drive/root:/${encodePath(path)}:/children` : `${BASE}/me/drive/root/children`; - const response = await fetch(url, { headers: authHeaders(accessToken) }); - if (!response.ok) await parseError(response); - const data = (await response.json()) as { value: DriveItem[] }; - return data.value; + const items: DriveItem[] = []; + while (url) { + const response = await fetch(url, { headers: authHeaders(accessToken) }); + if (!response.ok) await parseError(response); + const data = (await response.json()) as { value: DriveItem[]; '@odata.nextLink'?: string }; + items.push(...data.value); + url = data['@odata.nextLink'] ?? ''; + } + return items; } export async function getItemByPath(accessToken: string, path: string): Promise { From 0a8c0dd51195fed4ae6188e80cc2d139ad7dbf6c Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Tue, 2 Jun 2026 16:21:13 -0700 Subject: [PATCH 45/65] fix(onedrive): surface deep-folder 404s instead of returning a partial list listCloudVolumes' recursive walk caught any error whose message contained "404" and treated it as an empty folder. A 404 raised while listing a deep subfolder therefore silently dropped those volumes, and a following progress sync could overwrite good local data with the truncated set. Probe the root folder once up front (getItemByPath returns null cleanly on 404) and let real listing errors propagate from the walk. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/onedrive-provider.test.ts | 68 +++++++++++++++++++ .../providers/onedrive/onedrive-provider.ts | 22 +++--- 2 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts diff --git a/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts b/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts new file mode 100644 index 00000000..c118e748 --- /dev/null +++ b/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Keep the constructor cheap (no MSAL init) by reporting a non-browser env. +vi.mock('$app/environment', () => ({ browser: false })); + +// The provider grabs a worker core at construction; a dummy is enough here. +vi.mock('../../../core/cloud-provider-core-registry', () => ({ + getCloudProviderCore: vi.fn(() => ({})) +})); + +// Token manager: authenticated with a static token. +vi.mock('../token-manager', () => ({ + onedriveTokenManager: { + initialize: vi.fn().mockResolvedValue(undefined), + isAuthenticated: vi.fn().mockReturnValue(true), + getAccessToken: vi.fn().mockResolvedValue('TOKEN') + } +})); + +// Graph client: every network call is a spy we drive per test. +vi.mock('../graph-client', () => ({ + getItemByPath: vi.fn(), + listChildren: vi.fn(), + createFolder: vi.fn(), + deleteItem: vi.fn(), + getDriveQuota: vi.fn(), + patchItem: vi.fn() +})); + +import { OneDriveProvider } from '../onedrive-provider'; +import { getItemByPath, listChildren } from '../graph-client'; + +describe('OneDriveProvider.listCloudVolumes', () => { + let provider: OneDriveProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new OneDriveProvider(); + }); + + it('returns empty when the root mokuro folder does not exist', async () => { + vi.mocked(getItemByPath).mockResolvedValue(null); + + await expect(provider.listCloudVolumes()).resolves.toEqual([]); + // Must not attempt to walk a folder it knows is absent. + expect(vi.mocked(listChildren)).not.toHaveBeenCalled(); + }); + + it('propagates a non-404 listing error instead of swallowing it', async () => { + vi.mocked(getItemByPath).mockResolvedValue({ id: 'root', name: 'mokuro-reader' }); + vi.mocked(listChildren).mockRejectedValue(new Error('Graph 500 Internal Server Error: ')); + + await expect(provider.listCloudVolumes()).rejects.toThrow(/500/); + }); + + it('does NOT swallow a 404 raised while listing a deep subfolder', async () => { + vi.mocked(getItemByPath).mockResolvedValue({ id: 'root', name: 'mokuro-reader' }); + vi.mocked(listChildren) + // Top-level mokuro folder lists one series subfolder... + .mockResolvedValueOnce([{ id: 'series', name: 'Naruto', folder: {} }]) + // ...and listing that subfolder fails with a real 404. + .mockRejectedValueOnce(new Error('Graph 404 Not Found: ')); + + // A deep 404 means missing data, not "empty library" — it must surface, + // not silently drop the affected volumes and return a partial set. + await expect(provider.listCloudVolumes()).rejects.toThrow(/404/); + }); +}); diff --git a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts index a62257d6..337103ab 100644 --- a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts +++ b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts @@ -17,8 +17,7 @@ import { getDriveQuota, getItemByPath, listChildren, - patchItem, - type DriveItem + patchItem } from './graph-client'; import { getCloudProviderCore } from '../../core/cloud-provider-core-registry'; @@ -166,14 +165,19 @@ export class OneDriveProvider implements SyncProvider { const results: CloudFileMetadata[] = []; + // If the root mokuro folder doesn't exist yet, there's nothing to list. + // Probe it once here (getItemByPath cleanly returns null on 404) rather than + // swallowing 404s inside the recursive walk — a 404 raised while listing a + // deep subfolder means missing data, not "empty library", and must surface + // so a later progress sync can't overwrite good data with a truncated set. + const root = await getItemByPath(token, ONEDRIVE_CONFIG.MOKURO_FOLDER); + if (!root) { + console.log('OneDrive mokuro folder does not exist yet; nothing to list'); + return results; + } + const walk = async (path: string): Promise => { - const children = await listChildren(token, path).catch((error) => { - // If the root mokuro folder doesn't exist, treat as empty - if (error instanceof Error && error.message.includes('404')) { - return [] as DriveItem[]; - } - throw error; - }); + const children = await listChildren(token, path); for (const item of children) { const childPath = path ? `${path}/${item.name}` : item.name; if (item.folder) { From 827dc8ff3a36392ae84442d9a7656cca3a9a4da8 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Tue, 2 Jun 2026 16:21:13 -0700 Subject: [PATCH 46/65] fix(filesystem): prevent data loss when renaming a folder into itself renameFolder copied files to the new path then recursively deleted the old folder. When the new path nested under the old one (e.g. a free-text rename of "Series" to "Series/Archive"), the recursive delete destroyed the files just written into the subfolder. Skip the cleanup when the new path nests under the old folder. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/filesystem-provider.test.ts | 135 ++++++++++++++++++ .../filesystem/filesystem-provider.ts | 24 ++-- 2 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts diff --git a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts new file mode 100644 index 00000000..b958d200 --- /dev/null +++ b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Constructor should not attempt to restore a stored handle in tests. +vi.mock('$app/environment', () => ({ browser: false })); + +import { FilesystemProvider } from '../filesystem-provider'; + +// --------------------------------------------------------------------------- +// Minimal in-memory File System Access API fake (just enough for the provider: +// getDirectoryHandle / getFileHandle / removeEntry / values / createWritable). +// --------------------------------------------------------------------------- +let clock = 1000; + +function notFound(name: string): Error { + const e = new Error(`A requested entry was not found: ${name}`); + e.name = 'NotFoundError'; + return e; +} + +class FakeWritable { + private parts: BlobPart[] = []; + constructor(private handle: FakeFileHandle) {} + async write(data: BlobPart) { + this.parts.push(data); + } + async close() { + this.handle._blob = new Blob(this.parts); + this.handle._lastModified = clock++; + } +} + +class FakeFileHandle { + readonly kind = 'file' as const; + _blob: Blob = new Blob([]); + _lastModified = clock++; + constructor(public name: string) {} + async getFile(): Promise { + return new File([this._blob], this.name, { lastModified: this._lastModified }); + } + async createWritable() { + // Real createWritable truncates existing content by default. + this._blob = new Blob([]); + return new FakeWritable(this); + } +} + +class FakeDirHandle { + readonly kind = 'directory' as const; + children = new Map(); + constructor(public name: string) {} + async getDirectoryHandle(name: string, opts?: { create?: boolean }) { + let h = this.children.get(name); + if (!h) { + if (!opts?.create) throw notFound(name); + h = new FakeDirHandle(name); + this.children.set(name, h); + } + if (h.kind !== 'directory') throw new Error(`TypeMismatch: ${name} is a file`); + return h; + } + async getFileHandle(name: string, opts?: { create?: boolean }) { + let h = this.children.get(name); + if (!h) { + if (!opts?.create) throw notFound(name); + h = new FakeFileHandle(name); + this.children.set(name, h); + } + if (h.kind !== 'file') throw new Error(`TypeMismatch: ${name} is a directory`); + return h; + } + async removeEntry(name: string, _opts?: { recursive?: boolean }) { + if (!this.children.has(name)) throw notFound(name); + this.children.delete(name); + } + async *values() { + yield* this.children.values(); + } +} + +async function seedFile(root: FakeDirHandle, path: string, content: string) { + const segments = path.split('/'); + const filename = segments.pop() as string; + let dir = root; + for (const s of segments) dir = await dir.getDirectoryHandle(s, { create: true }); + const fh = await dir.getFileHandle(filename, { create: true }); + const w = await fh.createWritable(); + await w.write(new Blob([content])); + await w.close(); +} + +function makeProvider(root: FakeDirHandle): FilesystemProvider { + const provider = new FilesystemProvider(); + // Inject the fake root directly (private field, set via cast for the test). + (provider as unknown as { rootHandle: FileSystemDirectoryHandle }).rootHandle = + root as unknown as FileSystemDirectoryHandle; + return provider; +} + +describe('FilesystemProvider.renameFolder', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('moves files and removes the old folder on a normal rename', async () => { + const root = new FakeDirHandle(''); + await seedFile(root, 'Naruto/v1.cbz', 'NARUTO-V1'); + const provider = makeProvider(root); + + await provider.renameFolder('Naruto', 'Boruto'); + + const paths = (await provider.listCloudVolumes()).map((v) => v.path); + expect(paths).toContain('Boruto/v1.cbz'); + expect(paths).not.toContain('Naruto/v1.cbz'); + expect(root.children.has('Naruto')).toBe(false); + }); + + it('does not destroy data when the new path nests under the old folder', async () => { + const content = 'NARUTO-V1'; + const expectedSize = new Blob([content]).size; + const root = new FakeDirHandle(''); + await seedFile(root, 'Naruto/v1.cbz', content); + const provider = makeProvider(root); + + // e.g. a user renames series "Naruto" to "Naruto/Archive" (free-text input). + await provider.renameFolder('Naruto', 'Naruto/Archive'); + + const vols = await provider.listCloudVolumes(); + const moved = vols.find((v) => v.path === 'Naruto/Archive/v1.cbz'); + expect(moved, 'the renamed file must still exist').toBeDefined(); + + // ...and its bytes must be intact (not truncated/destroyed by the cleanup + // that, with the bug, recursively deleted the whole old folder). + expect(moved!.size).toBe(expectedSize); + }); +}); diff --git a/src/lib/util/sync/providers/filesystem/filesystem-provider.ts b/src/lib/util/sync/providers/filesystem/filesystem-provider.ts index 8afce779..90abf23a 100644 --- a/src/lib/util/sync/providers/filesystem/filesystem-provider.ts +++ b/src/lib/util/sync/providers/filesystem/filesystem-provider.ts @@ -322,15 +322,21 @@ export class FilesystemProvider implements SyncProvider { renamed.push(await this.renameFile(file, target)); } - // Best-effort cleanup of the now-empty old folder - try { - const parentPath = getParentPath(normalizedOld); - const parent = parentPath - ? await this.resolveDirectoryHandle(parentPath, { create: false }) - : this.requireRoot(); - await parent.removeEntry(getBasename(normalizedOld), { recursive: true }); - } catch { - // Already gone or never existed — fine + // Best-effort cleanup of the now-empty old folder. + // Skip when the new path nests inside the old folder (e.g. "Series" -> "Series/Archive"): + // the renamed files now live under the old folder, so a recursive delete would destroy + // the very files we just wrote. + const newNestsUnderOld = normalizedNew.startsWith(`${normalizedOld}/`); + if (!newNestsUnderOld) { + try { + const parentPath = getParentPath(normalizedOld); + const parent = parentPath + ? await this.resolveDirectoryHandle(parentPath, { create: false }) + : this.requireRoot(); + await parent.removeEntry(getBasename(normalizedOld), { recursive: true }); + } catch { + // Already gone or never existed — fine + } } console.log(`✅ Renamed folder ${normalizedOld} → ${normalizedNew} in filesystem`); From bb279280a850f5fd950cf4ad44aa628d2696e592 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Tue, 2 Jun 2026 16:21:14 -0700 Subject: [PATCH 47/65] fix(catalog): show display names for filesystem/onedrive providers The catalog's providerNames map was missing the two new providers, so their placeholder breakdowns rendered the raw keys ("2 filesystem") instead of friendly labels. Add Local Folder / OneDrive entries. Co-Authored-By: Claude Opus 4.8 --- src/lib/components/Catalog.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/components/Catalog.svelte b/src/lib/components/Catalog.svelte index 2ef960bf..8ebbda83 100644 --- a/src/lib/components/Catalog.svelte +++ b/src/lib/components/Catalog.svelte @@ -247,7 +247,9 @@ const providerNames: Record = { 'google-drive': 'Drive', mega: 'MEGA', - webdav: 'WebDAV' + webdav: 'WebDAV', + filesystem: 'Local Folder', + onedrive: 'OneDrive' }; return Object.entries(placeholdersByProvider) .map(([provider, count]) => `${count} ${providerNames[provider] || provider}`) From 20bc461ad3971b21ea1f28069e217678db5da090 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:13:29 -0700 Subject: [PATCH 48/65] docs: deployment-readiness plan for onedrive/filesystem providers --- ...nedrive-filesystem-deployment-readiness.md | 1866 +++++++++++++++++ 1 file changed, 1866 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-onedrive-filesystem-deployment-readiness.md diff --git a/docs/superpowers/plans/2026-07-05-onedrive-filesystem-deployment-readiness.md b/docs/superpowers/plans/2026-07-05-onedrive-filesystem-deployment-readiness.md new file mode 100644 index 00000000..264a4394 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-onedrive-filesystem-deployment-readiness.md @@ -0,0 +1,1866 @@ +# OneDrive + Filesystem Provider Deployment Readiness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring the OneDrive and Filesystem sync providers to parity with the mature providers (Google Drive, MEGA, WebDAV) so `feat/filesystem-provider` is deployment-ready. + +**Architecture:** All five providers implement `SyncProvider` (`src/lib/util/sync/provider-interface.ts`). Fixes are provider-local except one shared `isSyncableFile` module (new) and small UI/docs updates. Error signaling follows existing conventions: typed `ProviderError` (code `NOT_FOUND` consumed by `unified-cloud-manager.ts:28`; message substrings `not found`/`404`/`ENOENT` sniffed by `unified-sync-service.ts:360-364`), and `getStatus().needsAttention` drives the UI reconnect state (refreshed via the dynamic-import `providerManager.updateStatus()` pattern from `webdav-provider.ts:110-115`). + +**Tech Stack:** SvelteKit 5 (runes), TypeScript, Vitest (jsdom), MSAL (`@azure/msal-browser`, redirect flow), Microsoft Graph REST, File System Access API, Dexie-free IndexedDB helper (`handle-store.ts`). + +## Global Constraints + +- All work happens in the worktree `/home/nathan/Projects/mokuro-reader-worktrees/feat/filesystem-provider` on branch `feat/filesystem-provider`. Never commit in the main checkout. +- Do NOT push. Local commits only. +- Commit hooks run prettier + eslint via lint-staged; if a commit fails on formatting, run `npm run format` and re-stage. +- Test command: `npx vitest run ` (or `npm test -- --run `). Full gates at the end: `npm run check`, `npx vitest run`, `npm run lint`. +- Svelte 5 runes (`$state`, `$derived`) in components; no legacy `$:` reactivity. +- Baseline before Task 1: 887 tests passing, 0 svelte-check errors (verified at HEAD `07803b9e`). +- Do not regress the EXCEED items: onedrive `@odata.nextLink` pagination, onedrive deep-404 propagation in `listCloudVolumes`, onedrive per-segment `encodeURIComponent` path escaping, both new providers' `reauthenticate()` support, filesystem synchronous logout ordering. + +--- + +### Task 1: Shared `isSyncableFile` module (adds `libraries.json` + `.jpg/.jpeg` for filesystem/onedrive) + +The filter is duplicated at 6 sites with two divergences: filesystem/onedrive silently drop `.jpg/.jpeg` sidecars that the mature providers sync, and **no** provider lists `libraries.json`, so `unified-sync-service.ts:738-739` (`cache.get('libraries.json')`) always returns null and library sync silently no-ops on every provider. + +**Files:** + +- Create: `src/lib/util/sync/syncable-file.ts` +- Create: `src/lib/util/sync/syncable-file.test.ts` +- Modify: `src/lib/util/sync/providers/filesystem/filesystem-paths.ts:20-29` +- Modify: `src/lib/util/sync/providers/onedrive/onedrive-provider.ts:24-34` +- Modify: `src/lib/util/sync/providers/webdav/webdav-provider.ts:685-694` and `:747-756` +- Modify: `src/lib/util/sync/providers/mega/mega-provider.ts:593-603` +- Modify: `src/lib/util/sync/providers/google-drive/google-drive-provider.ts:210-227` + +**Interfaces:** + +- Produces: `isSyncableFile(path: string): boolean`, `isCbzFile(basename: string): boolean`, `isSidecarFile(basename: string): boolean`, `isRootConfigFile(basename: string): boolean` — all case-insensitive, exported from `$lib/util/sync/syncable-file`. + +- [ ] **Step 1: Write the failing test** + +`src/lib/util/sync/syncable-file.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { isSyncableFile, isCbzFile, isSidecarFile, isRootConfigFile } from './syncable-file'; + +describe('syncable-file', () => { + it('accepts cbz, mokuro, mokuro.gz anywhere in the tree', () => { + expect(isSyncableFile('Series/Vol 1.cbz')).toBe(true); + expect(isSyncableFile('Series/Vol 1.mokuro')).toBe(true); + expect(isSyncableFile('Series/Vol 1.mokuro.gz')).toBe(true); + }); + + it('accepts webp AND jpg/jpeg sidecar thumbnails (parity with mature providers)', () => { + expect(isSyncableFile('Series/Vol 1.webp')).toBe(true); + expect(isSyncableFile('Series/Vol 1.jpg')).toBe(true); + expect(isSyncableFile('Series/Vol 1.JPEG')).toBe(true); + }); + + it('accepts the three root config files, including libraries.json', () => { + expect(isSyncableFile('volume-data.json')).toBe(true); + expect(isSyncableFile('profiles.json')).toBe(true); + expect(isSyncableFile('libraries.json')).toBe(true); + }); + + it('rejects everything else', () => { + expect(isSyncableFile('Series/notes.txt')).toBe(false); + expect(isSyncableFile('Series/random.json')).toBe(false); + expect(isSyncableFile('desktop.ini')).toBe(false); + }); + + it('is case-insensitive and uses the basename only', () => { + expect(isSyncableFile('Series/VOL.CBZ')).toBe(true); + expect(isSyncableFile('a/b/c/LIBRARIES.JSON')).toBe(true); + }); + + it('exposes category predicates for providers that bucket by type', () => { + expect(isCbzFile('v.cbz')).toBe(true); + expect(isSidecarFile('v.mokuro')).toBe(true); + expect(isSidecarFile('v.jpeg')).toBe(true); + expect(isSidecarFile('v.cbz')).toBe(false); + expect(isRootConfigFile('libraries.json')).toBe(true); + expect(isRootConfigFile('v.cbz')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/util/sync/syncable-file.test.ts` +Expected: FAIL — module `./syncable-file` not found. + +- [ ] **Step 3: Write the implementation** + +`src/lib/util/sync/syncable-file.ts`: + +```typescript +/** + * The single source of truth for which files sync providers list and cache. + * Shared by ALL five providers — do not fork per-provider copies again. + * + * Categories: + * - CBZ archives (the volumes themselves) + * - Sidecars: OCR data (.mokuro / .mokuro.gz) and thumbnails (.webp/.jpg/.jpeg) + * - Root config files: volume-data.json (read progress), profiles.json + * (settings profiles), libraries.json (library definitions) + */ + +const ROOT_CONFIG_FILENAMES = new Set(['volume-data.json', 'profiles.json', 'libraries.json']); +const SIDECAR_IMAGE_RE = /\.(webp|jpe?g)$/i; + +function basenameOf(path: string): string { + return path.split('/').filter(Boolean).pop() ?? ''; +} + +export function isCbzFile(basename: string): boolean { + return basename.toLowerCase().endsWith('.cbz'); +} + +export function isSidecarFile(basename: string): boolean { + const lower = basename.toLowerCase(); + return lower.endsWith('.mokuro') || lower.endsWith('.mokuro.gz') || SIDECAR_IMAGE_RE.test(lower); +} + +export function isRootConfigFile(basename: string): boolean { + return ROOT_CONFIG_FILENAMES.has(basename.toLowerCase()); +} + +export function isSyncableFile(path: string): boolean { + const basename = basenameOf(path); + return isCbzFile(basename) || isSidecarFile(basename) || isRootConfigFile(basename); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/util/sync/syncable-file.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Wire filesystem** — replace the local filter in `filesystem-paths.ts` (keep the export so `filesystem-provider.ts:14` and existing path tests keep working): + +Replace lines 20-29 of `src/lib/util/sync/providers/filesystem/filesystem-paths.ts`: + +```typescript +export { isSyncableFile } from '../../syncable-file'; +``` + +(Delete the `SYNCABLE_EXTENSIONS` / `SYNCABLE_ROOT_FILENAMES` constants and the old function body.) + +- [ ] **Step 6: Wire onedrive** — in `onedrive-provider.ts`, delete the module-level `isSyncableFile` function (lines 24-34) and add to the imports from `'../../provider-interface'` block area: + +```typescript +import { isSyncableFile } from '../../syncable-file'; +``` + +- [ ] **Step 7: Wire webdav (both sites)** — in `webdav-provider.ts`, add `import { isSyncableFile } from '../../syncable-file';` and replace BOTH inline conditions (at ~:687-694 and ~:749-756): + +```typescript + // Include CBZ files, sidecars, and JSON config files + if (isSyncableFile(item.basename)) { +``` + +(Delete the multi-line `name.endsWith(...) || ... || item.basename === 'profiles.json'` condition and the now-unused `const name = item.basename.toLowerCase();` line at each site.) + +- [ ] **Step 8: Wire mega** — in `mega-provider.ts` (~:593-603), add the same import and replace the `isCbz`/`isSidecar`/`isJson` block. The downstream code uses those three booleans — check with `grep -n "isCbz\|isSidecar\|isJson" src/lib/util/sync/providers/mega/mega-provider.ts` and keep them, deriving from the shared helpers: + +```typescript +const name = (file as any).name || ''; +const isCbz = isCbzFile(name); +const isSidecar = isSidecarFile(name); +const isJson = isRootConfigFile(name); + +if (!isCbz && !isSidecar && !isJson) continue; +``` + +Import: `import { isCbzFile, isSidecarFile, isRootConfigFile } from '../../syncable-file';` + +- [ ] **Step 9: Wire google-drive** — in `google-drive-provider.ts:210-227` the filter buckets into `cbzFiles`/`sidecarFiles`/`jsonFiles`. Replace the conditions with the shared predicates (keep the buckets): + +```typescript +for (const item of allItems) { + if (item.mimeType === GOOGLE_DRIVE_CONFIG.MIME_TYPES.FOLDER) { + folderNames.set(item.id, item.name); + } else if (isCbzFile(item.name)) { + cbzFiles.push(item); + } else if (isSidecarFile(item.name)) { + sidecarFiles.push(item); + } else if (isRootConfigFile(item.name)) { + jsonFiles.push(item); + } +} +``` + +Import: `import { isCbzFile, isSidecarFile, isRootConfigFile } from '../../syncable-file';` + +BEFORE editing, check what the `jsonFiles` bucket feeds (`grep -n "jsonFiles" src/lib/util/sync/providers/google-drive/google-drive-provider.ts`) — it must simply become cache entries keyed by name at root (same as volume-data.json/profiles.json). If it special-cases the two known names, extend it generically; do not hardcode a third name. + +- [ ] **Step 10: Run affected tests + typecheck** + +Run: `npx vitest run src/lib/util/sync` and `npm run check` +Expected: all pass. The existing `filesystem-paths` tests (7) must still pass — if one asserts `.jpg` is NOT syncable, update that assertion (the old behavior was the bug). + +- [ ] **Step 11: Commit** + +```bash +git add -A && git commit -m "fix(sync): shared isSyncableFile — libraries.json syncs, jpg sidecars on all providers" +``` + +--- + +### Task 2: OneDrive token-manager hardening (logout ordering, interaction_in_progress, markNeedsAttention, delete dead code) + +**Files:** + +- Modify: `src/lib/util/sync/providers/onedrive/token-manager.ts` +- Create: `src/lib/util/sync/providers/onedrive/__tests__/token-manager.test.ts` + +**Interfaces:** + +- Produces: `onedriveTokenManager.markNeedsAttention(): void` (used by Task 4's graph-client 401 handling). +- Removes: `hasPendingRedirect()` — it is called nowhere, and its `url.searchParams.has('code')` check is wrong anyway (MSAL SPA redirect returns the code in the URL _fragment_, so it could never return true). Deleting dead+broken beats wiring it in; the `whenReady()` flow in `init-providers.ts:108-118` already sequences redirect completion before any bootstrap fetch. + +- [ ] **Step 1: Write the failing tests** + +`src/lib/util/sync/providers/onedrive/__tests__/token-manager.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('$app/environment', () => ({ browser: true })); + +// Capture-order log shared between the msal mock and assertions. +const calls: string[] = []; + +class FakeBrowserAuthError extends Error { + constructor(public errorCode: string) { + super(errorCode); + } +} +class FakeInteractionRequiredAuthError extends Error {} + +const fakeAccount = { name: 'Test User', username: 'test@example.com' }; + +const fakeInstance = { + initialize: vi.fn(async () => {}), + handleRedirectPromise: vi.fn(async () => null), + getAllAccounts: vi.fn(() => [fakeAccount]), + setActiveAccount: vi.fn(), + loginRedirect: vi.fn(async () => { + calls.push('loginRedirect'); + }), + acquireTokenRedirect: vi.fn(async () => { + calls.push('acquireTokenRedirect'); + }), + acquireTokenSilent: vi.fn(async () => ({ accessToken: 'tok' })), + logoutRedirect: vi.fn(async () => { + calls.push(`logoutRedirect(hasAuth=${localStorage.getItem('onedrive_has_authenticated')})`); + }) +}; + +vi.mock('@azure/msal-browser', () => ({ + PublicClientApplication: vi.fn(() => fakeInstance), + BrowserAuthError: FakeBrowserAuthError, + InteractionRequiredAuthError: FakeInteractionRequiredAuthError +})); + +async function freshManager() { + vi.resetModules(); + vi.stubEnv('VITE_ONEDRIVE_CLIENT_ID', 'test-client-id'); + const { onedriveTokenManager } = await import('../token-manager'); + return onedriveTokenManager; +} + +describe('OneDriveTokenManager', () => { + beforeEach(() => { + calls.length = 0; + localStorage.clear(); + vi.clearAllMocks(); + fakeInstance.getAllAccounts.mockReturnValue([fakeAccount]); + }); + + it('logout clears local state BEFORE the logoutRedirect navigation', async () => { + localStorage.setItem('onedrive_has_authenticated', 'true'); + localStorage.setItem('onedrive_login_pending', 'true'); + const mgr = await freshManager(); + await mgr.initialize(); + + await mgr.logout(); + + // The redirect call must observe already-cleared storage. + expect(calls).toContain('logoutRedirect(hasAuth=null)'); + expect(localStorage.getItem('onedrive_login_pending')).toBeNull(); + }); + + it('login surfaces a friendly error when an interaction is already in progress', async () => { + fakeInstance.loginRedirect.mockRejectedValueOnce( + new FakeBrowserAuthError('interaction_in_progress') + ); + const mgr = await freshManager(); + await expect(mgr.login()).rejects.toThrow(/already in progress/i); + }); + + it('reauthenticate surfaces a friendly error when an interaction is already in progress', async () => { + fakeInstance.acquireTokenRedirect.mockRejectedValueOnce( + new FakeBrowserAuthError('interaction_in_progress') + ); + const mgr = await freshManager(); + await mgr.initialize(); + await expect(mgr.reauthenticate()).rejects.toThrow(/already in progress/i); + }); + + it('markNeedsAttention flips the needsAttention store', async () => { + const mgr = await freshManager(); + let value = false; + mgr.needsAttention.subscribe((v) => (value = v))(); + mgr.markNeedsAttention(); + mgr.needsAttention.subscribe((v) => (value = v))(); + expect(value).toBe(true); + }); + + it('no longer exposes the dead hasPendingRedirect helper', async () => { + const mgr = await freshManager(); + expect((mgr as any).hasPendingRedirect).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/lib/util/sync/providers/onedrive/__tests__/token-manager.test.ts` +Expected: FAIL — logout ordering (hasAuth=true at redirect time), no friendly error, `markNeedsAttention` undefined, `hasPendingRedirect` defined. + +- [ ] **Step 3: Implement in `token-manager.ts`** + +3a. Delete the `hasPendingRedirect()` method (lines 99-109) entirely. + +3b. Replace `logout()` (lines 147-161): + +```typescript + async logout(): Promise { + // Snapshot what we need for the redirect, then clear ALL local state + // FIRST — logoutRedirect() navigates the window away, so anything after + // it never runs. + const instance = this.instance; + const account = this.account; + this.account = null; + this.tokenStore.set(''); + this.needsAttentionStore.set(false); + if (browser) { + localStorage.removeItem(ONEDRIVE_CONFIG.STORAGE_KEYS.HAS_AUTHENTICATED); + localStorage.removeItem(PENDING_LOGIN_KEY); + } + if (instance && account) { + await instance.logoutRedirect({ + account, + postLogoutRedirectUri: window.location.origin + }); + } + } +``` + +3c. Add a private helper and use it in `login()` and `reauthenticate()`: + +```typescript + /** + * MSAL throws BrowserAuthError("interaction_in_progress") when a redirect + * is already in flight (double-clicked button, or a stale lock after the + * user backed out of the Microsoft login page). Surface it as a friendly, + * actionable message instead of a raw MSAL crash. + */ + private translateInteractionError(error: unknown): Error { + if ( + this.msal && + error instanceof this.msal.BrowserAuthError && + error.errorCode === 'interaction_in_progress' + ) { + return new Error( + 'Microsoft sign-in is already in progress. Finish the login window, or reload this page and try again.' + ); + } + return error instanceof Error ? error : new Error(String(error)); + } +``` + +In `login()`, wrap the redirect call: + +```typescript +try { + await this.instance.loginRedirect(request); +} catch (error) { + localStorage.removeItem(PENDING_LOGIN_KEY); + throw this.translateInteractionError(error); +} +``` + +In `reauthenticate()`, wrap the same way: + +```typescript +try { + await this.instance.acquireTokenRedirect(request); +} catch (error) { + localStorage.removeItem(PENDING_LOGIN_KEY); + throw this.translateInteractionError(error); +} +``` + +3d. Add the public method next to `getAccessToken()`: + +```typescript + /** Flag the session as needing user re-authentication (e.g. Graph 401). */ + markNeedsAttention(): void { + this.needsAttentionStore.set(true); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/lib/util/sync/providers/onedrive/__tests__/token-manager.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "fix(onedrive): logout clears state before redirect; handle interaction_in_progress" +``` + +--- + +### Task 3: OneDrive provider logout ordering, init-error surfacing, provider-manager force-clear + +**Files:** + +- Modify: `src/lib/util/sync/providers/onedrive/onedrive-provider.ts:44-55` (constructor), `:65-88` (getStatus), `:131-135` (logout) +- Modify: `src/lib/util/sync/provider-manager.ts:191-203` +- Modify: `src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts` (follow its existing mock setup — it already mocks the token manager for `listCloudVolumes` tests) + +**Interfaces:** + +- Consumes: Task 2's reordered `onedriveTokenManager.logout()`. +- Produces: `getStatus()` distinguishes a failed MSAL init (`statusMessage: 'OneDrive initialization failed: …'`) from "Not configured". + +- [ ] **Step 1: Write the failing tests** (add to `onedrive-provider.test.ts`, reusing its mock style): + +```typescript +describe('logout', () => { + it('clears the active provider key before the token-manager redirect', async () => { + const order: string[] = []; + vi.mocked(clearActiveProviderKey).mockImplementation(() => { + order.push('clearActiveProviderKey'); + }); + vi.mocked(onedriveTokenManager.logout).mockImplementation(async () => { + order.push('tokenManager.logout'); + }); + + const provider = new OneDriveProvider(); + await provider.logout(); + + expect(order).toEqual(['clearActiveProviderKey', 'tokenManager.logout']); + }); +}); + +describe('getStatus after failed MSAL init', () => { + it('reports an initialization failure instead of "Not configured"', async () => { + vi.mocked(onedriveTokenManager.initialize).mockRejectedValueOnce( + new Error('VITE_ONEDRIVE_CLIENT_ID is not set') + ); + const provider = new OneDriveProvider(); + await provider.whenReady(); + const status = provider.getStatus(); + expect(status.statusMessage).toMatch(/initialization failed/i); + expect(status.isAuthenticated).toBe(false); + }); +}); +``` + +(Adjust mock references to match the file's existing `vi.mock` declarations; the test file already mocks `../token-manager` and `../../provider-detection` — check its top-of-file setup with `head -40` and mirror it. If it only mocks partially, extend the mock factory with `logout`, `initialize` fns.) + +- [ ] **Step 2: Run to verify failures** + +Run: `npx vitest run src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts` +Expected: FAIL — wrong order (`tokenManager.logout` first) and status says "Not configured". + +- [ ] **Step 3: Implement** + +3a. Constructor — track init errors (replace lines 47-55): + +```typescript + private initError: Error | null = null; + + constructor() { + if (browser) { + this.initPromise = onedriveTokenManager.initialize().catch((error) => { + // A missing/invalid client id is a deployment misconfiguration, not + // a "user never connected" state. Track it so getStatus() can say so. + this.initError = error instanceof Error ? error : new Error(String(error)); + console.warn('OneDrive MSAL init failed:', error); + }); + } else { + this.initPromise = Promise.resolve(); + } + } +``` + +3b. `getStatus()` — add at the top of the method: + +```typescript +if (this.initError) { + return { + isAuthenticated: false, + hasStoredCredentials: onedriveTokenManager.hasStoredCredentials(), + needsAttention: false, + statusMessage: `OneDrive initialization failed: ${this.initError.message}` + }; +} +``` + +3c. `logout()` — reorder (replace lines 131-135): + +```typescript + async logout(): Promise { + // Clear the active-provider key BEFORE the token manager's logout — + // logoutRedirect() navigates the window away and nothing after it runs. + clearActiveProviderKey(); + console.log('OneDrive logged out'); + await onedriveTokenManager.logout(); + } +``` + +3d. `provider-manager.ts` force-clear — extend the block at lines 193-203: + +```typescript +// MEGA +localStorage.removeItem('mega_session'); +localStorage.removeItem('mega_email'); +localStorage.removeItem('mega_password'); +localStorage.removeItem('mega_folder_path'); +// OneDrive (MSAL's own msal.* cache entries are cleared by MSAL itself) +localStorage.removeItem('onedrive_has_authenticated'); +localStorage.removeItem('onedrive_login_pending'); +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run src/lib/util/sync/providers/onedrive` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "fix(onedrive): surface init errors, clear provider key before logout redirect" +``` + +--- + +### Task 4: Graph error classification → typed ProviderError + 401 needsAttention + +Every Graph failure currently surfaces as a plain `Error('Graph 401 …')` — never `isAuthError`, never flips the reconnect UI. WebDAV's `classifyWriteError` is the bar. + +**Files:** + +- Modify: `src/lib/util/sync/providers/onedrive/graph-client.ts:30-33` (parseError) +- Modify: `src/lib/util/sync/providers/onedrive/onedrive-provider.ts` (`uploadFile`/`downloadFile` wrap) +- Modify: `src/lib/util/sync/providers/onedrive/__tests__/graph-client.test.ts` + +**Interfaces:** + +- Consumes: `onedriveTokenManager.markNeedsAttention()` from Task 2. +- Produces: all graph-client throws are `ProviderError` with `providerType: 'onedrive'`, `code: 'GRAPH_'`, `isAuthError` on 401, `isNetworkError` on 429/5xx. Message keeps the `Graph : ` shape (the `404` token is sniffed by `unified-sync-service.ts:360-364`). + +- [ ] **Step 1: Write the failing tests** (add to `graph-client.test.ts`): + +```typescript +import { ProviderError } from '../../../provider-interface'; +import { onedriveTokenManager } from '../token-manager'; + +describe('error classification', () => { + it('throws ProviderError with isAuthError on 401 and flags needsAttention', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'token expired' + } as Response); + + const err = await getDriveQuota('TOKEN').catch((e) => e); + expect(err).toBeInstanceOf(ProviderError); + expect(err.isAuthError).toBe(true); + expect(err.code).toBe('GRAPH_401'); + + let attention = false; + onedriveTokenManager.needsAttention.subscribe((v) => (attention = v))(); + expect(attention).toBe(true); + }); + + it('marks 429 and 5xx as network errors (retryable), not auth errors', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => '' + } as Response); + + const err = await getDriveQuota('TOKEN').catch((e) => e); + expect(err).toBeInstanceOf(ProviderError); + expect(err.isNetworkError).toBe(true); + expect(err.isAuthError).toBe(false); + }); + + it('keeps the Graph message shape for not-found sniffing', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => '' + } as Response); + // listChildren (not getItemByPath, which maps 404 to null) + await expect(listChildren('TOKEN', 'mokuro-reader/x')).rejects.toThrow(/404/); + }); +}); +``` + +Note: this test file will now transitively import `token-manager` → `$app/environment`. If that import fails in the vitest environment, add `vi.mock('$app/environment', () => ({ browser: true }));` at the top of the test file. + +- [ ] **Step 2: Run to verify failures** + +Run: `npx vitest run src/lib/util/sync/providers/onedrive/__tests__/graph-client.test.ts` +Expected: FAIL — plain Error, no classification. + +- [ ] **Step 3: Implement** — replace `parseError` in `graph-client.ts`: + +```typescript +import { ProviderError } from '../../provider-interface'; +import { onedriveTokenManager } from './token-manager'; + +async function parseError(response: Response): Promise { + const text = await response.text().catch(() => ''); + if (response.status === 401) { + // Token rejected server-side (revocation, password change). Silent + // refresh alone won't detect this — flag the session for reconnect. + onedriveTokenManager.markNeedsAttention(); + } + throw new ProviderError( + `Graph ${response.status} ${response.statusText}: ${text || '(no body)'}`, + 'onedrive', + `GRAPH_${response.status}`, + response.status === 401, + response.status === 429 || response.status >= 500 + ); +} +``` + +- [ ] **Step 4: Wrap provider transfer errors** — in `onedrive-provider.ts`, wrap the `uploadFile` core call (lines 225-233): + +```typescript +let fileId: string; +try { + fileId = await this.cloudCore.uploadFile({ + // onedrive-core prefixes its own mokuro-reader root, so pass just the + // bare series title here. + seriesTitle, + filename, + blob: blobToUpload, + credentials, + onProgress + }); +} catch (error) { + if (error instanceof ProviderError) throw error; + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new ProviderError( + `OneDrive upload failed: ${message}`, + 'onedrive', + 'UPLOAD_FAILED', + /\b401\b/.test(message), + /network|timed out|\b429\b|\b5\d\d\b/i.test(message) + ); +} +console.log(`✅ Uploaded ${path} to OneDrive`); +return fileId; +``` + +And the `downloadFile` core call (lines 246-251): + +```typescript +let buffer: ArrayBuffer; +try { + buffer = await this.cloudCore.downloadFile({ + fileId: file.fileId, + credentials, + onProgress: onProgress || (() => {}) + }); +} catch (error) { + if (error instanceof ProviderError) throw error; + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new ProviderError( + `OneDrive download failed: ${message}`, + 'onedrive', + 'DOWNLOAD_FAILED', + /\b401\b/.test(message), + /network|timed out|\b429\b|\b5\d\d\b/i.test(message) + ); +} +return new Blob([buffer], { type: 'application/zip' }); +``` + +- [ ] **Step 5: Run the onedrive suite + typecheck** + +Run: `npx vitest run src/lib/util/sync/providers/onedrive && npm run check` +Expected: PASS. If existing tests asserted plain-Error messages from graph-client, update them to expect `ProviderError` (message shape is unchanged). + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat(onedrive): typed ProviderError classification; 401 flags reconnect" +``` + +--- + +### Task 5: OneDrive folder-creation mutex + 409 tolerance + +`ensureMokuroFolder`/`ensureSeriesFolder` are unguarded check-then-create with `conflictBehavior: 'fail'` — N parallel uploads into a new series race to a Graph 409. MEGA's coalescing pattern (`mega-provider.ts:165-166`, `:458-497`, `:1282-1321`) is the reference. + +**Files:** + +- Modify: `src/lib/util/sync/providers/onedrive/onedrive-provider.ts:141-158` +- Modify: `src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts` + +**Interfaces:** + +- Produces: `ensureMokuroFolder`/`ensureSeriesFolder` are concurrency-safe (single in-flight create per path) and tolerate a 409 from an external racer by re-fetching. + +- [ ] **Step 1: Write the failing test** (add to `onedrive-provider.test.ts`; `prepareUploadTarget` is the public entry into `ensureSeriesFolder`): + +```typescript +describe('folder creation coalescing', () => { + it('creates a missing series folder exactly once under concurrent prepareUploadTarget calls', async () => { + // Follow the file's existing graph-client mock setup. Arrange: + // - getItemByPath: mokuro root exists; series folder missing (null) until created + // - createFolder: resolves after a tick, records call count + let created = false; + vi.mocked(getItemByPath).mockImplementation(async (_t, path) => { + if (path === 'mokuro-reader') return { id: 'root-id', name: 'mokuro-reader', folder: {} }; + return created ? { id: 'series-id', name: 'Series', folder: {} } : null; + }); + vi.mocked(createFolder).mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 10)); + created = true; + return { id: 'series-id', name: 'Series', folder: {} }; + }); + + const provider = new OneDriveProvider(); + await Promise.all([ + provider.prepareUploadTarget('Series'), + provider.prepareUploadTarget('Series'), + provider.prepareUploadTarget('Series') + ]); + + expect(vi.mocked(createFolder)).toHaveBeenCalledTimes(1); + }); + + it('recovers when createFolder 409s because another client already created it', async () => { + const { ProviderError } = await import('../../../provider-interface'); + let calls = 0; + vi.mocked(getItemByPath).mockImplementation(async (_t, path) => { + if (path === 'mokuro-reader') return { id: 'root-id', name: 'mokuro-reader', folder: {} }; + calls++; + return calls > 1 ? { id: 'series-id', name: 'Series', folder: {} } : null; + }); + vi.mocked(createFolder).mockRejectedValue( + new ProviderError('Graph 409 Conflict: nameAlreadyExists', 'onedrive', 'GRAPH_409') + ); + + const provider = new OneDriveProvider(); + await expect(provider.prepareUploadTarget('Series')).resolves.not.toThrow(); + }); +}); +``` + +(If the test file mocks graph-client differently — e.g. via `vi.mock('../graph-client')` with a factory — adapt the `vi.mocked(...)` handles to that factory. Requires the provider mock for `isAuthenticated` to return true; mirror the `listCloudVolumes` tests' arrangement.) + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts` +Expected: FAIL — `createFolder` called 3 times; 409 propagates. + +- [ ] **Step 3: Implement** — replace `ensureMokuroFolder`/`ensureSeriesFolder` (lines 141-158): + +```typescript + // Coalesce concurrent folder creation (MEGA pattern): parallel uploads into + // a new series must not each POST createFolder — Graph 409s on the losers. + private mokuroFolderPromise: Promise | null = null; + private seriesFolderPromises = new Map>(); + + /** + * createFolder uses conflictBehavior 'fail'; if ANOTHER client (worker, + * second tab) won the race, re-fetch and return the existing folder. + */ + private async createFolderTolerant(parentPath: string, name: string): Promise { + const token = await onedriveTokenManager.getAccessToken(); + try { + const created = await createFolder(token, parentPath, name); + return created.id; + } catch (error) { + if (error instanceof ProviderError && error.code === 'GRAPH_409') { + const fullPath = parentPath ? `${parentPath}/${name}` : name; + const existing = await getItemByPath(token, fullPath); + if (existing) return existing.id; + } + throw error; + } + } + + private async ensureMokuroFolder(): Promise { + const token = await onedriveTokenManager.getAccessToken(); + const existing = await getItemByPath(token, ONEDRIVE_CONFIG.MOKURO_FOLDER); + if (existing) return existing.id; + + if (this.mokuroFolderPromise) return this.mokuroFolderPromise; + this.mokuroFolderPromise = (async () => { + try { + const id = await this.createFolderTolerant('', ONEDRIVE_CONFIG.MOKURO_FOLDER); + console.log(`Created ${ONEDRIVE_CONFIG.MOKURO_FOLDER} folder in OneDrive`); + return id; + } finally { + this.mokuroFolderPromise = null; + } + })(); + return this.mokuroFolderPromise; + } + + private async ensureSeriesFolder(seriesTitle: string): Promise { + const token = await onedriveTokenManager.getAccessToken(); + const path = `${ONEDRIVE_CONFIG.MOKURO_FOLDER}/${seriesTitle}`; + const existing = await getItemByPath(token, path); + if (existing) return existing.id; + + const inFlight = this.seriesFolderPromises.get(seriesTitle); + if (inFlight) return inFlight; + + const promise = (async () => { + try { + await this.ensureMokuroFolder(); + return await this.createFolderTolerant(ONEDRIVE_CONFIG.MOKURO_FOLDER, seriesTitle); + } finally { + this.seriesFolderPromises.delete(seriesTitle); + } + })(); + this.seriesFolderPromises.set(seriesTitle, promise); + return promise; + } +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run src/lib/util/sync/providers/onedrive` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "fix(onedrive): coalesce folder creation; tolerate 409 from concurrent clients" +``` + +--- + +### Task 6: OneDrive chunked-upload resilience (retry, resume, 202 drain, timeout) + +The chunk loop (`onedrive-core.ts:79-103`) has zero retry — any blip aborts the whole session; 202 bodies are never read; no fetch has a timeout. `parseNextExpectedRange` exists (`upload-session.ts:39-44`, tested) but was never wired in. + +**Files:** + +- Modify: `src/lib/util/sync/core/providers/onedrive-core.ts:42-109` +- Modify: `src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts` + +**Interfaces:** + +- Consumes: `parseNextExpectedRange(ranges: string[]): number | null` from `../../providers/onedrive/upload-session`. +- Produces: unchanged `onedriveCore.uploadFile(...)` signature; now survives transient chunk failures (408/429/5xx/network) with 400ms→5s backoff, resumes from Graph's `nextExpectedRanges`, and every response body is consumed. + +- [ ] **Step 1: Write the failing tests** (add to `onedrive-core.test.ts`, matching its `vi.stubGlobal('fetch', vi.fn())` style): + +```typescript +it('retries a transient 503 chunk failure, resuming from nextExpectedRanges', async () => { + const CHUNK = 10 * 1024 * 1024; + // Session init + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ uploadUrl: 'https://upload.example/xyz' }) + } as Response); + // Chunk 1 OK (202) + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 202, + json: async () => ({ nextExpectedRanges: [`${CHUNK}-`] }) + } as Response); + // Chunk 2 fails transiently + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => '' + } as Response); + // Session status query → resume where we left off + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ nextExpectedRanges: [`${CHUNK}-`] }) + } as Response); + // Chunk 2 retry succeeds (final → 201 + driveItem) + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: 'item-after-retry' }) + } as Response); + + const blob = new Blob([new Uint8Array(CHUNK + 100)]); + const id = await onedriveCore.uploadFile({ + seriesTitle: 'S', + filename: 'v.cbz', + blob, + credentials: { accessToken: 'TOKEN' } + }); + expect(id).toBe('item-after-retry'); + // init + chunk1 + failed chunk2 + status query + retried chunk2 + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(5); +}, 15000); + +it('gives up after repeated transient failures with a descriptive error', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ uploadUrl: 'https://upload.example/xyz' }) + } as Response); + // Every subsequent call (chunk PUTs and status queries) fails + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => '', + json: async () => ({}) + } as Response); + + const blob = new Blob([new Uint8Array(100)]); + await expect( + onedriveCore.uploadFile({ + seriesTitle: 'S', + filename: 'v.cbz', + blob, + credentials: { accessToken: 'TOKEN' } + }) + ).rejects.toThrow(/after 5 attempts/i); +}, 30000); + +it('fails fast on a non-retryable 4xx without retrying', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ uploadUrl: 'https://upload.example/xyz' }) + } as Response); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'invalid range' + } as Response); + + const blob = new Blob([new Uint8Array(100)]); + await expect( + onedriveCore.uploadFile({ + seriesTitle: 'S', + filename: 'v.cbz', + blob, + credentials: { accessToken: 'TOKEN' } + }) + ).rejects.toThrow(/400/); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); // no retry +}); + +it('consumes 202 response bodies (no unread streams)', async () => { + const CHUNK = 10 * 1024 * 1024; + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ uploadUrl: 'https://upload.example/xyz' }) + } as Response); + const json202 = vi.fn(async () => ({ nextExpectedRanges: [`${CHUNK}-`] })); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 202, + json: json202 + } as unknown as Response); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: 'done' }) + } as Response); + + await onedriveCore.uploadFile({ + seriesTitle: 'S', + filename: 'v.cbz', + blob: new Blob([new Uint8Array(CHUNK + 1)]), + credentials: { accessToken: 'TOKEN' } + }); + expect(json202).toHaveBeenCalled(); +}); +``` + +Note: the existing multi-chunk test already mocks `json` on its 202 response, so it keeps passing once bodies are read. + +- [ ] **Step 2: Run to verify failures** + +Run: `npx vitest run src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts` +Expected: retry tests FAIL (upload throws on first 503); 202-drain test FAIL (`json202` never called). + +- [ ] **Step 3: Implement** — in `onedrive-core.ts`, change the import to include the parser: + +```typescript +import { createChunkRanges, parseNextExpectedRange } from '../../providers/onedrive/upload-session'; +``` + +(`createChunkRanges` stays exported/tested but is no longer used here — remove it from the import if eslint flags it, and leave `upload-session.ts` untouched.) + +Add module-level helpers under `encodePath`: + +```typescript +const MAX_CHUNK_ATTEMPTS = 5; +const RETRY_BASE_DELAY_MS = 400; +const RETRY_MAX_DELAY_MS = 5000; +const CHUNK_TIMEOUT_MS = 5 * 60 * 1000; +const SESSION_TIMEOUT_MS = 30 * 1000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableStatus(status: number): boolean { + return status === 408 || status === 429 || status >= 500; +} + +/** + * Ask the upload session where to resume (Graph tracks received ranges + * server-side). Returns null when the session can't say — caller retries + * from its own counter. + */ +async function queryResumeOffset(uploadUrl: string): Promise { + try { + const response = await fetch(uploadUrl, { signal: AbortSignal.timeout(SESSION_TIMEOUT_MS) }); + if (!response.ok) { + await response.text().catch(() => ''); + return null; + } + const data = (await response.json()) as { nextExpectedRanges?: string[] }; + return data.nextExpectedRanges ? parseNextExpectedRange(data.nextExpectedRanges) : null; + } catch { + return null; + } +} +``` + +Replace the session-creation fetch options (add a timeout signal): + +```typescript +const sessionResponse = await fetch( + `${BASE}/me/drive/root:/${encodePath(targetPath)}:/createUploadSession`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + item: { '@microsoft.graph.conflictBehavior': 'replace' } + }), + signal: AbortSignal.timeout(SESSION_TIMEOUT_MS) + } +); +``` + +Replace the chunk loop (lines 79-108) entirely: + +```typescript +let lastItemId: string | null = null; +let offset = 0; +let attempt = 0; + +const retryOrThrow = async (reason: string): Promise => { + attempt++; + if (attempt >= MAX_CHUNK_ATTEMPTS) { + throw new Error(`OneDrive upload failed after ${MAX_CHUNK_ATTEMPTS} attempts: ${reason}`); + } + await sleep(Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS)); + // Trust Graph's record of received bytes over our own counter. + const resume = await queryResumeOffset(uploadUrl); + if (resume !== null) offset = resume; +}; + +while (offset < blob.size) { + const end = Math.min(offset + ONEDRIVE_CONFIG.UPLOAD_CHUNK_SIZE - 1, blob.size - 1); + + let chunkResponse: Response; + try { + chunkResponse = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Length': String(end - offset + 1), + 'Content-Range': `bytes ${offset}-${end}/${blob.size}` + }, + body: blob.slice(offset, end + 1), + signal: AbortSignal.timeout(CHUNK_TIMEOUT_MS) + }); + } catch (error) { + await retryOrThrow(error instanceof Error ? error.message : 'network error'); + continue; + } + + if (chunkResponse.status === 200 || chunkResponse.status === 201) { + // Final chunk returns the completed driveItem. + const item = (await chunkResponse.json()) as { id: string }; + lastItemId = item.id; + offset = end + 1; + attempt = 0; + onProgress?.(offset, blob.size); + continue; + } + + if (chunkResponse.status === 202) { + // Intermediate chunk. Drain the body (avoids stream retention) and + // use Graph's nextExpectedRanges as the authoritative next offset. + const body = (await chunkResponse.json().catch(() => null)) as { + nextExpectedRanges?: string[]; + } | null; + const next = body?.nextExpectedRanges ? parseNextExpectedRange(body.nextExpectedRanges) : null; + offset = next ?? end + 1; + attempt = 0; + onProgress?.(offset, blob.size); + continue; + } + + await chunkResponse.text().catch(() => ''); + if (!isRetryableStatus(chunkResponse.status)) { + throw new Error( + `OneDrive upload chunk failed: ${chunkResponse.status} ${chunkResponse.statusText}` + ); + } + await retryOrThrow(`HTTP ${chunkResponse.status} ${chunkResponse.statusText}`); +} + +if (!lastItemId) { + throw new Error('OneDrive upload session did not return a final driveItem'); +} +return lastItemId; +``` + +Note: the give-up test spends ~7.6s in real backoff sleeps (400+800+1600+3200ms ×(no fake timers)) — the `30000` test timeout covers it. If the suite feels slow later, switch that one test to `vi.useFakeTimers()` + `vi.runAllTimersAsync()`. + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts src/lib/util/sync/providers/onedrive` +Expected: PASS, including the pre-existing 5 upload tests. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat(onedrive): chunk upload retry/resume via nextExpectedRanges, timeouts, 202 drain" +``` + +--- + +### Task 7: Filesystem restore hardening, typed errors, honest quota + +Three gaps: a broken stored handle (folder deleted/moved → `queryPermission` throws) is never cleared, so Reconnect loops forever; mid-session `NotFoundError`/`NotAllowedError` escape as raw DOMExceptions (never typed `NOT_FOUND`, never flip needs-reconnect); `getStorageQuota` reports the browser-origin estimate as if it were folder disk space. + +**Files:** + +- Modify: `src/lib/util/sync/providers/filesystem/filesystem-provider.ts` +- Modify: `src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts` (reuse its existing fake-handle helpers — read the file first; it already fabricates directory/file handles for the `renameFolder` tests) + +**Interfaces:** + +- Produces: private `toProviderError(error, operation, path): ProviderError` used by all ops — `NotFoundError` → code `'NOT_FOUND'`, message contains `not found` (consumed by `unified-cloud-manager.ts:28` and the `unified-sync-service.ts:360` sniffer); `NotAllowedError`/`SecurityError` → code `'PERMISSION_REVOKED'`, `isAuthError: true`, nulls `rootHandle`, and pings `providerManager.updateStatus()` via dynamic import (webdav's `notifyStatusChanged` pattern, `webdav-provider.ts:110-115`). +- Produces: `getStorageQuota()` always returns `{ used: 0, total: null, available: null }`. + +- [ ] **Step 1: Read the existing test file's fake-handle helpers** (`head -80 src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts`) and write failing tests in its style: + +```typescript +describe('error classification', () => { + it('converts NotFoundError to a typed NOT_FOUND ProviderError with a sniffable message', async () => { + const provider = connectedProvider(); // root handle whose getFileHandle throws NotFoundError + // Arrange the fake root to throw: + root.getFileHandle = vi.fn().mockRejectedValue(new DOMException('missing', 'NotFoundError')); + + const err = await provider + .downloadFile({ + provider: 'filesystem', + fileId: 'S/v.cbz', + path: 'S/v.cbz', + modifiedTime: '', + size: 1 + }) + .catch((e) => e); + expect(err).toBeInstanceOf(ProviderError); + expect(err.code).toBe('NOT_FOUND'); + expect(err.message).toMatch(/not found/i); + }); + + it('converts NotAllowedError to isAuthError and flips into needs-reconnect state', async () => { + const provider = connectedProvider(); + root.getFileHandle = vi.fn().mockRejectedValue(new DOMException('revoked', 'NotAllowedError')); + + const err = await provider + .downloadFile({ + provider: 'filesystem', + fileId: 'v.cbz', + path: 'v.cbz', + modifiedTime: '', + size: 1 + }) + .catch((e) => e); + expect(err).toBeInstanceOf(ProviderError); + expect(err.isAuthError).toBe(true); + expect(provider.isAuthenticated()).toBe(false); + expect(provider.getStatus().needsAttention).toBe(true); + }); +}); + +describe('restoreHandle', () => { + it('clears a stored handle whose queryPermission throws (folder deleted/moved)', async () => { + const broken = { + name: 'gone', + queryPermission: vi.fn().mockRejectedValue(new DOMException('x', 'InvalidStateError')) + }; + vi.mocked(loadRootHandle).mockResolvedValue(broken as any); + + const provider = new FilesystemProvider(); + await provider.whenReady(); + + expect(clearRootHandle).toHaveBeenCalled(); + expect(provider.getStatus().hasStoredCredentials).toBe(false); + expect(provider.getStatus().needsAttention).toBe(false); + }); +}); + +describe('getStorageQuota', () => { + it('returns the unavailable shape — origin estimate is not folder disk space', async () => { + const provider = connectedProvider(); + await expect(provider.getStorageQuota()).resolves.toEqual({ + used: 0, + total: null, + available: null + }); + }); +}); +``` + +(Exact helper names — `connectedProvider()`, `root`, the `handle-store` mock — must match what the existing test file provides; adapt while keeping the assertions identical. The file already mocks `$app/environment` and `feature-detect` if it constructs providers.) + +- [ ] **Step 2: Run to verify failures** + +Run: `npx vitest run src/lib/util/sync/providers/filesystem` +Expected: new tests FAIL (raw DOMException, no clear-on-throw, quota returns estimate). + +- [ ] **Step 3: Implement in `filesystem-provider.ts`** + +3a. Add the two private helpers after `requireRoot()` (~line 158): + +```typescript + /** Refresh provider-manager status after an in-provider state change + * (dynamic import avoids a circular dependency — same as WebDAV). */ + private notifyStatusChanged(): void { + import('../../provider-manager').then(({ providerManager }) => { + providerManager.updateStatus(); + }); + } + + /** + * Convert raw File System Access API failures into typed ProviderErrors. + * NOT_FOUND code + "not found" message are load-bearing: unified-cloud-manager + * keys idempotent deletes off the code, and unified-sync-service sniffs the + * message for missing-file-is-fine paths. + */ + private toProviderError(error: unknown, operation: string, path: string): ProviderError { + if (error instanceof ProviderError) return error; + if (error instanceof DOMException) { + if (error.name === 'NotFoundError') { + return new ProviderError( + `${operation} failed: '${path}' not found`, + 'filesystem', + 'NOT_FOUND' + ); + } + if (error.name === 'NotAllowedError' || error.name === 'SecurityError') { + // Permission revoked mid-session — flip to needs-reconnect so the UI + // stops pretending we're connected. + this.rootHandle = null; + this.notifyStatusChanged(); + return new ProviderError( + `${operation} failed: folder permission was revoked`, + 'filesystem', + 'PERMISSION_REVOKED', + true + ); + } + } + const message = error instanceof Error ? error.message : 'Unknown error'; + return new ProviderError(`${operation} failed: ${message}`, 'filesystem', 'OPERATION_FAILED'); + } +``` + +3b. Wrap each public op body. Pattern (apply to `listCloudVolumes`, `uploadFile`, `downloadFile`, `deleteFile`, `renameFile`, `renameFolder` — keeping each body identical inside the try): + +```typescript + async downloadFile( + file: CloudFileMetadata, + onProgress?: (loaded: number, total: number) => void + ): Promise { + try { + this.requireRoot(); + const fileHandle = await this.resolveFileHandle(file.fileId, { create: false }); + const data = await fileHandle.getFile(); + onProgress?.(data.size, data.size); + console.log(`✅ Downloaded ${file.path} from filesystem`); + return data; + } catch (error) { + throw this.toProviderError(error, 'Download', file.path); + } + } +``` + +Operation labels: `'List'` (path `''`), `'Upload'`, `'Download'`, `'Delete'`, `'Rename'`, `'Rename folder'` (path `oldPath`). `renameFolder`'s internal best-effort `catch { /* Already gone */ }` cleanup block stays as-is. `deleteSeriesFolder` already handles NotFoundError — leave it. + +3c. Replace `restoreHandle()` (lines 126-146): + +```typescript + private async restoreHandle(): Promise { + let stored: FileSystemDirectoryHandle | null = null; + try { + stored = await loadRootHandle(); + } catch (error) { + // IndexedDB read failed (transient) — keep config; a reload can retry. + console.warn('Failed to load stored filesystem handle:', error); + return; + } + if (!stored) return; + this.hasStoredHandle = true; + try { + // @ts-expect-error — queryPermission is Chromium-only, not in all TS lib.dom targets + const permission = await stored.queryPermission({ mode: 'readwrite' }); + if (permission === 'granted') { + this.rootHandle = stored; + console.log(`✅ Filesystem provider restored folder "${stored.name}"`); + } else if (permission === 'denied') { + // Clear on outright denial + this.hasStoredHandle = false; + await clearRootHandle(); + clearActiveProviderKey(); + } + // 'prompt' → leave rootHandle null; UI will show "Reconnect" + } catch (error) { + // queryPermission threw: the handle itself is dead (folder deleted or + // moved). Clear it so the user gets a fresh picker instead of a + // Reconnect button that can never succeed. + console.warn('Stored filesystem handle is unusable; clearing:', error); + this.hasStoredHandle = false; + await clearRootHandle().catch(() => {}); + clearActiveProviderKey(); + } + } +``` + +3d. In `reauthenticate()` (lines 106-124), wrap the `requestPermission` call the same way: + +```typescript +let permission: string; +try { + // @ts-expect-error — requestPermission is Chromium-only, not in all TS lib.dom targets + permission = await stored.requestPermission({ mode: 'readwrite' }); +} catch (error) { + // Handle is dead (folder deleted/moved) — clear it so login() offers a fresh picker. + this.hasStoredHandle = false; + await clearRootHandle().catch(() => {}); + clearActiveProviderKey(); + throw new ProviderError( + 'The previously chosen folder no longer exists — choose a folder again', + 'filesystem', + 'NOT_CONFIGURED' + ); +} +``` + +3e. Replace `getStorageQuota()` (lines 367-376): + +```typescript + async getStorageQuota(): Promise { + // navigator.storage.estimate() reports the browser-origin quota, which has + // nothing to do with the chosen folder's free disk space. Report "unknown" + // rather than a misleading number; the UI hides bars for null totals. + return { used: 0, total: null, available: null }; + } +``` + +Then verify the CloudView quota section renders sanely with `total: null` (read the block at `src/lib/views/CloudView.svelte:1213-1253`); if it assumes non-null totals, guard it with an `{#if quota.total !== null}` around the bar and show "Storage info unavailable" otherwise. + +- [ ] **Step 4: Run tests + typecheck** + +Run: `npx vitest run src/lib/util/sync/providers/filesystem && npm run check` +Expected: PASS (existing renameFolder/paths/handle-store/feature-detect tests plus new ones). + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "fix(filesystem): typed errors, dead-handle recovery, honest quota" +``` + +--- + +### Task 8: Filesystem renameFile idempotency (source gone + destination matches → converged) + +Filesystem rename is copy-then-delete (not atomic). If a retry runs after a prior attempt completed, the source is gone → after Task 7 that's a typed NOT*FOUND, which `unified-cloud-manager.moveFile` (`:501-514`) correctly treats as a genuine failure — so a \_successful* prior move would be reported as failed. WebDAV solves exactly this with a source-gone + destination-size-match check (`webdav-provider.ts` renameFile); mirror it. + +**Files:** + +- Modify: `src/lib/util/sync/providers/filesystem/filesystem-provider.ts` (renameFile) +- Modify: `src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts` + +**Interfaces:** + +- Consumes: `toProviderError` from Task 7. +- Produces: `renameFile` returns destination metadata when the source is missing but the destination exists with the source's recorded size; throws typed NOT_FOUND otherwise. + +- [ ] **Step 1: Write the failing test** (same fake-handle style): + +```typescript +describe('renameFile idempotency', () => { + it('treats source-gone + matching destination as an already-completed rename', async () => { + // Fake tree: destination 'B/v.cbz' exists with size 42; source 'A/v.cbz' missing. + const provider = providerWithTree({ 'B/v.cbz': fileOfSize(42) }); + const result = await provider.renameFile( + { provider: 'filesystem', fileId: 'A/v.cbz', path: 'A/v.cbz', modifiedTime: '', size: 42 }, + 'B/v.cbz' + ); + expect(result.path).toBe('B/v.cbz'); + expect(result.size).toBe(42); + }); + + it('still throws typed NOT_FOUND when the source is gone and no matching destination exists', async () => { + const provider = providerWithTree({}); + const err = await provider + .renameFile( + { provider: 'filesystem', fileId: 'A/v.cbz', path: 'A/v.cbz', modifiedTime: '', size: 42 }, + 'B/v.cbz' + ) + .catch((e) => e); + expect(err.code).toBe('NOT_FOUND'); + }); +}); +``` + +(Adapt `providerWithTree`/`fileOfSize` to whatever fake-handle helpers the test file actually has.) + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run src/lib/util/sync/providers/filesystem` +Expected: FAIL — first test throws NOT_FOUND. + +- [ ] **Step 3: Implement** — in `renameFile`, replace the unconditional source resolution (`const sourceHandle = await this.resolveFileHandle(file.fileId, { create: false }); const sourceFile = await sourceHandle.getFile();`) with: + +```typescript +let sourceFile: File; +try { + const sourceHandle = await this.resolveFileHandle(file.fileId, { create: false }); + sourceFile = await sourceHandle.getFile(); +} catch (error) { + if (error instanceof DOMException && error.name === 'NotFoundError') { + // Idempotent retry: copy-then-delete isn't atomic, so a prior attempt + // may have completed. Source gone + destination matching the source's + // recorded size = already renamed (same convergence rule as WebDAV). + try { + const destHandle = await this.resolveFileHandle(normalizedNewPath, { create: false }); + const destFile = await destHandle.getFile(); + if (typeof file.size === 'number' && destFile.size === file.size) { + console.log(`↩️ ${normalizedNewPath} already at destination (idempotent retry)`); + return { + provider: 'filesystem', + fileId: normalizedNewPath, + path: normalizedNewPath, + modifiedTime: new Date(destFile.lastModified).toISOString(), + size: destFile.size + }; + } + } catch { + // No destination either — fall through to the typed NOT_FOUND below. + } + } + throw this.toProviderError(error, 'Rename', file.path); +} +``` + +(This sits inside Task 7's outer try/catch; nested handling is fine because `toProviderError` passes through existing `ProviderError`s.) + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run src/lib/util/sync/providers/filesystem` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "fix(filesystem): idempotent renameFile retry (source gone, destination matches)" +``` + +--- + +### Task 9: `removeDirectoryIfEmpty` for OneDrive and Filesystem + +Develop's rename flow prunes emptied series folders via the optional `removeDirectoryIfEmpty` (`unified-cloud-manager.ts:363-373`); gdrive/mega/webdav implement it, the two new providers don't — cross-series moves leave orphan folders. Contract (`provider-interface.ts:333-341`): server-verified emptiness, never a blind recursive delete, best-effort (swallow failures). + +**Files:** + +- Modify: `src/lib/util/sync/providers/onedrive/onedrive-provider.ts` (add method after `deleteSeriesFolder`) +- Modify: `src/lib/util/sync/providers/filesystem/filesystem-provider.ts` (add method after `deleteSeriesFolder`) +- Modify: both providers' test files + +**Interfaces:** + +- Produces: `removeDirectoryIfEmpty(relativePath: string): Promise` on both providers, matching the optional method on `SyncProvider`. + +- [ ] **Step 1: Write the failing tests** + +OneDrive (`onedrive-provider.test.ts`, graph-client mocked as in Task 5): + +```typescript +describe('removeDirectoryIfEmpty', () => { + it('deletes a folder the server reports empty', async () => { + vi.mocked(getItemByPath).mockResolvedValue({ id: 'dir-id', name: 'Old', folder: {} }); + vi.mocked(listChildren).mockResolvedValue([]); + const provider = new OneDriveProvider(); + await provider.removeDirectoryIfEmpty('Old Series'); + expect(vi.mocked(deleteItem)).toHaveBeenCalledWith(expect.anything(), 'dir-id'); + }); + + it('keeps a folder that still has children', async () => { + vi.mocked(getItemByPath).mockResolvedValue({ id: 'dir-id', name: 'Old', folder: {} }); + vi.mocked(listChildren).mockResolvedValue([{ id: 'x', name: 'v.cbz', file: {} }]); + const provider = new OneDriveProvider(); + await provider.removeDirectoryIfEmpty('Old Series'); + expect(vi.mocked(deleteItem)).not.toHaveBeenCalled(); + }); + + it('no-ops when the folder is already gone', async () => { + vi.mocked(getItemByPath).mockResolvedValue(null); + const provider = new OneDriveProvider(); + await expect(provider.removeDirectoryIfEmpty('Old Series')).resolves.toBeUndefined(); + expect(vi.mocked(deleteItem)).not.toHaveBeenCalled(); + }); +}); +``` + +Filesystem (`filesystem-provider.test.ts`, fake-handle style — empty dir yields no entries from `values()`): + +```typescript +describe('removeDirectoryIfEmpty', () => { + it('removes an empty directory non-recursively', async () => { + const provider = providerWithTree({ 'Old Series/': emptyDir() }); + await provider.removeDirectoryIfEmpty('Old Series'); + expect(rootRemoveEntry).toHaveBeenCalledWith('Old Series'); + }); + + it('keeps a directory that still has entries', async () => { + const provider = providerWithTree({ 'Old Series/v.cbz': fileOfSize(1) }); + await provider.removeDirectoryIfEmpty('Old Series'); + expect(rootRemoveEntry).not.toHaveBeenCalled(); + }); + + it('is best-effort: swallows a missing directory', async () => { + const provider = providerWithTree({}); + await expect(provider.removeDirectoryIfEmpty('Old Series')).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failures** — both suites: method doesn't exist. + +- [ ] **Step 3: Implement** + +OneDrive (after `deleteSeriesFolder`, ~line 363): + +```typescript + /** + * Remove a series directory only if the SERVER confirms it is empty — never + * a blind recursive delete (Graph folder deletion is recursive). Best-effort: + * an orphaned empty directory is harmless. + */ + async removeDirectoryIfEmpty(relativePath: string): Promise { + if (!this.isAuthenticated()) return; + const normalized = relativePath.replace(/^\/+|\/+$/g, ''); + if (!normalized) return; + try { + const token = await onedriveTokenManager.getAccessToken(); + const path = `${ONEDRIVE_CONFIG.MOKURO_FOLDER}/${normalized}`; + const item = await getItemByPath(token, path); + if (!item || !item.folder) return; + const children = await listChildren(token, path); + if (children.length > 0) return; + await deleteItem(token, item.id); + console.log(`✅ Pruned empty series folder '${normalized}' from OneDrive`); + } catch (error) { + console.warn(`Could not prune OneDrive folder '${normalized}':`, error); + } + } +``` + +Filesystem (after `deleteSeriesFolder`, ~line 365): + +```typescript + /** + * Remove a directory only if it is verifiably empty — never recursive. + * Best-effort: an orphaned empty directory is harmless. + */ + async removeDirectoryIfEmpty(relativePath: string): Promise { + if (!this.isAuthenticated()) return; + const normalized = relativePath.replace(/^\/+|\/+$/g, ''); + if (!normalized) return; + try { + const dir = await this.resolveDirectoryHandle(normalized, { create: false }); + // @ts-expect-error — values() is defined on FileSystemDirectoryHandle at runtime + for await (const _entry of dir.values()) { + return; // any entry → not empty → keep + } + const parentPath = getParentPath(normalized); + const parent = parentPath + ? await this.resolveDirectoryHandle(parentPath, { create: false }) + : this.requireRoot(); + await parent.removeEntry(getBasename(normalized)); + console.log(`✅ Pruned empty folder '${normalized}' from filesystem`); + } catch { + // Already gone or unreadable — harmless. + } + } +``` + +(If eslint flags the unused `_entry`, use `for await (const _ of dir.values())` or add an inline eslint-disable for that line — match repo conventions.) + +- [ ] **Step 4: Run tests** — `npx vitest run src/lib/util/sync/providers` — PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat(sync): removeDirectoryIfEmpty for onedrive and filesystem providers" +``` + +--- + +### Task 10: UI parity — CloudView action gating, OneDrive config gate, shared provider display names + +Three UI gaps: Sync/Backup/Profile buttons stay clickable while filesystem/onedrive need reconnect (`CloudView.svelte:1184` only gates webdav read-only); the OneDrive selection button renders even when `VITE_ONEDRIVE_CLIENT_ID` is unset (throws only at click time — filesystem feature-gates, OneDrive should config-gate); `PlaceholderVolumeItem.svelte:40-75` labels the new providers "Cloud"/gray and `VolumeItem.svelte:476` shows raw slugs in the delete snackbar. + +**Files:** + +- Create: `src/lib/util/sync/provider-display.ts` +- Create: `src/lib/util/sync/provider-display.test.ts` +- Modify: `src/lib/views/CloudView.svelte` (~:56, ~:77-86, ~:918-933, ~:1184) +- Modify: `src/lib/components/PlaceholderVolumeItem.svelte:39-78` +- Modify: `src/lib/components/VolumeItem.svelte:476` + +**Interfaces:** + +- Produces: `PROVIDER_LABELS`, `PROVIDER_SHORT_LABELS`, `PROVIDER_BADGE_COLORS` — all `Record` so adding a sixth provider is a compile error until every map is updated. + +- [ ] **Step 1: Write the display-map test** + +`src/lib/util/sync/provider-display.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { PROVIDER_LABELS, PROVIDER_SHORT_LABELS, PROVIDER_BADGE_COLORS } from './provider-display'; + +const ALL = ['google-drive', 'mega', 'webdav', 'filesystem', 'onedrive'] as const; + +describe('provider-display', () => { + it('covers every provider in every map', () => { + for (const p of ALL) { + expect(PROVIDER_LABELS[p]).toBeTruthy(); + expect(PROVIDER_SHORT_LABELS[p]).toBeTruthy(); + expect(PROVIDER_BADGE_COLORS[p]).toBeTruthy(); + } + }); + + it('names the new providers properly (no "Cloud" fallback)', () => { + expect(PROVIDER_SHORT_LABELS.onedrive).toBe('OneDrive'); + expect(PROVIDER_SHORT_LABELS.filesystem).toBe('Local Folder'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — module not found. + +- [ ] **Step 3: Implement** + +`src/lib/util/sync/provider-display.ts`: + +```typescript +import type { ProviderType } from './provider-interface'; + +/** Full names for headers and provider-selection screens. */ +export const PROVIDER_LABELS: Record = { + 'google-drive': 'Google Drive', + mega: 'MEGA Cloud Storage', + webdav: 'WebDAV Server', + filesystem: 'Local Folder', + onedrive: 'OneDrive' +}; + +/** Short names for badges and snackbars. */ +export const PROVIDER_SHORT_LABELS: Record = { + 'google-drive': 'Drive', + mega: 'MEGA', + webdav: 'WebDAV', + filesystem: 'Local Folder', + onedrive: 'OneDrive' +}; + +export type ProviderBadgeColor = 'blue' | 'purple' | 'green' | 'yellow' | 'indigo' | 'gray'; + +export const PROVIDER_BADGE_COLORS: Record = { + 'google-drive': 'blue', + mega: 'purple', + webdav: 'green', + filesystem: 'yellow', + onedrive: 'indigo' +}; +``` + +- [ ] **Step 4: Wire the components** + +4a. `PlaceholderVolumeItem.svelte` — delete `getProviderDisplayName`, the local `BadgeColor` type, and `getProviderBadgeColor` (lines 39-78); import and use the maps: + +```typescript +import { PROVIDER_SHORT_LABELS, PROVIDER_BADGE_COLORS } from '$lib/util/sync/provider-display'; +// … +const providerName = cloudProvider ? PROVIDER_SHORT_LABELS[cloudProvider] : 'Cloud'; +const badgeColor = cloudProvider ? PROVIDER_BADGE_COLORS[cloudProvider] : 'gray'; +``` + +(Flowbite's Badge `color` prop accepts these values; run `npm run check` to confirm the type union matches — if Flowbite's type is wider, no change needed.) + +4b. `VolumeItem.svelte:476` — replace the ternary: + +```typescript +const providerName = PROVIDER_SHORT_LABELS[providerType]; +``` + +with import `import { PROVIDER_SHORT_LABELS } from '$lib/util/sync/provider-display';` added to the script block. + +4c. `CloudView.svelte` — replace the local `providerNames` map (lines 80-86) with the shared one: + +```typescript +import { PROVIDER_LABELS } from '$lib/util/sync/provider-display'; +const providerNames = PROVIDER_LABELS; +``` + +4d. `CloudView.svelte` — action-button gating. Near the other derived state (~line 76), add: + +```typescript +// Sync/Backup/Profile actions are pointless while the session is unusable — +// mirror the webdav read-only gate for the two reconnect states. +let providerActionsUnavailable = $derived( + (currentProvider === 'webdav' && webdavIsReadOnly) || + (currentProvider === 'filesystem' && filesystemNeedsReconnect) || + (currentProvider === 'onedrive' && onedriveNeedsAttention) +); +``` + +And change line 1184 from `{:else if !(currentProvider === 'webdav' && webdavIsReadOnly)}` to: + +```svelte + {:else if !providerActionsUnavailable} +``` + +4e. `CloudView.svelte` — config-gate the OneDrive selection button. Near `filesystemSupported` (~line 56): + +```typescript +const onedriveConfigured = !!import.meta.env.VITE_ONEDRIVE_CLIENT_ID; +``` + +Wrap the OneDrive selection button (lines ~918-933) in `{#if onedriveConfigured}` … `{/if}`, exactly like the filesystem button's `{#if filesystemSupported}` block above it. + +- [ ] **Step 5: Verify** — `npx vitest run src/lib/util/sync/provider-display.test.ts && npm run check` → PASS, 0 errors. Also run the full component tests: `npx vitest run src/lib/components` → PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat(ui): gate actions on reconnect states; shared provider labels; config-gate OneDrive" +``` + +--- + +### Task 11: Deployment docs (CLAUDE.md, README, .env.example) + +`VITE_ONEDRIVE_CLIENT_ID` is required (`onedrive/constants.ts:2`, enforced `token-manager.ts:46`) but documented nowhere; a deployer also needs the Azure app-registration steps (SPA redirect URI = deploy origin). + +**Files:** + +- Modify: `CLAUDE.md` ("Environment Variables" section, ~lines 251-260) +- Modify: `README.md` (env section, ~line 189 — locate with `grep -n "VITE_GDRIVE" README.md`) +- Create: `.env.example` + +- [ ] **Step 1: Update CLAUDE.md** — replace the Environment Variables section body: + +```markdown +## Environment Variables + +Create a `.env.local` file for cloud provider integration: + +​` +VITE_GDRIVE_CLIENT_ID=your_client_id +VITE_GDRIVE_API_KEY=your_api_key +VITE_ONEDRIVE_CLIENT_ID=your_azure_app_client_id +​` + +- `VITE_GDRIVE_*`: required only for Google Drive sync. +- `VITE_ONEDRIVE_CLIENT_ID`: required only for OneDrive sync. Register an + Azure AD app (any Microsoft account tenant, "common" authority) and add the + deploy origin as a **Single-page application** redirect URI. Scopes used: + `Files.ReadWrite`, `offline_access`, `User.Read`. When unset, the OneDrive + option is hidden from the cloud screen. +- MEGA, WebDAV, and Local Folder require no env vars. +``` + +(Remove the stray zero-width characters around the code fence when writing the actual file — they're only here to nest the fences.) + +- [ ] **Step 2: Update README.md** — find its env-var section and make the same addition (match the README's existing formatting/tone; include the SPA-redirect-URI note). + +- [ ] **Step 3: Create `.env.example`**: + +``` +# Google Drive sync (optional — omit to disable) +VITE_GDRIVE_CLIENT_ID= +VITE_GDRIVE_API_KEY= + +# OneDrive sync (optional — omit to hide the OneDrive option) +# Azure AD app registration with the deploy origin as an SPA redirect URI. +VITE_ONEDRIVE_CLIENT_ID= +``` + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "docs: document VITE_ONEDRIVE_CLIENT_ID and Azure app registration" +``` + +--- + +### Task 12: Full verification gates + +- [ ] **Step 1: Typecheck** — `npm run check` → 0 errors, 0 warnings. +- [ ] **Step 2: Full test suite** — `npx vitest run` → all pass (baseline 887 + ~25 new). +- [ ] **Step 3: Lint** — `npm run lint` → clean (run `npm run format` first if prettier complains). +- [ ] **Step 4: Build** — `npm run build` → succeeds. +- [ ] **Step 5:** If anything fails, fix before declaring done; re-run the failing gate. Do NOT push. + +--- + +## Explicitly out of scope (noted for follow-up, do not do here) + +- **WebDAV folder-creation mutex** — same race as OneDrive's (pre-existing on develop, `webdav-provider.ts:445,824`); fix on develop separately. +- **Cache triplication** (webdav-cache/onedrive-cache/filesystem-cache ~95% identical) — mechanical dedup refactor, no behavior change; separate cleanup branch. +- **MEGA-style reactive cache for onedrive/filesystem** — MEGA has push events; Graph delta/polling is a feature, not parity. +- **OneDrive byte-level upload progress** — 10 MiB chunk granularity is acceptable. +- **Filesystem provider-level filename sanitization** — higher layers already sanitize (develop's `sanitize-title.ts` at import/rename); provider-level OS-char handling is theoretical until a bug proves otherwise. From a6d41d635caec6ed280bf35bddbcd39940b4fdb3 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:18:09 -0700 Subject: [PATCH 49/65] =?UTF-8?q?fix(sync):=20shared=20isSyncableFile=20?= =?UTF-8?q?=E2=80=94=20libraries.json=20syncs,=20jpg=20sidecars=20on=20all?= =?UTF-8?q?=20providers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/filesystem-paths.test.ts | 3 +- .../providers/filesystem/filesystem-paths.ts | 11 +---- .../google-drive/google-drive-provider.ts | 14 ++----- .../util/sync/providers/mega/mega-provider.ts | 11 ++--- .../providers/onedrive/onedrive-provider.ts | 13 +----- .../sync/providers/webdav/webdav-provider.ts | 21 ++-------- src/lib/util/sync/syncable-file.test.ts | 42 +++++++++++++++++++ src/lib/util/sync/syncable-file.ts | 35 ++++++++++++++++ 8 files changed, 92 insertions(+), 58 deletions(-) create mode 100644 src/lib/util/sync/syncable-file.test.ts create mode 100644 src/lib/util/sync/syncable-file.ts diff --git a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts index 56251160..926cd1e3 100644 --- a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts +++ b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts @@ -31,7 +31,8 @@ describe('isSyncableFile', () => { ['Series/Volume.webp', true], ['volume-data.json', true], ['profiles.json', true], - ['Series/cover.jpg', false], + ['libraries.json', true], + ['Series/cover.jpg', true], ['.DS_Store', false], ['Series/Notes.txt', false], ['random.json', false] diff --git a/src/lib/util/sync/providers/filesystem/filesystem-paths.ts b/src/lib/util/sync/providers/filesystem/filesystem-paths.ts index 8c76252e..7422f5ab 100644 --- a/src/lib/util/sync/providers/filesystem/filesystem-paths.ts +++ b/src/lib/util/sync/providers/filesystem/filesystem-paths.ts @@ -17,13 +17,4 @@ export function getParentPath(path: string): string { return segments.slice(0, -1).join('/'); } -const SYNCABLE_EXTENSIONS = ['.cbz', '.mokuro', '.mokuro.gz', '.webp']; -const SYNCABLE_ROOT_FILENAMES = new Set(['volume-data.json', 'profiles.json']); - -export function isSyncableFile(path: string): boolean { - const basename = getBasename(path).toLowerCase(); - if (SYNCABLE_ROOT_FILENAMES.has(basename)) { - return true; - } - return SYNCABLE_EXTENSIONS.some((ext) => basename.endsWith(ext)); -} +export { isSyncableFile } from '../../syncable-file'; diff --git a/src/lib/util/sync/providers/google-drive/google-drive-provider.ts b/src/lib/util/sync/providers/google-drive/google-drive-provider.ts index 3a0e960e..c2446467 100644 --- a/src/lib/util/sync/providers/google-drive/google-drive-provider.ts +++ b/src/lib/util/sync/providers/google-drive/google-drive-provider.ts @@ -7,6 +7,7 @@ import type { StorageQuota } from '../../provider-interface'; import { ProviderError } from '../../provider-interface'; +import { isCbzFile, isSidecarFile, isRootConfigFile } from '../../syncable-file'; import { tokenManager } from '$lib/util/sync/providers/google-drive/token-manager'; import { driveApiClient } from '$lib/util/sync/providers/google-drive/api-client'; import { driveFilesCache } from '$lib/util/sync/providers/google-drive/drive-files-cache'; @@ -210,18 +211,11 @@ class GoogleDriveProvider implements SyncProvider { for (const item of allItems) { if (item.mimeType === GOOGLE_DRIVE_CONFIG.MIME_TYPES.FOLDER) { folderNames.set(item.id, item.name); - } else if (item.name.endsWith('.cbz')) { + } else if (isCbzFile(item.name)) { cbzFiles.push(item); - } else if ( - item.name.endsWith('.mokuro') || - item.name.endsWith('.mokuro.gz') || - /\.(webp|jpe?g)$/i.test(item.name) - ) { + } else if (isSidecarFile(item.name)) { sidecarFiles.push(item); - } else if ( - item.name === GOOGLE_DRIVE_CONFIG.FILE_NAMES.VOLUME_DATA || - item.name === GOOGLE_DRIVE_CONFIG.FILE_NAMES.PROFILES - ) { + } else if (isRootConfigFile(item.name)) { jsonFiles.push(item); } } diff --git a/src/lib/util/sync/providers/mega/mega-provider.ts b/src/lib/util/sync/providers/mega/mega-provider.ts index 867032c5..aa2eb92b 100644 --- a/src/lib/util/sync/providers/mega/mega-provider.ts +++ b/src/lib/util/sync/providers/mega/mega-provider.ts @@ -11,6 +11,7 @@ import { ProviderError } from '../../provider-interface'; import { megaCache } from './mega-cache'; import { cacheManager } from '../../cache-manager'; import { setActiveProviderKey, clearActiveProviderKey } from '../../provider-detection'; +import { isCbzFile, isSidecarFile, isRootConfigFile } from '../../syncable-file'; import type { FolderOperations, FolderInfo, FolderItem } from '../../folder-deduplicator'; import { isMfaRequiredError, @@ -592,13 +593,9 @@ export class MegaProvider implements SyncProvider { // Check if file is a CBZ, sidecar, or JSON const name = (file as any).name || ''; - const isCbz = name.toLowerCase().endsWith('.cbz'); - const lowerName = name.toLowerCase(); - const isSidecar = - lowerName.endsWith('.mokuro') || - lowerName.endsWith('.mokuro.gz') || - /\.(webp|jpe?g)$/i.test(lowerName); - const isJson = name === 'volume-data.json' || name === 'profiles.json'; + const isCbz = isCbzFile(name); + const isSidecar = isSidecarFile(name); + const isJson = isRootConfigFile(name); if (!isCbz && !isSidecar && !isJson) continue; diff --git a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts index 337103ab..036ed0c9 100644 --- a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts +++ b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts @@ -20,18 +20,7 @@ import { patchItem } from './graph-client'; import { getCloudProviderCore } from '../../core/cloud-provider-core-registry'; - -function isSyncableFile(path: string): boolean { - const lower = path.toLowerCase(); - const basename = lower.split('/').filter(Boolean).pop() ?? ''; - if (basename === 'volume-data.json' || basename === 'profiles.json') return true; - return ( - basename.endsWith('.cbz') || - basename.endsWith('.mokuro') || - basename.endsWith('.mokuro.gz') || - basename.endsWith('.webp') - ); -} +import { isSyncableFile } from '../../syncable-file'; export class OneDriveProvider implements SyncProvider { readonly type = 'onedrive' as const; diff --git a/src/lib/util/sync/providers/webdav/webdav-provider.ts b/src/lib/util/sync/providers/webdav/webdav-provider.ts index 1742eff0..5cbe0352 100644 --- a/src/lib/util/sync/providers/webdav/webdav-provider.ts +++ b/src/lib/util/sync/providers/webdav/webdav-provider.ts @@ -14,6 +14,7 @@ import { webdavAuthOptions } from '../../core/providers/webdav-auth'; import { basicAuthHeader } from '$lib/util/base64'; import { fetchServerIdentity, type ServerPermissions } from './identity'; import { classifyWriteError, type WriteErrorKind } from './webdav-errors'; +import { isSyncableFile } from '../../syncable-file'; interface WebDAVCredentials { serverUrl: string; @@ -682,16 +683,8 @@ export class WebDAVProvider implements SyncProvider { // Recurse into subdirectories await processFolder(item.filename); } else { - const name = item.basename.toLowerCase(); // Include CBZ files, sidecars, and JSON config files - if ( - name.endsWith('.cbz') || - name.endsWith('.mokuro') || - name.endsWith('.mokuro.gz') || - /\.(webp|jpe?g)$/i.test(name) || - item.basename === 'volume-data.json' || - item.basename === 'profiles.json' - ) { + if (isSyncableFile(item.basename)) { // Build relative path from mokuro folder const relativePath = item.filename.replace(MOKURO_FOLDER + '/', ''); @@ -744,16 +737,8 @@ export class WebDAVProvider implements SyncProvider { for (const item of contents) { if (item.type === 'file') { - const name = item.basename.toLowerCase(); // Include CBZ files, sidecars, and JSON config files - if ( - name.endsWith('.cbz') || - name.endsWith('.mokuro') || - name.endsWith('.mokuro.gz') || - /\.(webp|jpe?g)$/i.test(name) || - item.basename === 'volume-data.json' || - item.basename === 'profiles.json' - ) { + if (isSyncableFile(item.basename)) { // Build relative path from mokuro folder const relativePath = item.filename.replace(MOKURO_FOLDER + '/', ''); diff --git a/src/lib/util/sync/syncable-file.test.ts b/src/lib/util/sync/syncable-file.test.ts new file mode 100644 index 00000000..224bd44d --- /dev/null +++ b/src/lib/util/sync/syncable-file.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { isSyncableFile, isCbzFile, isSidecarFile, isRootConfigFile } from './syncable-file'; + +describe('syncable-file', () => { + it('accepts cbz, mokuro, mokuro.gz anywhere in the tree', () => { + expect(isSyncableFile('Series/Vol 1.cbz')).toBe(true); + expect(isSyncableFile('Series/Vol 1.mokuro')).toBe(true); + expect(isSyncableFile('Series/Vol 1.mokuro.gz')).toBe(true); + }); + + it('accepts webp AND jpg/jpeg sidecar thumbnails (parity with mature providers)', () => { + expect(isSyncableFile('Series/Vol 1.webp')).toBe(true); + expect(isSyncableFile('Series/Vol 1.jpg')).toBe(true); + expect(isSyncableFile('Series/Vol 1.JPEG')).toBe(true); + }); + + it('accepts the three root config files, including libraries.json', () => { + expect(isSyncableFile('volume-data.json')).toBe(true); + expect(isSyncableFile('profiles.json')).toBe(true); + expect(isSyncableFile('libraries.json')).toBe(true); + }); + + it('rejects everything else', () => { + expect(isSyncableFile('Series/notes.txt')).toBe(false); + expect(isSyncableFile('Series/random.json')).toBe(false); + expect(isSyncableFile('desktop.ini')).toBe(false); + }); + + it('is case-insensitive and uses the basename only', () => { + expect(isSyncableFile('Series/VOL.CBZ')).toBe(true); + expect(isSyncableFile('a/b/c/LIBRARIES.JSON')).toBe(true); + }); + + it('exposes category predicates for providers that bucket by type', () => { + expect(isCbzFile('v.cbz')).toBe(true); + expect(isSidecarFile('v.mokuro')).toBe(true); + expect(isSidecarFile('v.jpeg')).toBe(true); + expect(isSidecarFile('v.cbz')).toBe(false); + expect(isRootConfigFile('libraries.json')).toBe(true); + expect(isRootConfigFile('v.cbz')).toBe(false); + }); +}); diff --git a/src/lib/util/sync/syncable-file.ts b/src/lib/util/sync/syncable-file.ts new file mode 100644 index 00000000..94de9a5d --- /dev/null +++ b/src/lib/util/sync/syncable-file.ts @@ -0,0 +1,35 @@ +/** + * The single source of truth for which files sync providers list and cache. + * Shared by ALL five providers — do not fork per-provider copies again. + * + * Categories: + * - CBZ archives (the volumes themselves) + * - Sidecars: OCR data (.mokuro / .mokuro.gz) and thumbnails (.webp/.jpg/.jpeg) + * - Root config files: volume-data.json (read progress), profiles.json + * (settings profiles), libraries.json (library definitions) + */ + +const ROOT_CONFIG_FILENAMES = new Set(['volume-data.json', 'profiles.json', 'libraries.json']); +const SIDECAR_IMAGE_RE = /\.(webp|jpe?g)$/i; + +function basenameOf(path: string): string { + return path.split('/').filter(Boolean).pop() ?? ''; +} + +export function isCbzFile(basename: string): boolean { + return basename.toLowerCase().endsWith('.cbz'); +} + +export function isSidecarFile(basename: string): boolean { + const lower = basename.toLowerCase(); + return lower.endsWith('.mokuro') || lower.endsWith('.mokuro.gz') || SIDECAR_IMAGE_RE.test(lower); +} + +export function isRootConfigFile(basename: string): boolean { + return ROOT_CONFIG_FILENAMES.has(basename.toLowerCase()); +} + +export function isSyncableFile(path: string): boolean { + const basename = basenameOf(path); + return isCbzFile(basename) || isSidecarFile(basename) || isRootConfigFile(basename); +} From 90a0e00662fe649c59ce33725143177c4b2b33c5 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:20:22 -0700 Subject: [PATCH 50/65] fix(onedrive): logout clears state before redirect; handle interaction_in_progress --- .../onedrive/__tests__/token-manager.test.ts | 97 +++++++++++++++++++ .../sync/providers/onedrive/token-manager.ts | 67 +++++++++---- 2 files changed, 144 insertions(+), 20 deletions(-) create mode 100644 src/lib/util/sync/providers/onedrive/__tests__/token-manager.test.ts diff --git a/src/lib/util/sync/providers/onedrive/__tests__/token-manager.test.ts b/src/lib/util/sync/providers/onedrive/__tests__/token-manager.test.ts new file mode 100644 index 00000000..c9d59a83 --- /dev/null +++ b/src/lib/util/sync/providers/onedrive/__tests__/token-manager.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('$app/environment', () => ({ browser: true })); + +// Capture-order log shared between the msal mock and assertions. +const calls: string[] = []; + +class FakeBrowserAuthError extends Error { + constructor(public errorCode: string) { + super(errorCode); + } +} +class FakeInteractionRequiredAuthError extends Error {} + +const fakeAccount = { name: 'Test User', username: 'test@example.com' }; + +const fakeInstance = { + initialize: vi.fn(async () => {}), + handleRedirectPromise: vi.fn(async () => null), + getAllAccounts: vi.fn(() => [fakeAccount]), + setActiveAccount: vi.fn(), + loginRedirect: vi.fn(async () => { + calls.push('loginRedirect'); + }), + acquireTokenRedirect: vi.fn(async () => { + calls.push('acquireTokenRedirect'); + }), + acquireTokenSilent: vi.fn(async () => ({ accessToken: 'tok' })), + logoutRedirect: vi.fn(async () => { + calls.push(`logoutRedirect(hasAuth=${localStorage.getItem('onedrive_has_authenticated')})`); + }) +}; + +vi.mock('@azure/msal-browser', () => ({ + PublicClientApplication: vi.fn(() => fakeInstance), + BrowserAuthError: FakeBrowserAuthError, + InteractionRequiredAuthError: FakeInteractionRequiredAuthError +})); + +async function freshManager() { + vi.resetModules(); + vi.stubEnv('VITE_ONEDRIVE_CLIENT_ID', 'test-client-id'); + const { onedriveTokenManager } = await import('../token-manager'); + return onedriveTokenManager; +} + +describe('OneDriveTokenManager', () => { + beforeEach(() => { + calls.length = 0; + localStorage.clear(); + vi.clearAllMocks(); + fakeInstance.getAllAccounts.mockReturnValue([fakeAccount]); + }); + + it('logout clears local state BEFORE the logoutRedirect navigation', async () => { + localStorage.setItem('onedrive_has_authenticated', 'true'); + localStorage.setItem('onedrive_login_pending', 'true'); + const mgr = await freshManager(); + await mgr.initialize(); + + await mgr.logout(); + + // The redirect call must observe already-cleared storage. + expect(calls).toContain('logoutRedirect(hasAuth=null)'); + expect(localStorage.getItem('onedrive_login_pending')).toBeNull(); + }); + + it('login surfaces a friendly error when an interaction is already in progress', async () => { + fakeInstance.loginRedirect.mockRejectedValueOnce( + new FakeBrowserAuthError('interaction_in_progress') + ); + const mgr = await freshManager(); + await expect(mgr.login()).rejects.toThrow(/already in progress/i); + }); + + it('reauthenticate surfaces a friendly error when an interaction is already in progress', async () => { + fakeInstance.acquireTokenRedirect.mockRejectedValueOnce( + new FakeBrowserAuthError('interaction_in_progress') + ); + const mgr = await freshManager(); + await mgr.initialize(); + await expect(mgr.reauthenticate()).rejects.toThrow(/already in progress/i); + }); + + it('markNeedsAttention flips the needsAttention store', async () => { + const mgr = await freshManager(); + let value = false; + mgr.markNeedsAttention(); + mgr.needsAttention.subscribe((v) => (value = v))(); + expect(value).toBe(true); + }); + + it('no longer exposes the dead hasPendingRedirect helper', async () => { + const mgr = await freshManager(); + expect((mgr as unknown as Record).hasPendingRedirect).toBeUndefined(); + }); +}); diff --git a/src/lib/util/sync/providers/onedrive/token-manager.ts b/src/lib/util/sync/providers/onedrive/token-manager.ts index 564e7630..4cd083cc 100644 --- a/src/lib/util/sync/providers/onedrive/token-manager.ts +++ b/src/lib/util/sync/providers/onedrive/token-manager.ts @@ -96,18 +96,6 @@ class OneDriveTokenManager { return this.initPromise; } - /** - * Returns true when the app booted from a OneDrive redirect callback that - * the user is currently waiting on. Init-providers uses this to skip - * unrelated bootstrap work and let the UI surface "connected" instead. - */ - hasPendingRedirect(): boolean { - if (!browser) return false; - if (localStorage.getItem(PENDING_LOGIN_KEY) !== 'true') return false; - const url = new URL(window.location.href); - return url.searchParams.has('code') || url.searchParams.has('error'); - } - isAuthenticated(): boolean { return this.account !== null && !!this.instance; } @@ -141,16 +129,39 @@ class OneDriveTokenManager { const request: RedirectRequest = { scopes: ONEDRIVE_CONFIG.SCOPES as unknown as string[] }; - await this.instance.loginRedirect(request); + try { + await this.instance.loginRedirect(request); + } catch (error) { + localStorage.removeItem(PENDING_LOGIN_KEY); + throw this.translateInteractionError(error); + } } - async logout(): Promise { - if (this.instance && this.account) { - await this.instance.logoutRedirect({ - account: this.account, - postLogoutRedirectUri: window.location.origin - }); + /** + * MSAL throws BrowserAuthError("interaction_in_progress") when a redirect + * is already in flight (double-clicked button, or a stale lock after the + * user backed out of the Microsoft login page). Surface it as a friendly, + * actionable message instead of a raw MSAL crash. + */ + private translateInteractionError(error: unknown): Error { + if ( + this.msal && + error instanceof this.msal.BrowserAuthError && + error.errorCode === 'interaction_in_progress' + ) { + return new Error( + 'Microsoft sign-in is already in progress. Finish the login window, or reload this page and try again.' + ); } + return error instanceof Error ? error : new Error(String(error)); + } + + async logout(): Promise { + // Snapshot what we need for the redirect, then clear ALL local state + // FIRST — logoutRedirect() navigates the window away, so anything after + // it never runs. + const instance = this.instance; + const account = this.account; this.account = null; this.tokenStore.set(''); this.needsAttentionStore.set(false); @@ -158,6 +169,12 @@ class OneDriveTokenManager { localStorage.removeItem(ONEDRIVE_CONFIG.STORAGE_KEYS.HAS_AUTHENTICATED); localStorage.removeItem(PENDING_LOGIN_KEY); } + if (instance && account) { + await instance.logoutRedirect({ + account, + postLogoutRedirectUri: window.location.origin + }); + } } /** @@ -186,6 +203,11 @@ class OneDriveTokenManager { } } + /** Flag the session as needing user re-authentication (e.g. Graph 401). */ + markNeedsAttention(): void { + this.needsAttentionStore.set(true); + } + /** * Redirect-based re-authentication. Used by the UI when silent refresh * fails and the user clicks a "reconnect" action. @@ -200,7 +222,12 @@ class OneDriveTokenManager { scopes: ONEDRIVE_CONFIG.SCOPES as unknown as string[], account: this.account ?? undefined }; - await this.instance.acquireTokenRedirect(request); + try { + await this.instance.acquireTokenRedirect(request); + } catch (error) { + localStorage.removeItem(PENDING_LOGIN_KEY); + throw this.translateInteractionError(error); + } } } From 36338fc043eab9a671465c1105330ba2236838ea Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:22:28 -0700 Subject: [PATCH 51/65] fix(onedrive): surface init errors, clear provider key before logout redirect --- src/lib/util/sync/provider-manager.ts | 3 + .../onedrive-provider-lifecycle.test.ts | 89 +++++++++++++++++++ .../providers/onedrive/onedrive-provider.ts | 18 +++- 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider-lifecycle.test.ts diff --git a/src/lib/util/sync/provider-manager.ts b/src/lib/util/sync/provider-manager.ts index cd0a3d20..4bce5838 100644 --- a/src/lib/util/sync/provider-manager.ts +++ b/src/lib/util/sync/provider-manager.ts @@ -200,6 +200,9 @@ class ProviderManager { localStorage.removeItem('mega_email'); localStorage.removeItem('mega_password'); localStorage.removeItem('mega_folder_path'); + // OneDrive (MSAL's own msal.* cache entries are cleared by MSAL itself) + localStorage.removeItem('onedrive_has_authenticated'); + localStorage.removeItem('onedrive_login_pending'); } this.updateStatus(); diff --git a/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider-lifecycle.test.ts b/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider-lifecycle.test.ts new file mode 100644 index 00000000..c1612dba --- /dev/null +++ b/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider-lifecycle.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Lifecycle tests need the real constructor path (MSAL init + status), +// unlike onedrive-provider.test.ts which pins browser:false to skip it. +vi.mock('$app/environment', () => ({ browser: true })); + +vi.mock('../../../core/cloud-provider-core-registry', () => ({ + getCloudProviderCore: vi.fn(() => ({})) +})); + +const h = vi.hoisted(() => { + const order: string[] = []; + return { + order, + initialize: vi.fn(async () => {}), + logout: vi.fn(async () => { + order.push('tokenManager.logout'); + }), + clearActiveProviderKey: vi.fn(() => { + order.push('clearActiveProviderKey'); + }), + setActiveProviderKey: vi.fn() + }; +}); + +vi.mock('../token-manager', () => ({ + onedriveTokenManager: { + initialize: h.initialize, + logout: h.logout, + isAuthenticated: vi.fn().mockReturnValue(false), + hasStoredCredentials: vi.fn().mockReturnValue(false), + getActiveAccountName: vi.fn().mockReturnValue(null), + getAccessToken: vi.fn(async () => 'TOKEN'), + markNeedsAttention: vi.fn(), + needsAttention: { + subscribe: (fn: (v: boolean) => void) => { + fn(false); + return () => {}; + } + } + } +})); + +vi.mock('../../../provider-detection', () => ({ + setActiveProviderKey: h.setActiveProviderKey, + clearActiveProviderKey: h.clearActiveProviderKey +})); + +vi.mock('../graph-client', () => ({ + getItemByPath: vi.fn(), + listChildren: vi.fn(), + createFolder: vi.fn(), + deleteItem: vi.fn(), + getDriveQuota: vi.fn(), + patchItem: vi.fn() +})); + +import { OneDriveProvider } from '../onedrive-provider'; + +describe('OneDriveProvider lifecycle', () => { + beforeEach(() => { + h.order.length = 0; + vi.clearAllMocks(); + }); + + describe('logout', () => { + it('clears the active provider key before the token-manager redirect', async () => { + const provider = new OneDriveProvider(); + await provider.whenReady(); + + await provider.logout(); + + expect(h.order).toEqual(['clearActiveProviderKey', 'tokenManager.logout']); + }); + }); + + describe('getStatus after failed MSAL init', () => { + it('reports an initialization failure instead of "Not configured"', async () => { + h.initialize.mockRejectedValueOnce(new Error('VITE_ONEDRIVE_CLIENT_ID is not set')); + + const provider = new OneDriveProvider(); + await provider.whenReady(); + + const status = provider.getStatus(); + expect(status.statusMessage).toMatch(/initialization failed/i); + expect(status.isAuthenticated).toBe(false); + }); + }); +}); diff --git a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts index 036ed0c9..91b1b3ea 100644 --- a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts +++ b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts @@ -32,11 +32,15 @@ export class OneDriveProvider implements SyncProvider { private cloudCore = getCloudProviderCore('onedrive'); private initPromise: Promise; + private initError: Error | null = null; constructor() { if (browser) { this.initPromise = onedriveTokenManager.initialize().catch((error) => { - console.warn('OneDrive MSAL init failed (will retry on login):', error); + // A missing/invalid client id is a deployment misconfiguration, not + // a "user never connected" state. Track it so getStatus() can say so. + this.initError = error instanceof Error ? error : new Error(String(error)); + console.warn('OneDrive MSAL init failed:', error); }); } else { this.initPromise = Promise.resolve(); @@ -52,6 +56,14 @@ export class OneDriveProvider implements SyncProvider { } getStatus(): ProviderStatus { + if (this.initError) { + return { + isAuthenticated: false, + hasStoredCredentials: onedriveTokenManager.hasStoredCredentials(), + needsAttention: false, + statusMessage: `OneDrive initialization failed: ${this.initError.message}` + }; + } const authenticated = this.isAuthenticated(); const hasCredentials = onedriveTokenManager.hasStoredCredentials(); let needsAttention = false; @@ -118,9 +130,11 @@ export class OneDriveProvider implements SyncProvider { } async logout(): Promise { - await onedriveTokenManager.logout(); + // Clear the active-provider key BEFORE the token manager's logout — + // logoutRedirect() navigates the window away and nothing after it runs. clearActiveProviderKey(); console.log('OneDrive logged out'); + await onedriveTokenManager.logout(); } async reauthenticate(): Promise { From 5d97a7c6e2fe9ca786bd756eb7c3ac25172468ec Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:24:32 -0700 Subject: [PATCH 52/65] feat(onedrive): typed ProviderError classification; 401 flags reconnect --- .../onedrive/__tests__/graph-client.test.ts | 50 +++++++++++++++++ .../sync/providers/onedrive/graph-client.ts | 15 +++++- .../providers/onedrive/onedrive-provider.ts | 54 ++++++++++++++----- 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/lib/util/sync/providers/onedrive/__tests__/graph-client.test.ts b/src/lib/util/sync/providers/onedrive/__tests__/graph-client.test.ts index acbdd6c9..358a08e6 100644 --- a/src/lib/util/sync/providers/onedrive/__tests__/graph-client.test.ts +++ b/src/lib/util/sync/providers/onedrive/__tests__/graph-client.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('$app/environment', () => ({ browser: true })); + import { getDriveQuota, listChildren, @@ -8,6 +11,8 @@ import { createUploadSession, getItemByPath } from '../graph-client'; +import { ProviderError } from '../../../provider-interface'; +import { onedriveTokenManager } from '../token-manager'; const BASE = 'https://graph.microsoft.com/v1.0'; @@ -223,4 +228,49 @@ describe('graph-client', () => { ); }); }); + + describe('error classification', () => { + it('throws ProviderError with isAuthError on 401 and flags needsAttention', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'token expired' + } as Response); + + const err = await getDriveQuota('TOKEN').catch((e) => e); + expect(err).toBeInstanceOf(ProviderError); + expect(err.isAuthError).toBe(true); + expect(err.code).toBe('GRAPH_401'); + + let attention = false; + onedriveTokenManager.needsAttention.subscribe((v) => (attention = v))(); + expect(attention).toBe(true); + }); + + it('marks 429 and 5xx as network errors (retryable), not auth errors', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => '' + } as Response); + + const err = await getDriveQuota('TOKEN').catch((e) => e); + expect(err).toBeInstanceOf(ProviderError); + expect(err.isNetworkError).toBe(true); + expect(err.isAuthError).toBe(false); + }); + + it('keeps the Graph message shape for not-found sniffing', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => '' + } as Response); + // listChildren (not getItemByPath, which maps 404 to null) + await expect(listChildren('TOKEN', 'mokuro-reader/x')).rejects.toThrow(/404/); + }); + }); }); diff --git a/src/lib/util/sync/providers/onedrive/graph-client.ts b/src/lib/util/sync/providers/onedrive/graph-client.ts index 6b6cff0b..5175675a 100644 --- a/src/lib/util/sync/providers/onedrive/graph-client.ts +++ b/src/lib/util/sync/providers/onedrive/graph-client.ts @@ -1,4 +1,6 @@ import { ONEDRIVE_CONFIG } from './constants'; +import { ProviderError } from '../../provider-interface'; +import { onedriveTokenManager } from './token-manager'; const BASE = ONEDRIVE_CONFIG.GRAPH_BASE_URL; @@ -29,7 +31,18 @@ function encodePath(path: string): string { async function parseError(response: Response): Promise { const text = await response.text().catch(() => ''); - throw new Error(`Graph ${response.status} ${response.statusText}: ${text || '(no body)'}`); + if (response.status === 401) { + // Token rejected server-side (revocation, password change). Silent + // refresh alone won't detect this — flag the session for reconnect. + onedriveTokenManager.markNeedsAttention(); + } + throw new ProviderError( + `Graph ${response.status} ${response.statusText}: ${text || '(no body)'}`, + 'onedrive', + `GRAPH_${response.status}`, + response.status === 401, + response.status === 429 || response.status >= 500 + ); } export async function getDriveQuota(accessToken: string): Promise { diff --git a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts index 91b1b3ea..4fca5f2b 100644 --- a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts +++ b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts @@ -225,15 +225,28 @@ export class OneDriveProvider implements SyncProvider { : blob instanceof ArrayBuffer ? new Blob([blob]) : new Blob([new Uint8Array(blob).buffer as ArrayBuffer]); - const fileId = await this.cloudCore.uploadFile({ - // onedrive-core prefixes its own mokuro-reader root, so pass just the - // bare series title here. - seriesTitle, - filename, - blob: blobToUpload, - credentials, - onProgress - }); + let fileId: string; + try { + fileId = await this.cloudCore.uploadFile({ + // onedrive-core prefixes its own mokuro-reader root, so pass just the + // bare series title here. + seriesTitle, + filename, + blob: blobToUpload, + credentials, + onProgress + }); + } catch (error) { + if (error instanceof ProviderError) throw error; + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new ProviderError( + `OneDrive upload failed: ${message}`, + 'onedrive', + 'UPLOAD_FAILED', + /\b401\b/.test(message), + /network|timed out|\b429\b|\b5\d\d\b/i.test(message) + ); + } console.log(`✅ Uploaded ${path} to OneDrive`); return fileId; } @@ -246,11 +259,24 @@ export class OneDriveProvider implements SyncProvider { throw new ProviderError('Not authenticated', 'onedrive', 'NOT_AUTHENTICATED', true); } const credentials = await this.getWorkerDownloadCredentials(file.fileId); - const buffer = await this.cloudCore.downloadFile({ - fileId: file.fileId, - credentials, - onProgress: onProgress || (() => {}) - }); + let buffer: ArrayBuffer; + try { + buffer = await this.cloudCore.downloadFile({ + fileId: file.fileId, + credentials, + onProgress: onProgress || (() => {}) + }); + } catch (error) { + if (error instanceof ProviderError) throw error; + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new ProviderError( + `OneDrive download failed: ${message}`, + 'onedrive', + 'DOWNLOAD_FAILED', + /\b401\b/.test(message), + /network|timed out|\b429\b|\b5\d\d\b/i.test(message) + ); + } return new Blob([buffer], { type: 'application/zip' }); } From 9e410e1eb93763e56ceb862ee50f4d5c30f564ff Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:25:53 -0700 Subject: [PATCH 53/65] fix(onedrive): coalesce folder creation; tolerate 409 from concurrent clients --- .../__tests__/onedrive-provider.test.ts | 49 +++++++++++++++- .../providers/onedrive/onedrive-provider.ts | 56 +++++++++++++++++-- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts b/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts index c118e748..0e02d8d4 100644 --- a/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts +++ b/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts @@ -28,7 +28,8 @@ vi.mock('../graph-client', () => ({ })); import { OneDriveProvider } from '../onedrive-provider'; -import { getItemByPath, listChildren } from '../graph-client'; +import { getItemByPath, listChildren, createFolder } from '../graph-client'; +import { ProviderError } from '../../../provider-interface'; describe('OneDriveProvider.listCloudVolumes', () => { let provider: OneDriveProvider; @@ -66,3 +67,49 @@ describe('OneDriveProvider.listCloudVolumes', () => { await expect(provider.listCloudVolumes()).rejects.toThrow(/404/); }); }); + +describe('folder creation coalescing', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a missing series folder exactly once under concurrent prepareUploadTarget calls', async () => { + let created = false; + vi.mocked(getItemByPath).mockImplementation(async (_t, path) => { + if (path === 'mokuro-reader') return { id: 'root-id', name: 'mokuro-reader', folder: {} }; + return created ? { id: 'series-id', name: 'Series', folder: {} } : null; + }); + vi.mocked(createFolder).mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 10)); + created = true; + return { id: 'series-id', name: 'Series', folder: {} }; + }); + + const provider = new OneDriveProvider(); + await Promise.all([ + provider.prepareUploadTarget('Series'), + provider.prepareUploadTarget('Series'), + provider.prepareUploadTarget('Series') + ]); + + // Two POSTs at most: one for the series folder. The mokuro root already + // exists, so exactly one createFolder call total. + expect(vi.mocked(createFolder)).toHaveBeenCalledTimes(1); + }); + + it('recovers when createFolder 409s because another client already created it', async () => { + let probes = 0; + vi.mocked(getItemByPath).mockImplementation(async (_t, path) => { + if (path === 'mokuro-reader') return { id: 'root-id', name: 'mokuro-reader', folder: {} }; + probes++; + // Missing on the first existence probe, present on the post-409 re-fetch. + return probes > 1 ? { id: 'series-id', name: 'Series', folder: {} } : null; + }); + vi.mocked(createFolder).mockRejectedValue( + new ProviderError('Graph 409 Conflict: nameAlreadyExists', 'onedrive', 'GRAPH_409') + ); + + const provider = new OneDriveProvider(); + await expect(provider.prepareUploadTarget('Series')).resolves.not.toThrow(); + }); +}); diff --git a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts index 4fca5f2b..28785080 100644 --- a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts +++ b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts @@ -141,13 +141,46 @@ export class OneDriveProvider implements SyncProvider { await onedriveTokenManager.reauthenticate(); } + // Coalesce concurrent folder creation (MEGA pattern): parallel uploads into + // a new series must not each POST createFolder — Graph 409s on the losers. + private mokuroFolderPromise: Promise | null = null; + private seriesFolderPromises = new Map>(); + + /** + * createFolder uses conflictBehavior 'fail'; if ANOTHER client (worker, + * second tab) won the race, re-fetch and return the existing folder. + */ + private async createFolderTolerant(parentPath: string, name: string): Promise { + const token = await onedriveTokenManager.getAccessToken(); + try { + const created = await createFolder(token, parentPath, name); + return created.id; + } catch (error) { + if (error instanceof ProviderError && error.code === 'GRAPH_409') { + const fullPath = parentPath ? `${parentPath}/${name}` : name; + const existing = await getItemByPath(token, fullPath); + if (existing) return existing.id; + } + throw error; + } + } + private async ensureMokuroFolder(): Promise { const token = await onedriveTokenManager.getAccessToken(); const existing = await getItemByPath(token, ONEDRIVE_CONFIG.MOKURO_FOLDER); if (existing) return existing.id; - const created = await createFolder(token, '', ONEDRIVE_CONFIG.MOKURO_FOLDER); - console.log(`Created ${ONEDRIVE_CONFIG.MOKURO_FOLDER} folder in OneDrive`); - return created.id; + + if (this.mokuroFolderPromise) return this.mokuroFolderPromise; + this.mokuroFolderPromise = (async () => { + try { + const id = await this.createFolderTolerant('', ONEDRIVE_CONFIG.MOKURO_FOLDER); + console.log(`Created ${ONEDRIVE_CONFIG.MOKURO_FOLDER} folder in OneDrive`); + return id; + } finally { + this.mokuroFolderPromise = null; + } + })(); + return this.mokuroFolderPromise; } private async ensureSeriesFolder(seriesTitle: string): Promise { @@ -155,9 +188,20 @@ export class OneDriveProvider implements SyncProvider { const path = `${ONEDRIVE_CONFIG.MOKURO_FOLDER}/${seriesTitle}`; const existing = await getItemByPath(token, path); if (existing) return existing.id; - await this.ensureMokuroFolder(); - const created = await createFolder(token, ONEDRIVE_CONFIG.MOKURO_FOLDER, seriesTitle); - return created.id; + + const inFlight = this.seriesFolderPromises.get(seriesTitle); + if (inFlight) return inFlight; + + const promise = (async () => { + try { + await this.ensureMokuroFolder(); + return await this.createFolderTolerant(ONEDRIVE_CONFIG.MOKURO_FOLDER, seriesTitle); + } finally { + this.seriesFolderPromises.delete(seriesTitle); + } + })(); + this.seriesFolderPromises.set(seriesTitle, promise); + return promise; } async listCloudVolumes(): Promise { From ec0b16849f9886d52885e5a0c65c617b80befe71 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:28:29 -0700 Subject: [PATCH 54/65] feat(onedrive): chunk upload retry/resume via nextExpectedRanges, timeouts, 202 drain --- .../providers/__tests__/onedrive-core.test.ts | 128 ++++++++++++++++++ .../util/sync/core/providers/onedrive-core.ts | 116 +++++++++++++--- 2 files changed, 224 insertions(+), 20 deletions(-) diff --git a/src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts b/src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts index cadac35a..83bfc2a7 100644 --- a/src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts +++ b/src/lib/util/sync/core/providers/__tests__/onedrive-core.test.ts @@ -116,5 +116,133 @@ describe('onedriveCore', () => { }) ).rejects.toThrow(/access token/i); }); + + it( + 'retries a transient 503 chunk failure, resuming from nextExpectedRanges', + { timeout: 15000 }, + async () => { + const CHUNK = 10 * 1024 * 1024; + // Session init + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ uploadUrl: 'https://upload.example/xyz' }) + } as Response); + // Chunk 1 OK (202) + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 202, + json: async () => ({ nextExpectedRanges: [`${CHUNK}-`] }) + } as Response); + // Chunk 2 fails transiently + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => '' + } as Response); + // Session status query → resume where we left off + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ nextExpectedRanges: [`${CHUNK}-`] }) + } as Response); + // Chunk 2 retry succeeds (final → 201 + driveItem) + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: 'item-after-retry' }) + } as Response); + + const blob = new Blob([new Uint8Array(CHUNK + 100)]); + const id = await onedriveCore.uploadFile({ + seriesTitle: 'S', + filename: 'v.cbz', + blob, + credentials: { accessToken: 'TOKEN' } + }); + expect(id).toBe('item-after-retry'); + // init + chunk1 + failed chunk2 + status query + retried chunk2 + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(5); + } + ); + + it( + 'gives up after repeated transient failures with a descriptive error', + { timeout: 30000 }, + async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ uploadUrl: 'https://upload.example/xyz' }) + } as Response); + // Every subsequent call (chunk PUTs and status queries) fails + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => '', + json: async () => ({}) + } as Response); + + const blob = new Blob([new Uint8Array(100)]); + await expect( + onedriveCore.uploadFile({ + seriesTitle: 'S', + filename: 'v.cbz', + blob, + credentials: { accessToken: 'TOKEN' } + }) + ).rejects.toThrow(/after 5 attempts/i); + } + ); + + it('fails fast on a non-retryable 4xx without retrying', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ uploadUrl: 'https://upload.example/xyz' }) + } as Response); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'invalid range' + } as Response); + + const blob = new Blob([new Uint8Array(100)]); + await expect( + onedriveCore.uploadFile({ + seriesTitle: 'S', + filename: 'v.cbz', + blob, + credentials: { accessToken: 'TOKEN' } + }) + ).rejects.toThrow(/400/); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); // no retry + }); + + it('consumes 202 response bodies (no unread streams)', async () => { + const CHUNK = 10 * 1024 * 1024; + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ uploadUrl: 'https://upload.example/xyz' }) + } as Response); + const json202 = vi.fn(async () => ({ nextExpectedRanges: [`${CHUNK}-`] })); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 202, + json: json202 + } as unknown as Response); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: 'done' }) + } as Response); + + await onedriveCore.uploadFile({ + seriesTitle: 'S', + filename: 'v.cbz', + blob: new Blob([new Uint8Array(CHUNK + 1)]), + credentials: { accessToken: 'TOKEN' } + }); + expect(json202).toHaveBeenCalled(); + }); }); }); diff --git a/src/lib/util/sync/core/providers/onedrive-core.ts b/src/lib/util/sync/core/providers/onedrive-core.ts index 2e57519b..ffba516f 100644 --- a/src/lib/util/sync/core/providers/onedrive-core.ts +++ b/src/lib/util/sync/core/providers/onedrive-core.ts @@ -1,7 +1,7 @@ import type { CloudProviderCore } from '../cloud-provider-core-types'; import { requireCredentialString } from '../cloud-provider-core-types'; import { ONEDRIVE_CONFIG } from '../../providers/onedrive/constants'; -import { createChunkRanges } from '../../providers/onedrive/upload-session'; +import { parseNextExpectedRange } from '../../providers/onedrive/upload-session'; const BASE = ONEDRIVE_CONFIG.GRAPH_BASE_URL; @@ -9,6 +9,39 @@ function encodePath(path: string): string { return path.split('/').filter(Boolean).map(encodeURIComponent).join('/'); } +const MAX_CHUNK_ATTEMPTS = 5; +const RETRY_BASE_DELAY_MS = 400; +const RETRY_MAX_DELAY_MS = 5000; +const CHUNK_TIMEOUT_MS = 5 * 60 * 1000; +const SESSION_TIMEOUT_MS = 30 * 1000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableStatus(status: number): boolean { + return status === 408 || status === 429 || status >= 500; +} + +/** + * Ask the upload session where to resume (Graph tracks received ranges + * server-side). Returns null when the session can't say — caller retries + * from its own counter. + */ +async function queryResumeOffset(uploadUrl: string): Promise { + try { + const response = await fetch(uploadUrl, { signal: AbortSignal.timeout(SESSION_TIMEOUT_MS) }); + if (!response.ok) { + await response.text().catch(() => ''); + return null; + } + const data = (await response.json()) as { nextExpectedRanges?: string[] }; + return data.nextExpectedRanges ? parseNextExpectedRange(data.nextExpectedRanges) : null; + } catch { + return null; + } +} + export const onedriveCore: CloudProviderCore = { async downloadFile({ fileId, credentials, onProgress }): Promise { const accessToken = requireCredentialString( @@ -66,7 +99,8 @@ export const onedriveCore: CloudProviderCore = { }, body: JSON.stringify({ item: { '@microsoft.graph.conflictBehavior': 'replace' } - }) + }), + signal: AbortSignal.timeout(SESSION_TIMEOUT_MS) } ); if (!sessionResponse.ok) { @@ -77,29 +111,71 @@ export const onedriveCore: CloudProviderCore = { const { uploadUrl } = (await sessionResponse.json()) as { uploadUrl: string }; let lastItemId: string | null = null; - for (const range of createChunkRanges(blob.size, ONEDRIVE_CONFIG.UPLOAD_CHUNK_SIZE)) { - const chunk = blob.slice(range.start, range.end + 1); - const chunkResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'Content-Length': String(range.end - range.start + 1), - 'Content-Range': `bytes ${range.start}-${range.end}/${range.total}` - }, - body: chunk - }); - if (!chunkResponse.ok) { - throw new Error( - `OneDrive upload chunk failed: ${chunkResponse.status} ${chunkResponse.statusText}` - ); + let offset = 0; + let attempt = 0; + + const retryOrThrow = async (reason: string): Promise => { + attempt++; + if (attempt >= MAX_CHUNK_ATTEMPTS) { + throw new Error(`OneDrive upload failed after ${MAX_CHUNK_ATTEMPTS} attempts: ${reason}`); + } + await sleep(Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS)); + // Trust Graph's record of received bytes over our own counter. + const resume = await queryResumeOffset(uploadUrl); + if (resume !== null) offset = resume; + }; + + while (offset < blob.size) { + const end = Math.min(offset + ONEDRIVE_CONFIG.UPLOAD_CHUNK_SIZE - 1, blob.size - 1); + + let chunkResponse: Response; + try { + chunkResponse = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Length': String(end - offset + 1), + 'Content-Range': `bytes ${offset}-${end}/${blob.size}` + }, + body: blob.slice(offset, end + 1), + signal: AbortSignal.timeout(CHUNK_TIMEOUT_MS) + }); + } catch (error) { + await retryOrThrow(error instanceof Error ? error.message : 'network error'); + continue; } - onProgress?.(range.end + 1, range.total); - // Final chunk returns the completed driveItem (201 Created or 200 OK). - // Non-final chunks return 202 Accepted with nextExpectedRanges. - if (chunkResponse.status === 201 || chunkResponse.status === 200) { + if (chunkResponse.status === 200 || chunkResponse.status === 201) { + // Final chunk returns the completed driveItem. const item = (await chunkResponse.json()) as { id: string }; lastItemId = item.id; + offset = end + 1; + attempt = 0; + onProgress?.(offset, blob.size); + continue; + } + + if (chunkResponse.status === 202) { + // Intermediate chunk. Drain the body (avoids stream retention) and + // use Graph's nextExpectedRanges as the authoritative next offset. + const body = (await chunkResponse.json().catch(() => null)) as { + nextExpectedRanges?: string[]; + } | null; + const next = body?.nextExpectedRanges + ? parseNextExpectedRange(body.nextExpectedRanges) + : null; + offset = next ?? end + 1; + attempt = 0; + onProgress?.(offset, blob.size); + continue; + } + + await chunkResponse.text().catch(() => ''); + if (!isRetryableStatus(chunkResponse.status)) { + throw new Error( + `OneDrive upload chunk failed: ${chunkResponse.status} ${chunkResponse.statusText}` + ); } + await retryOrThrow(`HTTP ${chunkResponse.status} ${chunkResponse.statusText}`); } if (!lastItemId) { From 7f2be16af19d6d1ba38fd94c926f07a4ee38a54f Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:32:56 -0700 Subject: [PATCH 55/65] fix(filesystem): typed errors, dead-handle recovery, honest quota --- .../filesystem-provider-restore.test.ts | 86 ++++++ .../__tests__/filesystem-provider.test.ts | 79 ++++++ .../filesystem/filesystem-provider.ts | 266 ++++++++++++------ 3 files changed, 344 insertions(+), 87 deletions(-) create mode 100644 src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider-restore.test.ts diff --git a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider-restore.test.ts b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider-restore.test.ts new file mode 100644 index 00000000..dcd9086f --- /dev/null +++ b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider-restore.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Restore tests exercise the real constructor path (browser + supported API), +// unlike filesystem-provider.test.ts which pins browser:false to skip it. +vi.mock('$app/environment', () => ({ browser: true })); + +vi.mock('../feature-detect', () => ({ + isFilesystemProviderSupported: vi.fn(() => true) +})); + +vi.mock('../handle-store', () => ({ + loadRootHandle: vi.fn(), + saveRootHandle: vi.fn(), + clearRootHandle: vi.fn(async () => {}) +})); + +vi.mock('../../../provider-detection', () => ({ + setActiveProviderKey: vi.fn(), + clearActiveProviderKey: vi.fn() +})); + +import { FilesystemProvider } from '../filesystem-provider'; +import { loadRootHandle, clearRootHandle } from '../handle-store'; +import { clearActiveProviderKey } from '../../../provider-detection'; + +describe('FilesystemProvider.restoreHandle', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('clears a stored handle whose queryPermission throws (folder deleted/moved)', async () => { + const brokenError = new Error('The object is in an invalid state'); + brokenError.name = 'InvalidStateError'; + const broken = { + name: 'gone', + queryPermission: vi.fn().mockRejectedValue(brokenError) + }; + vi.mocked(loadRootHandle).mockResolvedValue(broken as unknown as FileSystemDirectoryHandle); + + const provider = new FilesystemProvider(); + await provider.whenReady(); + + expect(clearRootHandle).toHaveBeenCalled(); + expect(clearActiveProviderKey).toHaveBeenCalled(); + expect(provider.getStatus().hasStoredCredentials).toBe(false); + expect(provider.getStatus().needsAttention).toBe(false); + }); + + it('keeps config when the IndexedDB read itself fails (transient)', async () => { + vi.mocked(loadRootHandle).mockRejectedValue(new Error('idb unavailable')); + + const provider = new FilesystemProvider(); + await provider.whenReady(); + + expect(clearRootHandle).not.toHaveBeenCalled(); + }); + + it('restores the handle when permission is still granted', async () => { + const good = { + name: 'manga', + queryPermission: vi.fn().mockResolvedValue('granted') + }; + vi.mocked(loadRootHandle).mockResolvedValue(good as unknown as FileSystemDirectoryHandle); + + const provider = new FilesystemProvider(); + await provider.whenReady(); + + expect(provider.isAuthenticated()).toBe(true); + expect(provider.getStatus().statusMessage).toContain('manga'); + }); + + it('leaves the reconnect state when permission is "prompt"', async () => { + const prompt = { + name: 'manga', + queryPermission: vi.fn().mockResolvedValue('prompt') + }; + vi.mocked(loadRootHandle).mockResolvedValue(prompt as unknown as FileSystemDirectoryHandle); + + const provider = new FilesystemProvider(); + await provider.whenReady(); + + expect(provider.isAuthenticated()).toBe(false); + expect(provider.getStatus().needsAttention).toBe(true); + expect(clearRootHandle).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts index b958d200..cdeae784 100644 --- a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts +++ b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; vi.mock('$app/environment', () => ({ browser: false })); import { FilesystemProvider } from '../filesystem-provider'; +import { ProviderError } from '../../../provider-interface'; // --------------------------------------------------------------------------- // Minimal in-memory File System Access API fake (just enough for the provider: @@ -133,3 +134,81 @@ describe('FilesystemProvider.renameFolder', () => { expect(moved!.size).toBe(expectedSize); }); }); + +function notAllowed(): Error { + const e = new Error('The request is not allowed'); + e.name = 'NotAllowedError'; + return e; +} + +describe('error classification', () => { + it('converts NotFoundError to a typed NOT_FOUND ProviderError with a sniffable message', async () => { + const root = new FakeDirHandle(''); + const provider = makeProvider(root); + + const err = await provider + .downloadFile({ + provider: 'filesystem', + fileId: 'S/v.cbz', + path: 'S/v.cbz', + modifiedTime: '', + size: 1 + }) + .catch((e) => e); + + expect(err).toBeInstanceOf(ProviderError); + expect(err.code).toBe('NOT_FOUND'); + expect(err.message).toMatch(/not found/i); + }); + + it('converts NotAllowedError to isAuthError and flips into needs-reconnect state', async () => { + const root = new FakeDirHandle(''); + root.getFileHandle = vi.fn().mockRejectedValue(notAllowed()); + const provider = makeProvider(root); + (provider as unknown as { hasStoredHandle: boolean }).hasStoredHandle = true; + + const err = await provider + .downloadFile({ + provider: 'filesystem', + fileId: 'v.cbz', + path: 'v.cbz', + modifiedTime: '', + size: 1 + }) + .catch((e) => e); + + expect(err).toBeInstanceOf(ProviderError); + expect(err.isAuthError).toBe(true); + expect(provider.isAuthenticated()).toBe(false); + expect(provider.getStatus().needsAttention).toBe(true); + }); + + it('converts deleteFile NotFoundError to the typed NOT_FOUND consumed by idempotent deletes', async () => { + const root = new FakeDirHandle(''); + const provider = makeProvider(root); + + const err = await provider + .deleteFile({ + provider: 'filesystem', + fileId: 'S/v.cbz', + path: 'S/v.cbz', + modifiedTime: '', + size: 1 + }) + .catch((e) => e); + + expect(err).toBeInstanceOf(ProviderError); + expect(err.code).toBe('NOT_FOUND'); + }); +}); + +describe('getStorageQuota', () => { + it('returns the unavailable shape — origin estimate is not folder disk space', async () => { + const provider = makeProvider(new FakeDirHandle('')); + await expect(provider.getStorageQuota()).resolves.toEqual({ + used: 0, + total: null, + available: null + }); + }); +}); diff --git a/src/lib/util/sync/providers/filesystem/filesystem-provider.ts b/src/lib/util/sync/providers/filesystem/filesystem-provider.ts index 90abf23a..bc678d2f 100644 --- a/src/lib/util/sync/providers/filesystem/filesystem-provider.ts +++ b/src/lib/util/sync/providers/filesystem/filesystem-provider.ts @@ -112,8 +112,22 @@ export class FilesystemProvider implements SyncProvider { this.hasStoredHandle = false; throw new ProviderError('Stored folder reference is missing', 'filesystem', 'NOT_CONFIGURED'); } - // @ts-expect-error — requestPermission is Chromium-only, not in all TS lib.dom targets - const permission = await stored.requestPermission({ mode: 'readwrite' }); + let permission: string; + try { + // @ts-expect-error — requestPermission is Chromium-only, not in all TS lib.dom targets + permission = await stored.requestPermission({ mode: 'readwrite' }); + } catch (error) { + // Handle is dead (folder deleted/moved) — clear it so login() offers a fresh picker. + console.warn('Stored filesystem handle is unusable; clearing:', error); + this.hasStoredHandle = false; + await clearRootHandle().catch(() => {}); + clearActiveProviderKey(); + throw new ProviderError( + 'The previously chosen folder no longer exists — choose a folder again', + 'filesystem', + 'NOT_CONFIGURED' + ); + } if (permission !== 'granted') { // Keep the stored handle — user may grant on a later attempt throw new ProviderError('Permission was not granted', 'filesystem', 'PERMISSION_DENIED'); @@ -124,10 +138,17 @@ export class FilesystemProvider implements SyncProvider { } private async restoreHandle(): Promise { + let stored: FileSystemDirectoryHandle | null = null; + try { + stored = await loadRootHandle(); + } catch (error) { + // IndexedDB read failed (transient) — keep config; a reload can retry. + console.warn('Failed to load stored filesystem handle:', error); + return; + } + if (!stored) return; + this.hasStoredHandle = true; try { - const stored = await loadRootHandle(); - if (!stored) return; - this.hasStoredHandle = true; // @ts-expect-error — queryPermission is Chromium-only, not in all TS lib.dom targets const permission = await stored.queryPermission({ mode: 'readwrite' }); if (permission === 'granted') { @@ -141,7 +162,13 @@ export class FilesystemProvider implements SyncProvider { } // 'prompt' → leave rootHandle null; UI will show "Reconnect" } catch (error) { - console.warn('Failed to restore filesystem handle:', error); + // queryPermission threw: the handle itself is dead (folder deleted or + // moved). Clear it so the user gets a fresh picker instead of a + // Reconnect button that can never succeed. + console.warn('Stored filesystem handle is unusable; clearing:', error); + this.hasStoredHandle = false; + await clearRootHandle().catch(() => {}); + clearActiveProviderKey(); } } @@ -157,6 +184,47 @@ export class FilesystemProvider implements SyncProvider { return this.rootHandle; } + /** Refresh provider-manager status after an in-provider state change + * (dynamic import avoids a circular dependency — same as WebDAV). */ + private notifyStatusChanged(): void { + import('../../provider-manager').then(({ providerManager }) => { + providerManager.updateStatus(); + }); + } + + /** + * Convert raw File System Access API failures into typed ProviderErrors. + * NOT_FOUND code + "not found" message are load-bearing: unified-cloud-manager + * keys idempotent deletes off the code, and unified-sync-service sniffs the + * message for missing-file-is-fine paths. Matches on error.name (not + * instanceof DOMException) so cross-realm exceptions classify too. + */ + private toProviderError(error: unknown, operation: string, path: string): ProviderError { + if (error instanceof ProviderError) return error; + const name = error instanceof Error ? error.name : ''; + if (name === 'NotFoundError') { + return new ProviderError( + `${operation} failed: '${path}' not found`, + 'filesystem', + 'NOT_FOUND' + ); + } + if (name === 'NotAllowedError' || name === 'SecurityError') { + // Permission revoked mid-session — flip to needs-reconnect so the UI + // stops pretending we're connected. + this.rootHandle = null; + this.notifyStatusChanged(); + return new ProviderError( + `${operation} failed: folder permission was revoked`, + 'filesystem', + 'PERMISSION_REVOKED', + true + ); + } + const message = error instanceof Error ? error.message : 'Unknown error'; + return new ProviderError(`${operation} failed: ${message}`, 'filesystem', 'OPERATION_FAILED'); + } + private async resolveDirectoryHandle( relativePath: string, options: { create: boolean } @@ -200,21 +268,25 @@ export class FilesystemProvider implements SyncProvider { } async listCloudVolumes(): Promise { - const root = this.requireRoot(); - const results: CloudFileMetadata[] = []; - for await (const { path, fileHandle } of this.walkDirectory(root, '')) { - if (!isSyncableFile(path)) continue; - const file = await fileHandle.getFile(); - results.push({ - provider: 'filesystem', - fileId: path, - path, - modifiedTime: new Date(file.lastModified).toISOString(), - size: file.size - }); + try { + const root = this.requireRoot(); + const results: CloudFileMetadata[] = []; + for await (const { path, fileHandle } of this.walkDirectory(root, '')) { + if (!isSyncableFile(path)) continue; + const file = await fileHandle.getFile(); + results.push({ + provider: 'filesystem', + fileId: path, + path, + modifiedTime: new Date(file.lastModified).toISOString(), + size: file.size + }); + } + console.log(`✅ Listed ${results.length} files from filesystem provider`); + return results; + } catch (error) { + throw this.toProviderError(error, 'List', ''); } - console.log(`✅ Listed ${results.length} files from filesystem provider`); - return results; } async uploadFile( @@ -223,87 +295,111 @@ export class FilesystemProvider implements SyncProvider { _description?: string, onProgress?: (loaded: number, total: number) => void ): Promise { - this.requireRoot(); - const fileHandle = await this.resolveFileHandle(path, { create: true }); - const writable = await fileHandle.createWritable(); try { - const payload = - blob instanceof Blob - ? blob - : blob instanceof ArrayBuffer - ? new Blob([blob]) - : new Blob([new Uint8Array(blob).buffer as ArrayBuffer]); - await writable.write(payload); - onProgress?.(payload.size, payload.size); - } finally { - await writable.close(); + this.requireRoot(); + const fileHandle = await this.resolveFileHandle(path, { create: true }); + const writable = await fileHandle.createWritable(); + try { + const payload = + blob instanceof Blob + ? blob + : blob instanceof ArrayBuffer + ? new Blob([blob]) + : new Blob([new Uint8Array(blob).buffer as ArrayBuffer]); + await writable.write(payload); + onProgress?.(payload.size, payload.size); + } finally { + await writable.close(); + } + console.log(`✅ Uploaded ${path} to filesystem`); + return path; + } catch (error) { + throw this.toProviderError(error, 'Upload', path); } - console.log(`✅ Uploaded ${path} to filesystem`); - return path; } async downloadFile( file: CloudFileMetadata, onProgress?: (loaded: number, total: number) => void ): Promise { - this.requireRoot(); - const fileHandle = await this.resolveFileHandle(file.fileId, { create: false }); - const data = await fileHandle.getFile(); - onProgress?.(data.size, data.size); - console.log(`✅ Downloaded ${file.path} from filesystem`); - return data; + try { + this.requireRoot(); + const fileHandle = await this.resolveFileHandle(file.fileId, { create: false }); + const data = await fileHandle.getFile(); + onProgress?.(data.size, data.size); + console.log(`✅ Downloaded ${file.path} from filesystem`); + return data; + } catch (error) { + throw this.toProviderError(error, 'Download', file.path); + } } async deleteFile(file: CloudFileMetadata): Promise { - this.requireRoot(); - const parentPath = getParentPath(file.fileId); - const filename = getBasename(file.fileId); - const parent = parentPath - ? await this.resolveDirectoryHandle(parentPath, { create: false }) - : this.requireRoot(); - await parent.removeEntry(filename); - console.log(`✅ Deleted ${file.path} from filesystem`); + try { + this.requireRoot(); + const parentPath = getParentPath(file.fileId); + const filename = getBasename(file.fileId); + const parent = parentPath + ? await this.resolveDirectoryHandle(parentPath, { create: false }) + : this.requireRoot(); + await parent.removeEntry(filename); + console.log(`✅ Deleted ${file.path} from filesystem`); + } catch (error) { + throw this.toProviderError(error, 'Delete', file.path); + } } async renameFile(file: CloudFileMetadata, newPath: string): Promise { - this.requireRoot(); const normalizedNewPath = newPath.replace(/^\/+|\/+$/g, ''); - if (file.path === normalizedNewPath) { - return file; - } + try { + this.requireRoot(); + if (file.path === normalizedNewPath) { + return file; + } - // Read source - const sourceHandle = await this.resolveFileHandle(file.fileId, { create: false }); - const sourceFile = await sourceHandle.getFile(); + // Read source + const sourceHandle = await this.resolveFileHandle(file.fileId, { create: false }); + const sourceFile = await sourceHandle.getFile(); - // Write to destination - const destHandle = await this.resolveFileHandle(normalizedNewPath, { create: true }); - const writable = await destHandle.createWritable(); - try { - await writable.write(sourceFile); - } finally { - await writable.close(); - } + // Write to destination + const destHandle = await this.resolveFileHandle(normalizedNewPath, { create: true }); + const writable = await destHandle.createWritable(); + try { + await writable.write(sourceFile); + } finally { + await writable.close(); + } - // Delete source - const sourceParentPath = getParentPath(file.fileId); - const sourceParent = sourceParentPath - ? await this.resolveDirectoryHandle(sourceParentPath, { create: false }) - : this.requireRoot(); - await sourceParent.removeEntry(getBasename(file.fileId)); + // Delete source + const sourceParentPath = getParentPath(file.fileId); + const sourceParent = sourceParentPath + ? await this.resolveDirectoryHandle(sourceParentPath, { create: false }) + : this.requireRoot(); + await sourceParent.removeEntry(getBasename(file.fileId)); - console.log(`✅ Renamed ${file.path} → ${normalizedNewPath} in filesystem`); - const destFile = await destHandle.getFile(); - return { - provider: 'filesystem', - fileId: normalizedNewPath, - path: normalizedNewPath, - modifiedTime: new Date(destFile.lastModified).toISOString(), - size: destFile.size - }; + console.log(`✅ Renamed ${file.path} → ${normalizedNewPath} in filesystem`); + const destFile = await destHandle.getFile(); + return { + provider: 'filesystem', + fileId: normalizedNewPath, + path: normalizedNewPath, + modifiedTime: new Date(destFile.lastModified).toISOString(), + size: destFile.size + }; + } catch (error) { + throw this.toProviderError(error, 'Rename', file.path); + } } async renameFolder(oldPath: string, newPath: string): Promise { + try { + return await this.renameFolderInner(oldPath, newPath); + } catch (error) { + throw this.toProviderError(error, 'Rename folder', oldPath); + } + } + + private async renameFolderInner(oldPath: string, newPath: string): Promise { this.requireRoot(); const normalizedOld = oldPath.replace(/^\/+|\/+$/g, ''); const normalizedNew = newPath.replace(/^\/+|\/+$/g, ''); @@ -365,14 +461,10 @@ export class FilesystemProvider implements SyncProvider { } async getStorageQuota(): Promise { - if (typeof navigator === 'undefined' || !navigator.storage?.estimate) { - return { used: 0, total: null, available: null }; - } - const estimate = await navigator.storage.estimate(); - const used = estimate.usage ?? 0; - const total = estimate.quota ?? null; - const available = total !== null ? total - used : null; - return { used, total, available }; + // navigator.storage.estimate() reports the browser-origin quota, which has + // nothing to do with the chosen folder's free disk space. Report "unknown" + // rather than a misleading number; the UI hides bars for null totals. + return { used: 0, total: null, available: null }; } } From ab361fe65aea0374f455cce148d627a244d4c985 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:34:07 -0700 Subject: [PATCH 56/65] fix(filesystem): idempotent renameFile retry (source gone, destination matches) --- .../__tests__/filesystem-provider.test.ts | 66 +++++++++++++++++++ .../filesystem/filesystem-provider.ts | 44 ++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts index cdeae784..75e8fcc3 100644 --- a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts +++ b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts @@ -202,6 +202,72 @@ describe('error classification', () => { }); }); +describe('renameFile idempotency', () => { + it('treats source-gone + matching destination as an already-completed rename', async () => { + const root = new FakeDirHandle(''); + await seedFile(root, 'B/v.cbz', '42-bytes-content-simulated-here-abcdefghi'); // 41 chars + const destSize = new Blob(['42-bytes-content-simulated-here-abcdefghi']).size; + const provider = makeProvider(root); + + const result = await provider.renameFile( + { + provider: 'filesystem', + fileId: 'A/v.cbz', + path: 'A/v.cbz', + modifiedTime: '', + size: destSize + }, + 'B/v.cbz' + ); + + expect(result.path).toBe('B/v.cbz'); + expect(result.size).toBe(destSize); + }); + + it('still throws typed NOT_FOUND when the source is gone and no matching destination exists', async () => { + const root = new FakeDirHandle(''); + const provider = makeProvider(root); + + const err = await provider + .renameFile( + { + provider: 'filesystem', + fileId: 'A/v.cbz', + path: 'A/v.cbz', + modifiedTime: '', + size: 42 + }, + 'B/v.cbz' + ) + .catch((e) => e); + + expect(err).toBeInstanceOf(ProviderError); + expect(err.code).toBe('NOT_FOUND'); + }); + + it('does not converge on a destination whose size differs from the source record', async () => { + const root = new FakeDirHandle(''); + await seedFile(root, 'B/v.cbz', 'different-length-content'); + const provider = makeProvider(root); + + const err = await provider + .renameFile( + { + provider: 'filesystem', + fileId: 'A/v.cbz', + path: 'A/v.cbz', + modifiedTime: '', + size: 99999 + }, + 'B/v.cbz' + ) + .catch((e) => e); + + expect(err).toBeInstanceOf(ProviderError); + expect(err.code).toBe('NOT_FOUND'); + }); +}); + describe('getStorageQuota', () => { it('returns the unavailable shape — origin estimate is not folder disk space', async () => { const provider = makeProvider(new FakeDirHandle('')); diff --git a/src/lib/util/sync/providers/filesystem/filesystem-provider.ts b/src/lib/util/sync/providers/filesystem/filesystem-provider.ts index bc678d2f..8bfe9248 100644 --- a/src/lib/util/sync/providers/filesystem/filesystem-provider.ts +++ b/src/lib/util/sync/providers/filesystem/filesystem-provider.ts @@ -358,8 +358,20 @@ export class FilesystemProvider implements SyncProvider { } // Read source - const sourceHandle = await this.resolveFileHandle(file.fileId, { create: false }); - const sourceFile = await sourceHandle.getFile(); + let sourceFile: File; + try { + const sourceHandle = await this.resolveFileHandle(file.fileId, { create: false }); + sourceFile = await sourceHandle.getFile(); + } catch (error) { + if (error instanceof Error && error.name === 'NotFoundError') { + // Idempotent retry: copy-then-delete isn't atomic, so a prior attempt + // may have completed. Source gone + destination matching the source's + // recorded size = already renamed (same convergence rule as WebDAV). + const converged = await this.findConvergedRename(file, normalizedNewPath); + if (converged) return converged; + } + throw error; + } // Write to destination const destHandle = await this.resolveFileHandle(normalizedNewPath, { create: true }); @@ -391,6 +403,34 @@ export class FilesystemProvider implements SyncProvider { } } + /** + * Check whether a rename already completed in a prior attempt: destination + * exists and its size matches the source's recorded size. Returns its + * metadata, or null when there is no matching destination. + */ + private async findConvergedRename( + file: CloudFileMetadata, + normalizedNewPath: string + ): Promise { + try { + const destHandle = await this.resolveFileHandle(normalizedNewPath, { create: false }); + const destFile = await destHandle.getFile(); + if (typeof file.size === 'number' && destFile.size === file.size) { + console.log(`↩️ ${normalizedNewPath} already at destination (idempotent retry)`); + return { + provider: 'filesystem', + fileId: normalizedNewPath, + path: normalizedNewPath, + modifiedTime: new Date(destFile.lastModified).toISOString(), + size: destFile.size + }; + } + } catch { + // No destination either — caller falls through to the typed NOT_FOUND. + } + return null; + } + async renameFolder(oldPath: string, newPath: string): Promise { try { return await this.renameFolderInner(oldPath, newPath); From 378cd6757e8adbc4681d95b237b3ae1f0aaf02bb Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:35:56 -0700 Subject: [PATCH 57/65] feat(sync): removeDirectoryIfEmpty for onedrive and filesystem providers --- .../__tests__/filesystem-provider.test.ts | 31 ++++++++++++++++ .../filesystem/filesystem-provider.ts | 26 +++++++++++++ .../__tests__/onedrive-provider.test.ts | 37 ++++++++++++++++++- .../providers/onedrive/onedrive-provider.ts | 23 ++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts index 75e8fcc3..df6b787a 100644 --- a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts +++ b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-provider.test.ts @@ -278,3 +278,34 @@ describe('getStorageQuota', () => { }); }); }); + +describe('removeDirectoryIfEmpty', () => { + it('removes an empty directory non-recursively', async () => { + const root = new FakeDirHandle(''); + await root.getDirectoryHandle('Old Series', { create: true }); + const removeSpy = vi.spyOn(root, 'removeEntry'); + const provider = makeProvider(root); + + await provider.removeDirectoryIfEmpty('Old Series'); + + expect(removeSpy).toHaveBeenCalledWith('Old Series'); + expect(root.children.has('Old Series')).toBe(false); + }); + + it('keeps a directory that still has entries', async () => { + const root = new FakeDirHandle(''); + await seedFile(root, 'Old Series/v.cbz', 'DATA'); + const removeSpy = vi.spyOn(root, 'removeEntry'); + const provider = makeProvider(root); + + await provider.removeDirectoryIfEmpty('Old Series'); + + expect(removeSpy).not.toHaveBeenCalled(); + expect(root.children.has('Old Series')).toBe(true); + }); + + it('is best-effort: swallows a missing directory', async () => { + const provider = makeProvider(new FakeDirHandle('')); + await expect(provider.removeDirectoryIfEmpty('Old Series')).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/util/sync/providers/filesystem/filesystem-provider.ts b/src/lib/util/sync/providers/filesystem/filesystem-provider.ts index 8bfe9248..0d0418c6 100644 --- a/src/lib/util/sync/providers/filesystem/filesystem-provider.ts +++ b/src/lib/util/sync/providers/filesystem/filesystem-provider.ts @@ -500,6 +500,32 @@ export class FilesystemProvider implements SyncProvider { } } + /** + * Remove a directory only if it is verifiably empty — never recursive. + * Best-effort: an orphaned empty directory is harmless. + */ + async removeDirectoryIfEmpty(relativePath: string): Promise { + if (!this.isAuthenticated()) return; + const normalized = relativePath.replace(/^\/+|\/+$/g, ''); + if (!normalized) return; + try { + const dir = await this.resolveDirectoryHandle(normalized, { create: false }); + // @ts-expect-error — values() is defined on FileSystemDirectoryHandle at runtime + for await (const _entry of dir.values()) { + void _entry; + return; // any entry → not empty → keep + } + const parentPath = getParentPath(normalized); + const parent = parentPath + ? await this.resolveDirectoryHandle(parentPath, { create: false }) + : this.requireRoot(); + await parent.removeEntry(getBasename(normalized)); + console.log(`✅ Pruned empty folder '${normalized}' from filesystem`); + } catch { + // Already gone or unreadable — harmless. + } + } + async getStorageQuota(): Promise { // navigator.storage.estimate() reports the browser-origin quota, which has // nothing to do with the chosen folder's free disk space. Report "unknown" diff --git a/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts b/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts index 0e02d8d4..0fa92537 100644 --- a/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts +++ b/src/lib/util/sync/providers/onedrive/__tests__/onedrive-provider.test.ts @@ -28,7 +28,7 @@ vi.mock('../graph-client', () => ({ })); import { OneDriveProvider } from '../onedrive-provider'; -import { getItemByPath, listChildren, createFolder } from '../graph-client'; +import { getItemByPath, listChildren, createFolder, deleteItem } from '../graph-client'; import { ProviderError } from '../../../provider-interface'; describe('OneDriveProvider.listCloudVolumes', () => { @@ -113,3 +113,38 @@ describe('folder creation coalescing', () => { await expect(provider.prepareUploadTarget('Series')).resolves.not.toThrow(); }); }); + +describe('removeDirectoryIfEmpty', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deletes a folder the server reports empty', async () => { + vi.mocked(getItemByPath).mockResolvedValue({ id: 'dir-id', name: 'Old', folder: {} }); + vi.mocked(listChildren).mockResolvedValue([]); + + const provider = new OneDriveProvider(); + await provider.removeDirectoryIfEmpty('Old Series'); + + expect(vi.mocked(deleteItem)).toHaveBeenCalledWith(expect.anything(), 'dir-id'); + }); + + it('keeps a folder that still has children', async () => { + vi.mocked(getItemByPath).mockResolvedValue({ id: 'dir-id', name: 'Old', folder: {} }); + vi.mocked(listChildren).mockResolvedValue([{ id: 'x', name: 'v.cbz', file: {} }]); + + const provider = new OneDriveProvider(); + await provider.removeDirectoryIfEmpty('Old Series'); + + expect(vi.mocked(deleteItem)).not.toHaveBeenCalled(); + }); + + it('no-ops when the folder is already gone', async () => { + vi.mocked(getItemByPath).mockResolvedValue(null); + + const provider = new OneDriveProvider(); + await expect(provider.removeDirectoryIfEmpty('Old Series')).resolves.toBeUndefined(); + + expect(vi.mocked(deleteItem)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts index 28785080..a99646f4 100644 --- a/src/lib/util/sync/providers/onedrive/onedrive-provider.ts +++ b/src/lib/util/sync/providers/onedrive/onedrive-provider.ts @@ -435,6 +435,29 @@ export class OneDriveProvider implements SyncProvider { console.log(`✅ Deleted series folder '${seriesTitle}' from OneDrive`); } + /** + * Remove a series directory only if the SERVER confirms it is empty — never + * a blind recursive delete (Graph folder deletion is recursive). Best-effort: + * an orphaned empty directory is harmless. + */ + async removeDirectoryIfEmpty(relativePath: string): Promise { + if (!this.isAuthenticated()) return; + const normalized = relativePath.replace(/^\/+|\/+$/g, ''); + if (!normalized) return; + try { + const token = await onedriveTokenManager.getAccessToken(); + const path = `${ONEDRIVE_CONFIG.MOKURO_FOLDER}/${normalized}`; + const item = await getItemByPath(token, path); + if (!item || !item.folder) return; + const children = await listChildren(token, path); + if (children.length > 0) return; + await deleteItem(token, item.id); + console.log(`✅ Pruned empty series folder '${normalized}' from OneDrive`); + } catch (error) { + console.warn(`Could not prune OneDrive folder '${normalized}':`, error); + } + } + async getStorageQuota(): Promise { if (!this.isAuthenticated()) { return { used: 0, total: null, available: null }; From 0cc815e99c9ad3ad1fdee84cab6844857961d561 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 12:39:03 -0700 Subject: [PATCH 58/65] feat(ui): gate actions on reconnect states; shared provider labels; config-gate OneDrive --- .../components/PlaceholderVolumeItem.svelte | 45 ++-------------- src/lib/components/VolumeItem.svelte | 3 +- src/lib/util/sync/provider-display.test.ts | 19 +++++++ src/lib/util/sync/provider-display.ts | 29 ++++++++++ src/lib/views/CloudView.svelte | 54 +++++++++++-------- 5 files changed, 85 insertions(+), 65 deletions(-) create mode 100644 src/lib/util/sync/provider-display.test.ts create mode 100644 src/lib/util/sync/provider-display.ts diff --git a/src/lib/components/PlaceholderVolumeItem.svelte b/src/lib/components/PlaceholderVolumeItem.svelte index 317aa3ac..72357c18 100644 --- a/src/lib/components/PlaceholderVolumeItem.svelte +++ b/src/lib/components/PlaceholderVolumeItem.svelte @@ -12,7 +12,8 @@ getCloudSize, getCloudModifiedTime } from '$lib/util/cloud-fields'; - import type { ProviderType, CloudFileMetadata } from '$lib/util/sync/provider-interface'; + import type { CloudFileMetadata } from '$lib/util/sync/provider-interface'; + import { PROVIDER_SHORT_LABELS, PROVIDER_BADGE_COLORS } from '$lib/util/sync/provider-display'; import PlaceholderThumbnail from './PlaceholderThumbnail.svelte'; interface Props { @@ -36,46 +37,8 @@ return `${mb} MB`; }); - // Provider display helpers - function getProviderDisplayName(provider: ProviderType): string { - switch (provider) { - case 'google-drive': - return 'Drive'; - case 'mega': - return 'MEGA'; - case 'webdav': - return 'WebDAV'; - default: - return 'Cloud'; - } - } - - type BadgeColor = - | 'blue' - | 'purple' - | 'green' - | 'gray' - | 'red' - | 'yellow' - | 'primary' - | 'pink' - | 'indigo'; - - function getProviderBadgeColor(provider: ProviderType): BadgeColor { - switch (provider) { - case 'google-drive': - return 'blue'; - case 'mega': - return 'purple'; - case 'webdav': - return 'green'; - default: - return 'gray'; - } - } - - const providerName = cloudProvider ? getProviderDisplayName(cloudProvider) : 'Cloud'; - const badgeColor = cloudProvider ? getProviderBadgeColor(cloudProvider) : 'gray'; + const providerName = cloudProvider ? PROVIDER_SHORT_LABELS[cloudProvider] : 'Cloud'; + const badgeColor = cloudProvider ? PROVIDER_BADGE_COLORS[cloudProvider] : 'gray'; // Track queue state let queueState = $state($downloadQueue); diff --git a/src/lib/components/VolumeItem.svelte b/src/lib/components/VolumeItem.svelte index ef40ae2f..54fb2da0 100644 --- a/src/lib/components/VolumeItem.svelte +++ b/src/lib/components/VolumeItem.svelte @@ -45,6 +45,7 @@ import { nav, routeParams } from '$lib/util/hash-router'; import BackupButton from './BackupButton.svelte'; import { unifiedCloudManager } from '$lib/util/sync/unified-cloud-manager'; + import { PROVIDER_SHORT_LABELS } from '$lib/util/sync/provider-display'; import { providerManager } from '$lib/util/sync'; import { backupQueue } from '$lib/util/backup-queue'; import type { CloudVolumeWithProvider } from '$lib/util/sync/unified-cloud-manager'; @@ -473,7 +474,7 @@ const providerType = cloudFile.provider; try { await unifiedCloudManager.deleteManagedVolume(volume.series_title, volume.volume_title); - const providerName = providerType === 'google-drive' ? 'Drive' : providerType; + const providerName = PROVIDER_SHORT_LABELS[providerType]; showSnackbar(`Deleted from ${providerName}`); } catch (error) { console.error('Delete failed:', error); diff --git a/src/lib/util/sync/provider-display.test.ts b/src/lib/util/sync/provider-display.test.ts new file mode 100644 index 00000000..3ccc8cd2 --- /dev/null +++ b/src/lib/util/sync/provider-display.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { PROVIDER_LABELS, PROVIDER_SHORT_LABELS, PROVIDER_BADGE_COLORS } from './provider-display'; + +const ALL = ['google-drive', 'mega', 'webdav', 'filesystem', 'onedrive'] as const; + +describe('provider-display', () => { + it('covers every provider in every map', () => { + for (const p of ALL) { + expect(PROVIDER_LABELS[p]).toBeTruthy(); + expect(PROVIDER_SHORT_LABELS[p]).toBeTruthy(); + expect(PROVIDER_BADGE_COLORS[p]).toBeTruthy(); + } + }); + + it('names the new providers properly (no "Cloud" fallback)', () => { + expect(PROVIDER_SHORT_LABELS.onedrive).toBe('OneDrive'); + expect(PROVIDER_SHORT_LABELS.filesystem).toBe('Local Folder'); + }); +}); diff --git a/src/lib/util/sync/provider-display.ts b/src/lib/util/sync/provider-display.ts new file mode 100644 index 00000000..bcfec948 --- /dev/null +++ b/src/lib/util/sync/provider-display.ts @@ -0,0 +1,29 @@ +import type { ProviderType } from './provider-interface'; + +/** Full names for headers and provider-selection screens. */ +export const PROVIDER_LABELS: Record = { + 'google-drive': 'Google Drive', + mega: 'MEGA Cloud Storage', + webdav: 'WebDAV Server', + filesystem: 'Local Folder', + onedrive: 'OneDrive' +}; + +/** Short names for badges and snackbars. */ +export const PROVIDER_SHORT_LABELS: Record = { + 'google-drive': 'Drive', + mega: 'MEGA', + webdav: 'WebDAV', + filesystem: 'Local Folder', + onedrive: 'OneDrive' +}; + +export type ProviderBadgeColor = 'blue' | 'purple' | 'green' | 'yellow' | 'indigo' | 'gray'; + +export const PROVIDER_BADGE_COLORS: Record = { + 'google-drive': 'blue', + mega: 'purple', + webdav: 'green', + filesystem: 'yellow', + onedrive: 'indigo' +}; diff --git a/src/lib/views/CloudView.svelte b/src/lib/views/CloudView.svelte index 01859c39..8f17b543 100644 --- a/src/lib/views/CloudView.svelte +++ b/src/lib/views/CloudView.svelte @@ -22,6 +22,7 @@ import { unifiedSyncService } from '$lib/util/sync/unified-sync-service'; import { cacheManager } from '$lib/util/sync/cache-manager'; import { isFilesystemProviderSupported } from '$lib/util/sync/providers/filesystem/feature-detect'; + import { PROVIDER_LABELS } from '$lib/util/sync/provider-display'; const CLOUD_ROOT_FOLDER = 'mokuro-reader'; @@ -76,14 +77,19 @@ // Show the connected provider UI only for a usable session. let hasAnyProvider = $derived(currentProvider !== null && !webdavNeedsReLogin); - // Provider display names - const providerNames: Record = { - 'google-drive': 'Google Drive', - mega: 'MEGA Cloud Storage', - webdav: 'WebDAV Server', - filesystem: 'Local Folder', - onedrive: 'OneDrive' - }; + // Sync/Backup/Profile actions are pointless while the session is unusable — + // mirror the webdav read-only gate for the two reconnect states. + let providerActionsUnavailable = $derived( + (currentProvider === 'webdav' && webdavIsReadOnly) || + (currentProvider === 'filesystem' && filesystemNeedsReconnect) || + (currentProvider === 'onedrive' && onedriveNeedsAttention) + ); + + // Without a client id the OneDrive login can only throw — hide the option. + const onedriveConfigured = !!import.meta.env.VITE_ONEDRIVE_CLIENT_ID; + + // Provider display names (shared module keeps all provider maps in sync) + const providerNames: Record = PROVIDER_LABELS; // Provider info const providerInfo = { @@ -915,22 +921,24 @@ {/if} - - + + {/if} @@ -1181,7 +1189,7 @@ Loading cloud data... - {:else if !(currentProvider === 'webdav' && webdavIsReadOnly)} + {:else if !providerActionsUnavailable} + {/if} + @@ -920,25 +941,6 @@ {/if} - - - {#if onedriveConfigured} - - {/if} From d4bbb8ff51329ca9fdc955585f32d376c3b181ff Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 14:11:29 -0700 Subject: [PATCH 61/65] feat(ui): OneDrive copy says persistent login, matching MEGA/WebDAV --- src/lib/views/CloudView.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/views/CloudView.svelte b/src/lib/views/CloudView.svelte index 1ae2b8b2..ce68da20 100644 --- a/src/lib/views/CloudView.svelte +++ b/src/lib/views/CloudView.svelte @@ -127,7 +127,7 @@ items: [ 'Free 5GB personal storage; 1TB+ for Microsoft 365 subscribers', 'Works with personal accounts (outlook.com) and work/school accounts', - 'Silent token refresh — no hourly re-authentication popups', + 'Persistent login (no re-authentication needed)', 'Encrypted in transit and at rest' ] } @@ -860,7 +860,7 @@
OneDrive
- 5GB free • Personal or work/school • Silent refresh + 5GB free • Personal or work/school • Persistent login
From f9238de442f634d038a32b36f63679873e9b87cd Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 14:21:48 -0700 Subject: [PATCH 62/65] feat(gdrive): gesture-retry re-auth + verified browser-aware popup help --- src/lib/util/popup-help.test.ts | 123 +++++++ src/lib/util/popup-help.ts | 321 ++++++++++++++++++ .../providers/google-drive/token-manager.ts | 51 ++- src/lib/util/user-gesture.test.ts | 55 +++ src/lib/util/user-gesture.ts | 38 +++ src/lib/views/CloudView.svelte | 206 +++++------ 6 files changed, 662 insertions(+), 132 deletions(-) create mode 100644 src/lib/util/popup-help.test.ts create mode 100644 src/lib/util/popup-help.ts create mode 100644 src/lib/util/user-gesture.test.ts create mode 100644 src/lib/util/user-gesture.ts diff --git a/src/lib/util/popup-help.test.ts b/src/lib/util/popup-help.test.ts new file mode 100644 index 00000000..7efe18f5 --- /dev/null +++ b/src/lib/util/popup-help.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { classifyBrowser, getPopupHelp, type BrowserPlatform } from './popup-help'; + +const UA = { + chromeWin: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + edgeWin: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0', + operaWin: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 OPR/111.0.0.0', + firefoxLinux: 'Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0', + safariMac: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', + chromeAndroid: + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36', + firefoxAndroid: 'Mozilla/5.0 (Android 14; Mobile; rv:127.0) Gecko/127.0 Firefox/127.0', + chromeIos: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.0.0 Mobile/15E148 Safari/604.1', + firefoxIos: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/127.0 Mobile/15E148 Safari/605.1.15', + safariIphone: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' +}; + +describe('classifyBrowser', () => { + it('detects desktop Chromium-family browsers, most-specific token first', () => { + expect(classifyBrowser(UA.edgeWin, 'Google Inc.', false).browser).toBe('edge'); + expect(classifyBrowser(UA.operaWin, 'Google Inc.', false).browser).toBe('opera'); + expect(classifyBrowser(UA.chromeWin, 'Google Inc.', false).browser).toBe('chrome'); + }); + + it('detects Brave via the isBrave flag despite a plain-Chrome UA', () => { + expect(classifyBrowser(UA.chromeWin, 'Google Inc.', true).browser).toBe('brave'); + }); + + it('detects Firefox and Safari on desktop', () => { + expect(classifyBrowser(UA.firefoxLinux, '', false).browser).toBe('firefox'); + const safari = classifyBrowser(UA.safariMac, 'Apple Computer, Inc.', false); + expect(safari.browser).toBe('safari'); + expect(safari.platform).toBe('desktop'); + }); + + it('detects platforms: android and ios (including iOS browser skins)', () => { + expect(classifyBrowser(UA.chromeAndroid, 'Google Inc.', false).platform).toBe('android'); + expect(classifyBrowser(UA.firefoxAndroid, '', false).platform).toBe('android'); + expect(classifyBrowser(UA.chromeIos, 'Apple Computer, Inc.', false)).toMatchObject({ + browser: 'chrome', + platform: 'ios' + }); + expect(classifyBrowser(UA.firefoxIos, 'Apple Computer, Inc.', false)).toMatchObject({ + browser: 'firefox', + platform: 'ios' + }); + expect(classifyBrowser(UA.safariIphone, 'Apple Computer, Inc.', false)).toMatchObject({ + browser: 'safari', + platform: 'ios' + }); + }); +}); + +describe('getPopupHelp', () => { + const origin = 'https://reader.example.com'; + + function help(browser: BrowserPlatform['browser'], platform: BrowserPlatform['platform']) { + return getPopupHelp({ browser, platform, standalone: false }, origin); + } + + it('returns non-empty numbered steps for every browser/platform combination', () => { + const browsers = ['chrome', 'edge', 'brave', 'opera', 'firefox', 'safari', 'unknown'] as const; + const platforms = ['desktop', 'android', 'ios'] as const; + for (const b of browsers) { + for (const p of platforms) { + const h = help(b, p); + expect(h.steps.length, `${b}/${p}`).toBeGreaterThan(0); + expect(h.name, `${b}/${p}`).toBeTruthy(); + } + } + }); + + it('provides copyable settings deep links only on desktop Chromium browsers', () => { + expect(help('chrome', 'desktop').settingsUrl).toContain('chrome://settings'); + expect(help('edge', 'desktop').settingsUrl).toContain('edge://settings'); + expect(help('brave', 'desktop').settingsUrl).toContain('brave://settings'); + expect(help('firefox', 'desktop').settingsUrl).toBeNull(); + expect(help('safari', 'desktop').settingsUrl).toBeNull(); + expect(help('chrome', 'android').settingsUrl).toBeNull(); + expect(help('safari', 'ios').settingsUrl).toBeNull(); + }); + + it('marks per-site unattended refresh as unavailable where the browser only has a global toggle', () => { + // Desktop browsers + Chrome mobile support per-site allow. + expect(help('chrome', 'desktop').supportsPerSiteAllow).toBe(true); + expect(help('firefox', 'desktop').supportsPerSiteAllow).toBe(true); + expect(help('safari', 'desktop').supportsPerSiteAllow).toBe(true); + expect(help('chrome', 'android').supportsPerSiteAllow).toBe(true); + // Global-toggle-only platforms. + expect(help('safari', 'ios').supportsPerSiteAllow).toBe(false); + expect(help('firefox', 'android').supportsPerSiteAllow).toBe(false); + expect(help('firefox', 'ios').supportsPerSiteAllow).toBe(false); + expect(help('edge', 'android').supportsPerSiteAllow).toBe(false); + }); + + it('embeds the site origin into instructions that reference it', () => { + const h = help('chrome', 'desktop'); + expect(h.settingsUrl).toContain(encodeURIComponent(origin)); + }); + + it('adds the standalone (installed PWA) warning on iOS', () => { + const h = getPopupHelp({ browser: 'safari', platform: 'ios', standalone: true }, origin); + expect(h.note).toMatch(/home screen/i); + }); + + it('recommends a Chromium browser where hands-off refresh is unreliable', () => { + expect(help('safari', 'ios').recommendation).toMatch(/chromium/i); + expect(help('firefox', 'android').recommendation).toMatch(/chromium/i); + expect(help('edge', 'ios').recommendation).toMatch(/chromium/i); + expect(help('chrome', 'ios').recommendation).toMatch(/chromium/i); + // Reliable platforms get no nag. + expect(help('chrome', 'desktop').recommendation).toBeUndefined(); + expect(help('firefox', 'desktop').recommendation).toBeUndefined(); + expect(help('safari', 'desktop').recommendation).toBeUndefined(); + }); +}); diff --git a/src/lib/util/popup-help.ts b/src/lib/util/popup-help.ts new file mode 100644 index 00000000..e23c79dc --- /dev/null +++ b/src/lib/util/popup-help.ts @@ -0,0 +1,321 @@ +/** + * Browser/platform detection + verified per-browser instructions for allowing + * popups, used by the Google Drive auto-re-auth helper. + * + * Instruction sources (verified 2026-07 against vendor docs): + * - Chrome: support.google.com/chrome/answer/95472 (Desktop/Android/iOS) + * - Edge: support.microsoft.com/en-us/microsoft-edge/block-pop-ups-in-microsoft-edge-1d8ba4f8-f385-9a0b-e944-aa47339b6bb5 + * - Firefox: support.mozilla.org/en-US/kb/pop-blocker-settings-exceptions-troubleshooting + * - Safari: support.apple.com/guide/safari/block-pop-ups-sfri40696/mac, + * support.apple.com/guide/iphone/block-pop-ups-ipha49a83ae8/ios + * - Brave: support.brave.app/hc/en-us/articles/360018205431 + * + * Key facts encoded here: + * - iOS Safari / Firefox (all platforms' mobile) / mobile Edge only have a + * GLOBAL popup toggle — no per-site allow. Chrome mobile allows per-site via + * the "Pop-ups blocked → Always show" banner. + * - chrome://, edge://, brave:// settings URLs work when the USER pastes them + * into the address bar; web pages cannot navigate to them. + * - Popup permission is only needed for UNATTENDED refresh. The app also + * retries the OAuth popup inside the next user gesture (see + * user-gesture.ts), which works everywhere with no settings changes. + */ + +export interface BrowserPlatform { + browser: 'chrome' | 'edge' | 'brave' | 'opera' | 'firefox' | 'safari' | 'unknown'; + platform: 'desktop' | 'android' | 'ios'; + standalone: boolean; +} + +export interface PopupHelp { + /** Display name, e.g. "Microsoft Edge" */ + name: string; + /** Numbered steps shown to the user */ + steps: string[]; + /** Copyable settings deep link (user must paste it — pages can't open it) */ + settingsUrl: string | null; + /** Whether this browser/platform can allow popups for JUST this site */ + supportsPerSiteAllow: boolean; + /** Extra caveat worth surfacing */ + note?: string; + /** Shown when hands-off refresh is unreliable here — suggests a better browser */ + recommendation?: string; +} + +/** Pure classification from UA/vendor strings — testable without a browser. */ +export function classifyBrowser( + ua: string, + vendor: string, + isBrave: boolean +): Omit & { standalone: boolean } { + const platform: BrowserPlatform['platform'] = /iPhone|iPad|iPod/.test(ua) + ? 'ios' + : /Android/.test(ua) + ? 'android' + : 'desktop'; + + let browser: BrowserPlatform['browser']; + if (isBrave) { + browser = 'brave'; + } else if (platform === 'ios') { + // Every iOS browser is WebKit; the skin is identified by its own token. + browser = /CriOS\//.test(ua) + ? 'chrome' + : /FxiOS\//.test(ua) + ? 'firefox' + : /EdgiOS\//.test(ua) + ? 'edge' + : 'safari'; + } else if (/Edg\//.test(ua)) { + browser = 'edge'; + } else if (/OPR\//.test(ua)) { + browser = 'opera'; + } else if (/Firefox\//.test(ua)) { + browser = 'firefox'; + } else if (/Chrome\//.test(ua)) { + browser = 'chrome'; + } else if (/Safari\//.test(ua) && /Apple/.test(vendor)) { + browser = 'safari'; + } else { + browser = 'unknown'; + } + + return { browser, platform, standalone: false }; +} + +/** Detect the live environment (async because Brave detection is a promise). */ +export async function detectBrowserPlatform(): Promise { + let isBrave = false; + try { + const nav = navigator as Navigator & { brave?: { isBrave?: () => Promise } }; + isBrave = (await nav.brave?.isBrave?.()) ?? false; + } catch { + isBrave = false; + } + + const result = classifyBrowser(navigator.userAgent, navigator.vendor ?? '', isBrave); + + // iPadOS masquerades as macOS but is still touch-first WebKit with the same + // global-only popup toggle as iPhone. + if ( + result.platform === 'desktop' && + /Apple/.test(navigator.vendor ?? '') && + navigator.maxTouchPoints > 1 + ) { + result.platform = 'ios'; + } + + const standalone = + (typeof matchMedia === 'function' && matchMedia('(display-mode: standalone)').matches) || + (navigator as Navigator & { standalone?: boolean }).standalone === true; + + return { ...result, standalone }; +} + +const GLOBAL_TOGGLE_NOTE = + 'This browser only has an all-sites popup toggle — most people should skip this and rely on the built-in click-to-reconnect instead.'; + +const CHROMIUM_RECOMMENDATION = + 'For reliable hands-off Google Drive syncing, use a Chromium browser instead — Chrome, Edge, or Brave on desktop, or Chrome on Android, all support allowing popups for just this site.'; + +export function getPopupHelp(bp: BrowserPlatform, origin: string): PopupHelp { + const encodedOrigin = encodeURIComponent(origin); + + let help: PopupHelp; + + switch (`${bp.browser}/${bp.platform}`) { + case 'chrome/desktop': + help = { + name: 'Chrome', + steps: [ + 'Click "Test popup permission" below — Chrome will block the test and show a "Pop-up blocked" icon at the right end of the address bar', + 'Click that icon, choose "Always allow pop-ups and redirects from this site", then click Done', + 'Or copy the settings link below, paste it into the address bar, and set "Pop-ups and redirects" to Allow' + ], + settingsUrl: `chrome://settings/content/siteDetails?site=${encodedOrigin}`, + supportsPerSiteAllow: true + }; + break; + case 'chrome/android': + help = { + name: 'Chrome', + steps: [ + 'Tap "Test popup permission" below — Chrome shows "Pop-ups blocked" at the bottom of the screen', + 'Tap "Always show" to allow popups for this site' + ], + settingsUrl: null, + supportsPerSiteAllow: true + }; + break; + case 'chrome/ios': + help = { + name: 'Chrome', + steps: [ + 'Tap "Test popup permission" below — Chrome shows "Pop-ups blocked" at the bottom of the screen', + 'Tap "Always show" to allow popups for this site' + ], + settingsUrl: null, + supportsPerSiteAllow: true, + note: 'Background popups are unreliable on iOS — expect to reconnect with a tap now and then even after allowing.' + }; + break; + case 'edge/desktop': + help = { + name: 'Microsoft Edge', + steps: [ + 'Click "Test popup permission" below — Edge will block the test and show a blocked-popup icon at the right end of the address bar', + 'Click that icon and choose "Always allow pop-ups and redirects from this site"', + 'Or: Settings and more (⋯) → Settings → Privacy, search, and services → Site permissions → All permissions → Pop-ups and redirects → add this site under "Allowed"', + 'Or copy the settings link below and paste it into the address bar' + ], + settingsUrl: `edge://settings/content/siteDetails?site=${encodedOrigin}`, + supportsPerSiteAllow: true + }; + break; + case 'brave/desktop': + help = { + name: 'Brave', + steps: [ + 'Open Settings → Privacy and security → Site and Shields Settings → Pop-ups and redirects', + 'Under "Customized behavior", click Add next to the allowed list and enter this site', + 'Or copy the settings link below and paste it into the address bar' + ], + settingsUrl: 'brave://settings/content/pop-ups', + supportsPerSiteAllow: true, + note: 'If sign-in still fails, click the Brave lion icon in the address bar and relax Shields for this site.' + }; + break; + case 'brave/android': + help = { + name: 'Brave', + steps: [ + 'Menu (⋮) → Settings → Site settings → Pop-ups and redirects', + 'Allow popups, or add this site to the allowed list' + ], + settingsUrl: null, + supportsPerSiteAllow: true, + note: 'The Brave lion icon → Advanced controls also has a per-site popup setting.' + }; + break; + case 'opera/desktop': + help = { + name: 'Opera', + steps: [ + 'Open Settings → Privacy & security → Site settings → Pop-ups and redirects', + 'Click Add next to the Allow list and enter this site' + ], + settingsUrl: null, + supportsPerSiteAllow: true + }; + break; + case 'firefox/desktop': + help = { + name: 'Firefox', + steps: [ + 'Click "Test popup permission" below — Firefox will show a notification bar saying it prevented a pop-up', + 'In that bar, choose "Allow pop-ups for this site" (this is permanent)', + 'Or: Menu (☰) → Settings → Privacy & Security → scroll to Permissions → next to the pop-up blocker setting click "Manage Exceptions…" → add this site → Allow → Save Changes' + ], + settingsUrl: null, + supportsPerSiteAllow: true + }; + break; + case 'firefox/android': + help = { + name: 'Firefox', + steps: [ + 'Menu (⋮) → Settings → Privacy and security', + 'Turn off "Block pop-up windows" (applies to all sites)' + ], + settingsUrl: null, + supportsPerSiteAllow: false, + note: GLOBAL_TOGGLE_NOTE + }; + break; + case 'firefox/ios': + help = { + name: 'Firefox', + steps: [ + 'Open the tab tray, tap the gear (Settings) icon', + 'Turn off "Block Pop-up Windows" (applies to all sites)' + ], + settingsUrl: null, + supportsPerSiteAllow: false, + note: GLOBAL_TOGGLE_NOTE + }; + break; + case 'safari/desktop': + help = { + name: 'Safari', + steps: [ + 'Right-click the address bar and choose "Settings for This Website…", then set "Pop-up Windows" to Allow', + 'Or: Safari menu → Settings… → Websites → Pop-up Windows → find this site → Allow' + ], + settingsUrl: null, + supportsPerSiteAllow: true + }; + break; + case 'safari/ios': + help = { + name: 'Safari', + steps: [ + 'Open the Settings app → Apps → Safari', + 'Turn off "Block Pop-ups" (applies to all sites)' + ], + settingsUrl: null, + supportsPerSiteAllow: false, + note: GLOBAL_TOGGLE_NOTE + }; + break; + case 'edge/android': + case 'edge/ios': + help = { + name: 'Microsoft Edge', + steps: [ + 'Menu (⋯) → Settings → Privacy and security', + 'Turn off the popup blocker (applies to all sites)' + ], + settingsUrl: null, + supportsPerSiteAllow: false, + note: GLOBAL_TOGGLE_NOTE + }; + break; + default: + help = + bp.platform === 'desktop' + ? { + name: 'your browser', + steps: [ + 'Click "Test popup permission" below — look for a popup-blocked icon in the address bar', + 'Click it and choose "Always allow pop-ups from this site"' + ], + settingsUrl: null, + supportsPerSiteAllow: true + } + : { + name: 'your browser', + steps: [ + 'Look for a "popup blocked" notice after using the test button below', + 'Allow popups for this site if offered, or find the popup blocker in your browser settings' + ], + settingsUrl: null, + supportsPerSiteAllow: false + }; + } + + if (bp.standalone && bp.platform === 'ios') { + help = { + ...help, + note: 'This app is installed to the Home Screen — iOS often blocks sign-in popups entirely in this mode. If reconnecting fails, open the site in Safari itself.' + }; + } + + // Hands-off refresh is unreliable wherever per-site allow doesn't exist, + // and on all of iOS (WebKit popup behavior) — steer users to a Chromium + // browser for the smooth experience. + if (!help.supportsPerSiteAllow || bp.platform === 'ios') { + help = { ...help, recommendation: CHROMIUM_RECOMMENDATION }; + } + + return help; +} diff --git a/src/lib/util/sync/providers/google-drive/token-manager.ts b/src/lib/util/sync/providers/google-drive/token-manager.ts index bc830ab8..ebef3a79 100644 --- a/src/lib/util/sync/providers/google-drive/token-manager.ts +++ b/src/lib/util/sync/providers/google-drive/token-manager.ts @@ -2,6 +2,7 @@ import { writable } from 'svelte/store'; import { browser } from '$app/environment'; import { GOOGLE_DRIVE_CONFIG } from './constants'; import { showSnackbar } from '$lib/util/snackbar'; +import { onNextUserGesture } from '$lib/util/user-gesture'; class TokenManager { private tokenStore = writable(''); @@ -9,6 +10,7 @@ class TokenManager { private needsAttentionStore = writable(false); private refreshIntervalId: number | null = null; private isRefreshing = false; + private gestureRetryCancel: (() => void) | null = null; constructor() { if (browser) { @@ -89,10 +91,39 @@ class TokenManager { }, GOOGLE_DRIVE_CONFIG.TOKEN_REFRESH_CHECK_INTERVAL_MS); } + /** + * Popup blockers gate on transient user activation, not a stored permission, + * so a popup blocked in the background will open fine when requested from + * inside a real click/tap. Arm a one-shot retry on the next gesture — this + * makes auto re-auth work WITHOUT the user touching browser settings. + */ + private armGestureRetry(): void { + if (this.gestureRetryCancel) return; // already armed + showSnackbar('Google Drive session expired — click or tap anywhere to reconnect.'); + this.gestureRetryCancel = onNextUserGesture(() => { + this.gestureRetryCancel = null; + try { + // Must stay synchronous: an await here would forfeit the activation + // window that lets the OAuth popup open. + this.requestNewToken(false); + } catch (error) { + console.warn('Gesture-retry re-auth failed:', error); + } + }); + } + + private disarmGestureRetry(): void { + if (this.gestureRetryCancel) { + this.gestureRetryCancel(); + this.gestureRetryCancel = null; + } + } + setToken(token: string, expiresIn?: number): void { this.tokenStore.set(token); this.isRefreshing = false; this.needsAttentionStore.set(false); // Clear attention flag when token is set + this.disarmGestureRetry(); // A valid token makes any pending retry moot if (browser) { localStorage.setItem(GOOGLE_DRIVE_CONFIG.STORAGE_KEYS.TOKEN, token); @@ -204,9 +235,10 @@ class TokenManager { response.error === 'popup_failed_to_open' || response.error === 'popup_blocked' ) { - // Popup was blocked by browser - console.log('Popup was blocked by browser'); - showSnackbar('Popup blocked. Please allow popups for this site and try again.'); + // Popup was blocked (no user activation). Retry inside the next + // real click/tap, where blockers always allow it. + console.log('Popup was blocked by browser — arming gesture retry'); + this.armGestureRetry(); } else { // Other errors (network issues, etc.) - keep auth history but clear token // Next sign-in will use minimal prompt since permissions weren't explicitly denied @@ -229,6 +261,18 @@ class TokenManager { providerManager.updateStatus(); }); } + }, + // GIS reports NON-OAuth failures here, not in `callback` — in particular + // popup_failed_to_open when the blocker eats a background-triggered + // popup. Without this handler, blocked auto re-auth failed silently. + error_callback: (error: { type?: string; message?: string }) => { + console.warn('Token client non-OAuth error:', error?.type, error?.message); + if (error?.type === 'popup_failed_to_open') { + this.armGestureRetry(); + } else if (error?.type === 'popup_closed') { + showSnackbar('Sign-in cancelled. Please try again when ready.'); + } + this.isRefreshing = false; } }); @@ -271,6 +315,7 @@ class TokenManager { } async logout(): Promise { + this.disarmGestureRetry(); // Clear the refresh interval if (this.refreshIntervalId) { clearInterval(this.refreshIntervalId); diff --git a/src/lib/util/user-gesture.test.ts b/src/lib/util/user-gesture.test.ts new file mode 100644 index 00000000..f2274c56 --- /dev/null +++ b/src/lib/util/user-gesture.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest'; +import { onNextUserGesture } from './user-gesture'; + +describe('onNextUserGesture', () => { + it('runs the callback synchronously inside the next pointerdown', () => { + const fn = vi.fn(); + onNextUserGesture(fn); + + expect(fn).not.toHaveBeenCalled(); + window.dispatchEvent(new Event('pointerdown')); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('runs on keydown too, but only once total', () => { + const fn = vi.fn(); + onNextUserGesture(fn); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + window.dispatchEvent(new Event('pointerdown')); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b' })); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('ignores Escape keydown (does not count as an activation gesture)', () => { + const fn = vi.fn(); + onNextUserGesture(fn); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(fn).not.toHaveBeenCalled(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('cancel() disarms without firing', () => { + const fn = vi.fn(); + const cancel = onNextUserGesture(fn); + + cancel(); + window.dispatchEvent(new Event('pointerdown')); + expect(fn).not.toHaveBeenCalled(); + }); + + it('re-arming after fire works (new registration)', () => { + const fn = vi.fn(); + onNextUserGesture(fn); + window.dispatchEvent(new Event('pointerdown')); + + onNextUserGesture(fn); + window.dispatchEvent(new Event('pointerdown')); + + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/util/user-gesture.ts b/src/lib/util/user-gesture.ts new file mode 100644 index 00000000..aacd565d --- /dev/null +++ b/src/lib/util/user-gesture.ts @@ -0,0 +1,38 @@ +/** + * Run a callback synchronously inside the NEXT real user gesture. + * + * Popup blockers gate window.open on transient user activation, which is only + * true during (and ~1s after) a genuine pointerdown/keydown. Anything that + * must open a popup without a stored popup permission (e.g. a Google OAuth + * re-auth after token expiry) can be deferred here: the callback runs inside + * the gesture handler itself, so the popup inherits the activation. + * + * The callback MUST do its popup-opening work synchronously — an `await` + * before window.open forfeits the activation window. + * + * Returns a cancel function that disarms the listener without firing. + */ +export function onNextUserGesture(callback: () => void): () => void { + let armed = true; + + const handler = (event: Event) => { + // Escape does not grant user activation (HTML spec) — don't burn the + // one-shot on it. + if (event instanceof KeyboardEvent && event.key === 'Escape') return; + if (!armed) return; + cancel(); + callback(); + }; + + const cancel = () => { + if (!armed) return; + armed = false; + window.removeEventListener('pointerdown', handler, true); + window.removeEventListener('keydown', handler, true); + }; + + window.addEventListener('pointerdown', handler, true); + window.addEventListener('keydown', handler, true); + + return cancel; +} diff --git a/src/lib/views/CloudView.svelte b/src/lib/views/CloudView.svelte index ce68da20..ccce70f2 100644 --- a/src/lib/views/CloudView.svelte +++ b/src/lib/views/CloudView.svelte @@ -23,6 +23,7 @@ import { cacheManager } from '$lib/util/sync/cache-manager'; import { isFilesystemProviderSupported } from '$lib/util/sync/providers/filesystem/feature-detect'; import { PROVIDER_LABELS } from '$lib/util/sync/provider-display'; + import { detectBrowserPlatform, getPopupHelp, type PopupHelp } from '$lib/util/popup-help'; const CLOUD_ROOT_FOLDER = 'mokuro-reader'; @@ -326,6 +327,23 @@ } } + function testPopupPermission() { + showSnackbar('Testing in 5 seconds — hands off the mouse/keyboard so no click can help…'); + // Wait out the transient-activation window so this tests a TRUE + // background popup, which is what unattended re-auth needs. + setTimeout(() => { + triggerGoogleReauth() + .then(() => { + showSnackbar('If the Google window appeared, hands-off refresh is configured.'); + }) + .catch(() => { + showSnackbar( + 'Popup was blocked. Follow the steps above — or just rely on click-to-reconnect.' + ); + }); + }, 5000); + } + async function triggerGoogleReauth() { const provider = providerManager.getProviderInstance('google-drive'); if (!provider) { @@ -354,6 +372,7 @@ onMount(async () => { filesystemSupported = isFilesystemProviderSupported(); + popupHelp = getPopupHelp(await detectBrowserPlatform(), window.location.origin); // Clear service worker cache for Google Drive downloads // This is cloud-page-specific and not part of global init clearServiceWorkerCache(); @@ -638,72 +657,7 @@ } // Browser detection and settings URL generation - function getBrowserInfo() { - const ua = navigator.userAgent; - const isChrome = /Chrome/.test(ua) && /Google Inc/.test(navigator.vendor); - const isEdge = /Edg/.test(ua); - const isFirefox = /Firefox/.test(ua); - const isSafari = /Safari/.test(ua) && !/Chrome/.test(ua); - - // Get current site URL for settings - const siteUrl = encodeURIComponent(window.location.origin); - - if (isEdge) { - return { - name: 'Edge', - settingsUrl: `edge://settings/content/siteDetails?site=${siteUrl}`, - instructions: [ - 'Click the link below to copy the Edge settings URL', - 'Paste it into your address bar and press Enter', - 'Toggle "Pop-ups and redirects" to "Allow"', - 'Return here and click the test button to verify' - ] - }; - } else if (isChrome) { - return { - name: 'Chrome', - settingsUrl: `chrome://settings/content/siteDetails?site=${siteUrl}`, - instructions: [ - 'Click the link below to copy the Chrome settings URL', - 'Paste it into your address bar and press Enter', - 'Toggle "Pop-ups and redirects" to "Allow"', - 'Return here and click the test button to verify' - ] - }; - } else if (isFirefox) { - return { - name: 'Firefox', - settingsUrl: 'about:preferences#privacy', - instructions: [ - 'Click the permissions icon (🔒) in the address bar', - 'Find "Open pop-up windows" and change to "Allow"', - 'Or: Settings → Privacy & Security → Permissions → Pop-ups → Exceptions' - ] - }; - } else if (isSafari) { - return { - name: 'Safari', - settingsUrl: null, - instructions: [ - 'Safari → Settings → Websites → Pop-up Windows', - 'Find this website in the list', - 'Change setting to "Allow"' - ] - }; - } else { - return { - name: 'Unknown', - settingsUrl: null, - instructions: [ - 'Click the popup blocked icon in your address bar', - 'Select "Always allow popups from this site"', - 'Click the test button below to verify it works' - ] - }; - } - } - - let browserInfo = $derived(getBrowserInfo()); + let popupHelp = $state(null); async function backupAllSeries() { // Get default provider @@ -1114,73 +1068,67 @@

{#if $miscSettings.gdriveAutoReAuth} -
-

- ⚠️ Popup Permission Required ({browserInfo.name}) +
+

+ Click-to-reconnect is built in

-

- For auto re-authentication to work, you must allow popups for this site. - Otherwise, the browser will block automatic re-authentication attempts. +

+ When your Google session expires, your next click or tap re-opens the sign-in + popup automatically — no browser configuration needed.

-
-

To enable popups:

-
    - {#each browserInfo.instructions as instruction} -
  1. {instruction}
  2. - {/each} -
- {#if browserInfo.settingsUrl} -
-

- { - navigator.clipboard.writeText(browserInfo.settingsUrl); - showSnackbar(`Copied! Paste this into your address bar`); - }} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - navigator.clipboard.writeText(browserInfo.settingsUrl); - showSnackbar(`Copied! Paste this into your address bar`); - } - }} - > - {browserInfo.settingsUrl} - -

-
+
+ {#if popupHelp} +
+

+ Optional: hands-off refresh ({popupHelp.name}) +

+

+ {popupHelp.supportsPerSiteAllow + ? 'To refresh with no click at all (e.g. syncing while the app sits open unattended), allow popups for this site:' + : 'This browser can only allow popups for ALL sites, not just this one:'} +

+ {#if popupHelp.note} +

{popupHelp.note}

+ {/if} +
+
    + {#each popupHelp.steps as instruction (instruction)} +
  1. {instruction}
  2. + {/each} +
+ {#if popupHelp.settingsUrl} +
+

+ { + navigator.clipboard.writeText(popupHelp?.settingsUrl ?? ''); + showSnackbar('Copied! Paste this into your address bar'); + }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigator.clipboard.writeText(popupHelp?.settingsUrl ?? ''); + showSnackbar('Copied! Paste this into your address bar'); + } + }} + > + {popupHelp.settingsUrl} + +

+
+ {/if} +
+ {#if popupHelp.recommendation} +

💡 {popupHelp.recommendation}

{/if} +
- -
+ {/if} {/if}

{/if} From 8171d35728f8ad607789873f3620d7d1af4201c5 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 14:50:37 -0700 Subject: [PATCH 63/65] feat(gdrive): reconnect at moments that matter; post-reauth sync guards progress --- src/lib/components/NavBar.svelte | 33 +- src/lib/util/popup-help.test.ts | 123 ------- src/lib/util/popup-help.ts | 321 ------------------ .../providers/google-drive/token-manager.ts | 24 +- src/lib/views/CloudView.svelte | 97 +----- src/lib/views/ReaderView.svelte | 25 ++ 6 files changed, 87 insertions(+), 536 deletions(-) delete mode 100644 src/lib/util/popup-help.test.ts delete mode 100644 src/lib/util/popup-help.ts diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index 303ad71f..03a26df0 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -187,20 +187,33 @@ {/if} {#if isGoogleDrive && providerState.isAuthenticated && tokenMinutesLeft !== null} - {#key tokenMinutesLeft} + {#if tokenMinutesLeft <= 0 || providerState.needsAttention} + - {/key} + {:else} + {#key tokenMinutesLeft} + + {/key} + {/if} {/if} {#if hasActiveProvider && !providerState.isReadOnly} - - {/if} {/if} {/if} diff --git a/src/lib/views/ReaderView.svelte b/src/lib/views/ReaderView.svelte index 26cb1250..3015468a 100644 --- a/src/lib/views/ReaderView.svelte +++ b/src/lib/views/ReaderView.svelte @@ -2,13 +2,38 @@ import Reader from '$lib/components/Reader/Reader.svelte'; import Timer from '$lib/components/Reader/Timer.svelte'; import { effectiveVolumeSettings, initializeVolume, settings, volumes } from '$lib/settings'; + import { miscSettings } from '$lib/settings/misc'; import { onMount } from 'svelte'; import { activityTracker } from '$lib/util/activity-tracker'; import { Spinner } from 'flowbite-svelte'; import { routeParams } from '$lib/util/hash-router'; + import { unifiedCloudManager } from '$lib/util/sync/unified-cloud-manager'; + import { tokenManager } from '$lib/util/sync/providers/google-drive/token-manager'; let volumeId = $derived($routeParams.volume || ''); + // Opening a book with an expired Google session: request reconnection NOW, + // before page turns stamp fresh local timestamps that would win the + // newest-wins merge and clobber progress made on another device. The + // navigation click's activation usually lets the account chooser open + // immediately; if not, the blocked-popup path arms a next-click retry. + // Closing Google's dialog IS the "read anyway" choice — no extra dialog. + let gdriveReauthRequested = false; + $effect(() => { + if (!volumeId) return; + if (!$miscSettings.gdriveAutoReAuth) return; + const active = unifiedCloudManager.getActiveProvider(); + if (active?.type !== 'google-drive') return; + const msLeft = tokenManager.getTimeUntilExpiry(); + if (msLeft === null || msLeft > 0) { + gdriveReauthRequested = false; + return; + } + if (gdriveReauthRequested) return; + gdriveReauthRequested = true; + tokenManager.reAuthenticate(); + }); + // Cache volume settings to prevent flash when unrelated volumes are added. // The effectiveVolumeSettings store emits a new object whenever ANY volume changes, // which would cause this component to re-render. By caching the specific volume's From 65bc5e9b9536e49c8a6c0b24ae2cdbd4041ba209 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 15:00:10 -0700 Subject: [PATCH 64/65] =?UTF-8?q?revert(sync):=20keep=20libraries.json=20o?= =?UTF-8?q?ut=20of=20the=20sync=20filter=20=E2=80=94=20feature=20unshipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filesystem/__tests__/filesystem-paths.test.ts | 2 +- src/lib/util/sync/syncable-file.test.ts | 12 ++++++++---- src/lib/util/sync/syncable-file.ts | 9 +++++++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts index 926cd1e3..c947febf 100644 --- a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts +++ b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts @@ -31,7 +31,7 @@ describe('isSyncableFile', () => { ['Series/Volume.webp', true], ['volume-data.json', true], ['profiles.json', true], - ['libraries.json', true], + ['libraries.json', false], // libraries feature not shipped — see syncable-file.ts ['Series/cover.jpg', true], ['.DS_Store', false], ['Series/Notes.txt', false], diff --git a/src/lib/util/sync/syncable-file.test.ts b/src/lib/util/sync/syncable-file.test.ts index 224bd44d..74ca012b 100644 --- a/src/lib/util/sync/syncable-file.test.ts +++ b/src/lib/util/sync/syncable-file.test.ts @@ -14,10 +14,14 @@ describe('syncable-file', () => { expect(isSyncableFile('Series/Vol 1.JPEG')).toBe(true); }); - it('accepts the three root config files, including libraries.json', () => { + it('accepts the root config files', () => { expect(isSyncableFile('volume-data.json')).toBe(true); expect(isSyncableFile('profiles.json')).toBe(true); - expect(isSyncableFile('libraries.json')).toBe(true); + }); + + it('excludes libraries.json until the libraries feature ships', () => { + expect(isSyncableFile('libraries.json')).toBe(false); + expect(isRootConfigFile('libraries.json')).toBe(false); }); it('rejects everything else', () => { @@ -28,7 +32,7 @@ describe('syncable-file', () => { it('is case-insensitive and uses the basename only', () => { expect(isSyncableFile('Series/VOL.CBZ')).toBe(true); - expect(isSyncableFile('a/b/c/LIBRARIES.JSON')).toBe(true); + expect(isSyncableFile('a/b/c/PROFILES.JSON')).toBe(true); }); it('exposes category predicates for providers that bucket by type', () => { @@ -36,7 +40,7 @@ describe('syncable-file', () => { expect(isSidecarFile('v.mokuro')).toBe(true); expect(isSidecarFile('v.jpeg')).toBe(true); expect(isSidecarFile('v.cbz')).toBe(false); - expect(isRootConfigFile('libraries.json')).toBe(true); + expect(isRootConfigFile('profiles.json')).toBe(true); expect(isRootConfigFile('v.cbz')).toBe(false); }); }); diff --git a/src/lib/util/sync/syncable-file.ts b/src/lib/util/sync/syncable-file.ts index 94de9a5d..a03af7cd 100644 --- a/src/lib/util/sync/syncable-file.ts +++ b/src/lib/util/sync/syncable-file.ts @@ -6,10 +6,15 @@ * - CBZ archives (the volumes themselves) * - Sidecars: OCR data (.mokuro / .mokuro.gz) and thumbnails (.webp/.jpg/.jpeg) * - Root config files: volume-data.json (read progress), profiles.json - * (settings profiles), libraries.json (library definitions) + * (settings profiles) + * + * libraries.json is deliberately NOT listed: the libraries feature has no + * specced/shipped UI yet, and excluding it here keeps its cloud download path + * (unified-sync-service findLibrariesFile → cache lookup) inert. Add it back + * when the feature ships. */ -const ROOT_CONFIG_FILENAMES = new Set(['volume-data.json', 'profiles.json', 'libraries.json']); +const ROOT_CONFIG_FILENAMES = new Set(['volume-data.json', 'profiles.json']); const SIDECAR_IMAGE_RE = /\.(webp|jpe?g)$/i; function basenameOf(path: string): string { From ca669d49bf76c4bb9fc98a9c4f8eb2709025a927 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Sun, 5 Jul 2026 19:54:00 -0700 Subject: [PATCH 65/65] refactor: remove the unshipped libraries feature --- .../cloud-ocr-upgrade.ts} | 151 ++------ src/lib/catalog/index.ts | 33 +- src/lib/catalog/placeholders.ts | 2 +- src/lib/components/AddLibraryModal.svelte | 353 ------------------ src/lib/components/AppViewRouter.svelte | 4 +- src/lib/components/LibrarySelector.svelte | 114 ------ src/lib/settings/libraries.ts | 220 ----------- src/lib/types/index.ts | 4 - src/lib/util/download-queue.ts | 53 +-- src/lib/util/hash-router.test.ts | 24 +- src/lib/util/hash-router.ts | 28 +- src/lib/util/libraries/index.ts | 36 -- .../util/libraries/library-cache-manager.ts | 294 --------------- .../util/libraries/library-placeholders.ts | 228 ----------- .../util/libraries/library-webdav-client.ts | 336 ----------------- src/lib/util/modals.ts | 43 --- .../__tests__/filesystem-paths.test.ts | 2 +- src/lib/util/sync/syncable-file.test.ts | 2 +- src/lib/util/sync/syncable-file.ts | 7 +- src/lib/util/sync/unified-sync-service.ts | 147 +------- src/lib/views/AddLibraryView.svelte | 54 --- src/lib/views/LibraryManagerView.svelte | 245 ------------ src/routes/+page.svelte | 4 +- src/routes/[...catchall]/+page.svelte | 4 +- 24 files changed, 71 insertions(+), 2317 deletions(-) rename src/lib/{util/libraries/library-ocr-upgrade-queue.ts => catalog/cloud-ocr-upgrade.ts} (53%) delete mode 100644 src/lib/components/AddLibraryModal.svelte delete mode 100644 src/lib/components/LibrarySelector.svelte delete mode 100644 src/lib/settings/libraries.ts delete mode 100644 src/lib/util/libraries/index.ts delete mode 100644 src/lib/util/libraries/library-cache-manager.ts delete mode 100644 src/lib/util/libraries/library-placeholders.ts delete mode 100644 src/lib/util/libraries/library-webdav-client.ts delete mode 100644 src/lib/views/AddLibraryView.svelte delete mode 100644 src/lib/views/LibraryManagerView.svelte diff --git a/src/lib/util/libraries/library-ocr-upgrade-queue.ts b/src/lib/catalog/cloud-ocr-upgrade.ts similarity index 53% rename from src/lib/util/libraries/library-ocr-upgrade-queue.ts rename to src/lib/catalog/cloud-ocr-upgrade.ts index 98344328..91422aba 100644 --- a/src/lib/util/libraries/library-ocr-upgrade-queue.ts +++ b/src/lib/catalog/cloud-ocr-upgrade.ts @@ -1,33 +1,26 @@ import { db } from '$lib/catalog/db'; import { parseMokuroFile } from '$lib/import/processing'; import type { VolumeMetadata } from '$lib/types'; -import type { LibraryFileMetadata } from './library-webdav-client'; -import { getLibraryById } from '$lib/settings/libraries'; -import { getLibraryClient } from './library-cache-manager'; import { unifiedCloudManager, type CloudVolumeWithProvider } from '$lib/util/sync/unified-cloud-manager'; import type { ProviderType } from '$lib/util/sync/provider-interface'; -type LibraryUpgradeTask = { - kind: 'library'; - volumeUuid: string; - libraryId: string; - sidecar: LibraryFileMetadata; -}; +/** + * Background queue that upgrades image-only local volumes with OCR data from + * a cloud provider's .mokuro/.mokuro.gz sidecar. (Extracted from the removed + * libraries feature — this cloud half is used by cloud placeholders.) + */ type CloudUpgradeTask = { - kind: 'cloud'; volumeUuid: string; provider: ProviderType; sidecar: CloudVolumeWithProvider; }; -type UpgradeTask = LibraryUpgradeTask | CloudUpgradeTask; - const pendingTaskIds = new Set(); -const queuedTasks: UpgradeTask[] = []; +const queuedTasks: CloudUpgradeTask[] = []; let processing = false; function countCharsInLines(lines: unknown): number { @@ -63,7 +56,7 @@ function buildPageCharCounts(pages: unknown[]): { totalChars: number; cumulative async function decodeMokuroSidecar(sidecarPath: string, blob: Blob): Promise { if (sidecarPath.toLowerCase().endsWith('.mokuro')) { - console.log('[Library OCR Upgrade] Decoding plain mokuro sidecar:', sidecarPath, blob.size); + console.log('[Cloud OCR Upgrade] Decoding plain mokuro sidecar:', sidecarPath, blob.size); return new File([blob], sidecarPath.split('/').pop() || sidecarPath, { type: 'application/json' }); @@ -74,64 +67,49 @@ async function decodeMokuroSidecar(sidecarPath: string, blob: Blob): Promise { - const taskScope = - task.kind === 'library' ? `library:${task.libraryId}` : `cloud:${task.provider}`; +async function applyUpgrade(task: CloudUpgradeTask): Promise { console.log( - '[Library OCR Upgrade] Starting task:', + '[Cloud OCR Upgrade] Starting task:', task.volumeUuid, 'sidecar=', task.sidecar.path, - 'scope=', - taskScope + 'provider=', + task.provider ); - let sidecarBlob: Blob; - let sidecarPath: string; - if (task.kind === 'library') { - const library = getLibraryById(task.libraryId); - if (!library) { - console.warn('[Library OCR Upgrade] Library config not found:', task.libraryId); - return; - } - const client = getLibraryClient(library); - sidecarBlob = await client.downloadFile(task.sidecar.fileId); - sidecarPath = task.sidecar.path; - } else { - const activeProvider = unifiedCloudManager.getActiveProvider(); - if (!activeProvider || activeProvider.type !== task.provider) { - console.warn( - '[Library OCR Upgrade] Active provider unavailable for cloud sidecar upgrade:', - task.provider, - 'active=', - activeProvider?.type - ); - return; - } - sidecarBlob = await activeProvider.downloadFile(task.sidecar); - sidecarPath = task.sidecar.path; + const activeProvider = unifiedCloudManager.getActiveProvider(); + if (!activeProvider || activeProvider.type !== task.provider) { + console.warn( + '[Cloud OCR Upgrade] Active provider unavailable for cloud sidecar upgrade:', + task.provider, + 'active=', + activeProvider?.type + ); + return; } + const sidecarBlob = await activeProvider.downloadFile(task.sidecar); + const sidecarPath = task.sidecar.path; - console.log('[Library OCR Upgrade] Downloaded sidecar bytes:', sidecarBlob.size, sidecarPath); + console.log('[Cloud OCR Upgrade] Downloaded sidecar bytes:', sidecarBlob.size, sidecarPath); const mokuroFile = await decodeMokuroSidecar(sidecarPath, sidecarBlob); if (!mokuroFile) { - console.warn('[Library OCR Upgrade] Failed to decode sidecar:', sidecarPath); + console.warn('[Cloud OCR Upgrade] Failed to decode sidecar:', sidecarPath); return; } const parsed = await parseMokuroFile(mokuroFile); console.log( - '[Library OCR Upgrade] Parsed mokuro:', + '[Cloud OCR Upgrade] Parsed mokuro:', parsed.series, parsed.volume, 'pages=', @@ -142,7 +120,7 @@ async function applyUpgrade(task: UpgradeTask): Promise { typeof existingVolume?.mokuro_version === 'string' ? existingVolume.mokuro_version.trim() : ''; if (!existingVolume || existingMokuroVersion !== '') { console.log( - '[Library OCR Upgrade] Skipping task, volume missing or already OCR:', + '[Cloud OCR Upgrade] Skipping task, volume missing or already OCR:', task.volumeUuid, 'existingVersion=', existingMokuroVersion @@ -169,7 +147,7 @@ async function applyUpgrade(task: UpgradeTask): Promise { }); console.log( - '[Library OCR Upgrade] Upgraded image-only volume:', + '[Cloud OCR Upgrade] Upgraded image-only volume:', existingVolume.series_title, existingVolume.volume_title ); @@ -178,74 +156,25 @@ async function applyUpgrade(task: UpgradeTask): Promise { async function processQueue(): Promise { if (processing) return; processing = true; - console.log('[Library OCR Upgrade] Processing queue. pending=', queuedTasks.length); + console.log('[Cloud OCR Upgrade] Processing queue. pending=', queuedTasks.length); try { while (queuedTasks.length > 0) { const task = queuedTasks.shift()!; - const sidecarId = task.kind === 'library' ? task.sidecar.fileId : task.sidecar.fileId; - const taskId = `${task.volumeUuid}:${sidecarId}`; + const taskId = `${task.volumeUuid}:${task.sidecar.fileId}`; try { await applyUpgrade(task); } catch (error) { - console.warn('[Library OCR Upgrade] Failed to auto-upgrade volume:', error); + console.warn('[Cloud OCR Upgrade] Failed to auto-upgrade volume:', error); } finally { pendingTaskIds.delete(taskId); - console.log( - '[Library OCR Upgrade] Task complete:', - taskId, - 'remaining=', - queuedTasks.length - ); + console.log('[Cloud OCR Upgrade] Task complete:', taskId, 'remaining=', queuedTasks.length); } } } finally { processing = false; - console.log('[Library OCR Upgrade] Queue idle'); - } -} - -export function enqueueLibraryOcrUpgrade( - volume: VolumeMetadata, - sidecar: LibraryFileMetadata -): void { - if (volume.isPlaceholder) { - console.log('[Library OCR Upgrade] Skip enqueue for placeholder volume:', volume.volume_uuid); - return; - } - const currentMokuroVersion = - typeof volume.mokuro_version === 'string' ? volume.mokuro_version.trim() : ''; - if (currentMokuroVersion !== '') { - console.log( - '[Library OCR Upgrade] Skip enqueue, volume already has OCR:', - volume.volume_uuid, - currentMokuroVersion - ); - return; - } - - const taskId = `${volume.volume_uuid}:${sidecar.fileId}`; - if (pendingTaskIds.has(taskId)) { - console.log('[Library OCR Upgrade] Skip enqueue duplicate task:', taskId); - return; + console.log('[Cloud OCR Upgrade] Queue idle'); } - pendingTaskIds.add(taskId); - - queuedTasks.push({ - kind: 'library', - volumeUuid: volume.volume_uuid, - libraryId: sidecar.libraryId, - sidecar - }); - console.log( - '[Library OCR Upgrade] Enqueued task:', - taskId, - `${volume.series_title}/${volume.volume_title}`, - 'queueLength=', - queuedTasks.length - ); - - void processQueue(); } export function enqueueCloudOcrUpgrade( @@ -253,17 +182,14 @@ export function enqueueCloudOcrUpgrade( sidecar: CloudVolumeWithProvider ): void { if (volume.isPlaceholder) { - console.log( - '[Library OCR Upgrade] Skip cloud enqueue for placeholder volume:', - volume.volume_uuid - ); + console.log('[Cloud OCR Upgrade] Skip enqueue for placeholder volume:', volume.volume_uuid); return; } const currentMokuroVersion = typeof volume.mokuro_version === 'string' ? volume.mokuro_version.trim() : ''; if (currentMokuroVersion !== '') { console.log( - '[Library OCR Upgrade] Skip cloud enqueue, volume already has OCR:', + '[Cloud OCR Upgrade] Skip enqueue, volume already has OCR:', volume.volume_uuid, currentMokuroVersion ); @@ -272,19 +198,18 @@ export function enqueueCloudOcrUpgrade( const taskId = `${volume.volume_uuid}:${sidecar.fileId}`; if (pendingTaskIds.has(taskId)) { - console.log('[Library OCR Upgrade] Skip cloud enqueue duplicate task:', taskId); + console.log('[Cloud OCR Upgrade] Skip enqueue duplicate task:', taskId); return; } pendingTaskIds.add(taskId); queuedTasks.push({ - kind: 'cloud', volumeUuid: volume.volume_uuid, provider: sidecar.provider, sidecar }); console.log( - '[Library OCR Upgrade] Enqueued cloud task:', + '[Cloud OCR Upgrade] Enqueued task:', taskId, `${volume.series_title}/${volume.volume_title}`, 'queueLength=', diff --git a/src/lib/catalog/index.ts b/src/lib/catalog/index.ts index 961c584c..0cd05b76 100644 --- a/src/lib/catalog/index.ts +++ b/src/lib/catalog/index.ts @@ -7,12 +7,6 @@ import { unifiedCloudManager } from '$lib/util/sync/unified-cloud-manager'; import { generatePlaceholders } from '$lib/catalog/placeholders'; import { routeParams } from '$lib/util/hash-router'; import { getLegacyImageOnlyVolumeUuid } from '$lib/util/download-volume-repair'; -import { - libraryFilesStore, - libraryMokuroFilesStore, - generateLibraryPlaceholders -} from '$lib/util/libraries'; -import { selectedLibraryId } from '$lib/settings/libraries'; async function loadCurrentVolumeData(volume: VolumeMetadata): Promise { let [ocr, files] = await Promise.all([ @@ -80,16 +74,10 @@ export const volumes = readable>({}, (set) => { return () => subscription.unsubscribe(); }); -// Merge local volumes with cloud placeholders and library placeholders +// Merge local volumes with cloud placeholders export const volumesWithPlaceholders = derived( - [ - volumes, - unifiedCloudManager.cloudFiles, - libraryFilesStore, - libraryMokuroFilesStore, - selectedLibraryId - ], - ([$volumes, $cloudFiles, $libraryFiles, $libraryMokuroFiles, $selectedLibraryId]) => { + [volumes, unifiedCloudManager.cloudFiles], + ([$volumes, $cloudFiles]) => { const combined = { ...$volumes }; const localVolumes = Object.values($volumes); @@ -101,21 +89,6 @@ export const volumesWithPlaceholders = derived( } } - // Generate library placeholders - if ($libraryFiles.size > 0) { - // Pass all combined volumes so library placeholders don't duplicate cloud placeholders - const allVolumes = Object.values(combined); - const libraryPlaceholders = generateLibraryPlaceholders( - $libraryFiles, - $libraryMokuroFiles, - allVolumes, - $selectedLibraryId - ); - for (const placeholder of libraryPlaceholders) { - combined[placeholder.volume_uuid] = placeholder; - } - } - return combined; }, {} as Record diff --git a/src/lib/catalog/placeholders.ts b/src/lib/catalog/placeholders.ts index 118a30a6..3b8d5e8a 100644 --- a/src/lib/catalog/placeholders.ts +++ b/src/lib/catalog/placeholders.ts @@ -2,7 +2,7 @@ import type { VolumeMetadata } from '$lib/types'; import type { CloudVolumeWithProvider } from '$lib/util/sync/unified-cloud-manager'; import { browser } from '$app/environment'; import { generateDeterministicUUID } from '$lib/util/series-extraction'; -import { enqueueCloudOcrUpgrade } from '$lib/util/libraries/library-ocr-upgrade-queue'; +import { enqueueCloudOcrUpgrade } from '$lib/catalog/cloud-ocr-upgrade'; /** * Extract series title from description field diff --git a/src/lib/components/AddLibraryModal.svelte b/src/lib/components/AddLibraryModal.svelte deleted file mode 100644 index af964d37..00000000 --- a/src/lib/components/AddLibraryModal.svelte +++ /dev/null @@ -1,353 +0,0 @@ - - - -
-

- {editMode ? 'Edit Library' : 'Add Library'} -

- -
{ - e.preventDefault(); - handleSave(); - }} - class="flex flex-col gap-4" - > - -
- - - - The WebDAV server URL (e.g., https://your-server.com/remote.php/dav/files/username) - -
- - -
- - - - A friendly name to identify this library (auto-generated from URL if empty) - -
- - -
- - - - Subfolder to browse (default: / for root). Example: /manga or /books - -
- - -
- - -
- - -
- - - Some servers require an app-specific password or token -
- - - {#if testResult} -
- {#if testResult === 'success'} - - Connection successful! - {:else} - - {testError || 'Connection failed'} - {/if} -
- {/if} - - - {#if !editMode && shareableUrl} -
-

Shareable link:

- - {shareableUrl} - -
- {/if} - - -
- -
- - -
-
-
-
diff --git a/src/lib/components/AppViewRouter.svelte b/src/lib/components/AppViewRouter.svelte index cfd6c0e1..1770dc86 100644 --- a/src/lib/components/AppViewRouter.svelte +++ b/src/lib/components/AppViewRouter.svelte @@ -14,9 +14,7 @@ cloud: () => import('$lib/views/CloudView.svelte'), upload: () => import('$lib/views/UploadView.svelte'), 'reading-speed': () => import('$lib/views/ReadingSpeedView.svelte'), - 'merge-series': () => import('$lib/views/MergeSeriesView.svelte'), - libraries: () => import('$lib/views/LibraryManagerView.svelte'), - 'add-library': () => import('$lib/views/AddLibraryView.svelte') + 'merge-series': () => import('$lib/views/MergeSeriesView.svelte') }; let CurrentComponent: Component | null = $state(null); diff --git a/src/lib/components/LibrarySelector.svelte b/src/lib/components/LibrarySelector.svelte deleted file mode 100644 index 5a5742fa..00000000 --- a/src/lib/components/LibrarySelector.svelte +++ /dev/null @@ -1,114 +0,0 @@ - - -
- - - - {#if showSelector} - - selectLibrary(null)} - class="flex items-center gap-2 {!selected ? 'bg-gray-100 dark:bg-gray-700' : ''}" - > - {#if !selected} - - {:else} - - {/if} - All Libraries - - - - - - {#each libraryList as library} - selectLibrary(library.id)} - class="flex items-center gap-2 {selected === library.id - ? 'bg-gray-100 dark:bg-gray-700' - : ''}" - > - {#if selected === library.id} - - {:else if errorMap.has(library.id)} - - {:else if statusMap.get(library.id) === 'fetching'} - - {:else if statusMap.get(library.id) === 'ready'} - - {:else} - - {/if} - {library.name} - - {/each} - - - {/if} - - - - - Manage Libraries - - -
diff --git a/src/lib/settings/libraries.ts b/src/lib/settings/libraries.ts deleted file mode 100644 index 51b50fd5..00000000 --- a/src/lib/settings/libraries.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * Library configuration store for read-only WebDAV libraries - * Libraries are separate from sync providers - they're browse-only sources for importing manga - */ - -import { browser } from '$app/environment'; -import { writable, derived, get } from 'svelte/store'; - -export interface LibraryConfig { - id: string; - name: string; - serverUrl: string; - username?: string; - password?: string; - basePath: string; // Subfolder path (default: '/') - lastFetched?: string; // ISO timestamp of last successful fetch - lastError?: string; // Error message if unreachable - lastErrorTime?: string; // When error occurred - createdAt: string; // ISO timestamp -} - -export interface LibraryState { - libraries: LibraryConfig[]; - selectedLibraryId: string | null; // null = show all -} - -const STORAGE_KEY = 'mokuro_libraries'; - -const defaultState: LibraryState = { - libraries: [], - selectedLibraryId: null -}; - -// Load from localStorage -function loadState(): LibraryState { - if (!browser) return defaultState; - - const stored = localStorage.getItem(STORAGE_KEY); - if (!stored) return defaultState; - - try { - const parsed = JSON.parse(stored); - // Ensure all required fields exist - return { - libraries: Array.isArray(parsed.libraries) ? parsed.libraries : [], - selectedLibraryId: parsed.selectedLibraryId ?? null - }; - } catch { - return defaultState; - } -} - -// Main store -export const librariesStore = writable(loadState()); - -// Auto-save to localStorage -librariesStore.subscribe((state) => { - if (browser) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - } -}); - -// Derived store for just the libraries array -export const libraries = derived(librariesStore, ($state) => $state.libraries); - -// Derived store for selected library ID -export const selectedLibraryId = derived(librariesStore, ($state) => $state.selectedLibraryId); - -// Derived store for currently selected library config (or null if showing all) -export const selectedLibrary = derived(librariesStore, ($state) => { - if (!$state.selectedLibraryId) return null; - return $state.libraries.find((lib) => lib.id === $state.selectedLibraryId) ?? null; -}); - -// Derived store: whether any libraries are configured -export const hasLibraries = derived(libraries, ($libraries) => $libraries.length > 0); - -/** - * Add a new library configuration - */ -export function addLibrary(config: Omit): LibraryConfig { - const newLibrary: LibraryConfig = { - ...config, - id: crypto.randomUUID(), - createdAt: new Date().toISOString() - }; - - librariesStore.update((state) => ({ - ...state, - libraries: [...state.libraries, newLibrary] - })); - - return newLibrary; -} - -/** - * Remove a library by ID - */ -export function removeLibrary(id: string): void { - librariesStore.update((state) => { - const newLibraries = state.libraries.filter((lib) => lib.id !== id); - return { - libraries: newLibraries, - // Clear selection if we removed the selected library - selectedLibraryId: state.selectedLibraryId === id ? null : state.selectedLibraryId - }; - }); -} - -/** - * Update a library's configuration - */ -export function updateLibrary( - id: string, - updates: Partial> -): void { - librariesStore.update((state) => ({ - ...state, - libraries: state.libraries.map((lib) => (lib.id === id ? { ...lib, ...updates } : lib)) - })); -} - -/** - * Set the selected library for filtering - * Pass null to show all libraries - */ -export function setSelectedLibrary(id: string | null): void { - librariesStore.update((state) => ({ - ...state, - selectedLibraryId: id - })); -} - -/** - * Get a library by ID - */ -export function getLibraryById(id: string): LibraryConfig | undefined { - const state = get(librariesStore); - return state.libraries.find((lib) => lib.id === id); -} - -/** - * Mark a library as successfully fetched - */ -export function markLibraryFetched(id: string): void { - updateLibrary(id, { - lastFetched: new Date().toISOString(), - lastError: undefined, - lastErrorTime: undefined - }); -} - -/** - * Mark a library as having an error - */ -export function markLibraryError(id: string, error: string): void { - updateLibrary(id, { - lastError: error, - lastErrorTime: new Date().toISOString() - }); -} - -/** - * Clear error state for a library - */ -export function clearLibraryError(id: string): void { - updateLibrary(id, { - lastError: undefined, - lastErrorTime: undefined - }); -} - -/** - * Get all libraries (useful for iteration) - */ -export function getAllLibraries(): LibraryConfig[] { - return get(librariesStore).libraries; -} - -/** - * Import libraries from profile sync data - * Uses newest-wins merge strategy by library ID - */ -export function importLibraries(importedLibraries: LibraryConfig[]): void { - librariesStore.update((state) => { - const mergedMap = new Map(); - - // Add existing libraries - for (const lib of state.libraries) { - mergedMap.set(lib.id, lib); - } - - // Merge imported libraries (newest wins based on createdAt) - for (const imported of importedLibraries) { - const existing = mergedMap.get(imported.id); - if (!existing) { - mergedMap.set(imported.id, imported); - } else { - // Keep the one with newer createdAt (or lastFetched as tiebreaker) - const existingTime = new Date(existing.lastFetched || existing.createdAt).getTime(); - const importedTime = new Date(imported.lastFetched || imported.createdAt).getTime(); - if (importedTime > existingTime) { - mergedMap.set(imported.id, imported); - } - } - } - - return { - ...state, - libraries: Array.from(mergedMap.values()) - }; - }); -} - -/** - * Export libraries for profile sync - */ -export function exportLibraries(): LibraryConfig[] { - return get(librariesStore).libraries; -} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index a72d4913..7a6cfffb 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -58,10 +58,6 @@ export interface VolumeMetadata { driveModifiedTime?: string; driveSize?: number; - // Library fields (for read-only WebDAV library sources) - libraryId?: string; - libraryName?: string; - // Spine width in pixels (from mokuro metadata, used for catalog stacking) spine_width?: number; } diff --git a/src/lib/util/download-queue.ts b/src/lib/util/download-queue.ts index b4ab594e..d69fc3ff 100644 --- a/src/lib/util/download-queue.ts +++ b/src/lib/util/download-queue.ts @@ -41,7 +41,6 @@ export interface QueueItem { volumeTitle: string; volumeMetadata: VolumeMetadata; status: 'queued' | 'downloading'; - libraryId?: string; } interface SeriesQueueStatus { @@ -143,8 +142,7 @@ export function queueVolume(volume: VolumeMetadata): void { seriesTitle: volume.series_title, volumeTitle: volume.volume_title, volumeMetadata: volume, - status: 'queued', - libraryId: volume.libraryId + status: 'queued' }; queueStore.update((q) => [...q, queueItem]); @@ -286,24 +284,7 @@ export function getSeriesQueueStatus(seriesTitle: string): SeriesQueueStatus { * For MEGA, creates a temporary share link instead of passing credentials * Implements rate limiting to prevent API congestion */ -async function getProviderCredentials( - provider: ProviderType, - fileId: string, - libraryId?: string -): Promise { - if (libraryId) { - const { getLibraryById } = await import('$lib/settings/libraries'); - const library = getLibraryById(libraryId); - if (!library) { - throw new Error(`Library not found: ${libraryId}`); - } - return { - webdavUrl: library.serverUrl.replace(/\/$/, ''), - webdavUsername: library.username, - webdavPassword: library.password - }; - } - +async function getProviderCredentials(provider: ProviderType, fileId: string): Promise { const activeProvider = unifiedCloudManager.getActiveProvider(); if (!activeProvider || activeProvider.type !== provider) { throw new Error(`Active provider mismatch for download credentials: expected ${provider}`); @@ -635,10 +616,9 @@ async function cleanupProviderDownloadCredentials( * - MEGA: Workers download from share link via MEGA API and decompress */ async function processDownload(item: QueueItem, processId: string): Promise { - const isLibraryDownload = !!item.libraryId; - const provider = isLibraryDownload ? null : unifiedCloudManager.getActiveProvider(); + const provider = unifiedCloudManager.getActiveProvider(); - if (!isLibraryDownload && !provider) { + if (!provider) { handleDownloadError(item, processId, `No cloud provider authenticated`); return; } @@ -646,9 +626,9 @@ async function processDownload(item: QueueItem, processId: string): Promise { // Get provider credentials (for MEGA, this creates a temporary share link) - const credentials = await getProviderCredentials( - providerType, - item.cloudFileId, - item.libraryId - ); + const credentials = await getProviderCredentials(providerType, item.cloudFileId); return { mode: 'download-and-decompress', @@ -732,18 +708,14 @@ async function processDownload(item: QueueItem, processId: string): Promise { console.error(`Error downloading ${item.volumeTitle}:`, data.error); - if (!isLibraryDownload) { - await cleanupProviderDownloadCredentials(provider!.type, item.cloudFileId); - } + await cleanupProviderDownloadCredentials(provider.type, item.cloudFileId); handleDownloadError(item, processId, data.error); checkAndTerminatePool(); @@ -786,7 +758,7 @@ async function processDownload(item: QueueItem, processId: string): Promise { + const blob = await provider.downloadFile(metadata, (loaded, total) => { if (total > 0) { const percent = Math.round((loaded / total) * 90); progressTrackerStore.updateProcess(processId, { @@ -885,9 +857,8 @@ async function processQueue(): Promise { } // Get active provider (single-provider architecture) - const isLibraryDownload = !!item.libraryId; const provider = unifiedCloudManager.getActiveProvider(); - if (!isLibraryDownload && !provider) { + if (!provider) { console.error(`[Download Queue] No cloud provider authenticated, skipping ${item.volumeTitle}`); return; } diff --git a/src/lib/util/hash-router.test.ts b/src/lib/util/hash-router.test.ts index f7dc8c5e..401ee570 100644 --- a/src/lib/util/hash-router.test.ts +++ b/src/lib/util/hash-router.test.ts @@ -35,18 +35,12 @@ describe('viewToHash', () => { const result = viewToHash({ type: 'merge-series' }); expect(result).toBe('#/merge-series'); }); +}); - test('generates libraries hash', () => { - const result = viewToHash({ type: 'libraries' }); - expect(result).toBe('#/libraries'); - }); - - test('generates add-library hash with params', () => { - const result = viewToHash({ - type: 'add-library', - params: { url: 'https://example.com/dav', name: 'My Library' } - }); - expect(result).toBe('#/add-library?url=https%3A%2F%2Fexample.com%2Fdav&name=My+Library'); +describe('removed libraries routes', () => { + test('stale #/libraries and #/add-library bookmarks fall back to catalog', () => { + expect(parseHash('#/libraries')).toEqual({ type: 'catalog' }); + expect(parseHash('#/add-library?url=x')).toEqual({ type: 'catalog' }); }); }); @@ -54,12 +48,4 @@ describe('nav helpers', () => { test('nav.toMergeSeries exists and is callable', () => { expect(typeof nav.toMergeSeries).toBe('function'); }); - - test('nav.toLibraries exists and is callable', () => { - expect(typeof nav.toLibraries).toBe('function'); - }); - - test('nav.toAddLibrary exists and is callable', () => { - expect(typeof nav.toAddLibrary).toBe('function'); - }); }); diff --git a/src/lib/util/hash-router.ts b/src/lib/util/hash-router.ts index 2c7e6ed6..ae5b6955 100644 --- a/src/lib/util/hash-router.ts +++ b/src/lib/util/hash-router.ts @@ -17,9 +17,7 @@ export type View = | { type: 'cloud' } | { type: 'upload' } | { type: 'reading-speed' } - | { type: 'merge-series' } - | { type: 'libraries' } - | { type: 'add-library'; params?: Record }; + | { type: 'merge-series' }; function getInitialView(): View { if (typeof window !== 'undefined') { @@ -47,6 +45,7 @@ export function parseHash(hash: string): View { if (segments[0] === 'upload') return { type: 'upload' }; if (segments[0] === 'reading-speed') return { type: 'reading-speed' }; if (segments[0] === 'merge-series') return { type: 'merge-series' }; + // Removed libraries feature: send stale bookmarks to the catalog if (segments[0] === 'libraries' || segments[0] === 'add-library') return { type: 'catalog' }; if (segments[0] === 'series' && segments.length >= 2) { @@ -94,16 +93,6 @@ export function viewToHash(view: View): string { return '#/reading-speed'; case 'merge-series': return '#/merge-series'; - case 'libraries': - return '#/libraries'; - case 'add-library': { - const base = '#/add-library'; - if (view.params && Object.keys(view.params).length > 0) { - const searchParams = new URLSearchParams(view.params); - return `${base}?${searchParams.toString()}`; - } - return base; - } } } @@ -163,14 +152,7 @@ export const nav = { toReadingSpeed: (options?: NavigateOptions) => navigate({ type: 'reading-speed' }, options), /** Navigate to merge series page */ - toMergeSeries: (options?: NavigateOptions) => navigate({ type: 'merge-series' }, options), - - /** Navigate to libraries page */ - toLibraries: (options?: NavigateOptions) => navigate({ type: 'libraries' }, options), - - /** Navigate to add library page */ - toAddLibrary: (params?: Record, options?: NavigateOptions) => - navigate({ type: 'add-library', params }, options) + toMergeSeries: (options?: NavigateOptions) => navigate({ type: 'merge-series' }, options) }; /** @@ -208,12 +190,8 @@ export function navigateBack(): void { case 'reading-speed': case 'upload': case 'merge-series': - case 'libraries': nav.toCatalog(); break; - case 'add-library': - nav.toLibraries(); - break; case 'catalog': // Already at root, do nothing break; diff --git a/src/lib/util/libraries/index.ts b/src/lib/util/libraries/index.ts deleted file mode 100644 index b020ce7a..00000000 --- a/src/lib/util/libraries/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Library module exports - * Read-only WebDAV libraries for browsing and downloading manga - */ - -// Client -export { - LibraryWebDAVClient, - createLibraryClient, - type LibraryFileMetadata -} from './library-webdav-client'; - -// Cache manager -export { - fetchLibrary, - fetchAllLibraries, - getLibraryStatus, - getLibraryFiles, - getAllLibraryFiles, - clearLibraryCache, - clearAllLibraryCaches, - removeLibraryFromCache, - getLibraryClient, - clearClientCache, - clearAllClients, - libraryFilesStore, - libraryMokuroFilesStore, - libraryStatusStore, - isAnyLibraryFetching, - totalLibraryFileCount, - libraryErrors, - type LibraryStatus -} from './library-cache-manager'; - -// Placeholders -export { generateLibraryPlaceholders, isLibraryVolume } from './library-placeholders'; diff --git a/src/lib/util/libraries/library-cache-manager.ts b/src/lib/util/libraries/library-cache-manager.ts deleted file mode 100644 index ef69ba76..00000000 --- a/src/lib/util/libraries/library-cache-manager.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * Cache manager for library files - * Maintains separate caches per library and provides reactive stores for UI - */ - -import { writable, derived, get, type Readable } from 'svelte/store'; -import type { LibraryConfig } from '$lib/settings/libraries'; -import { - libraries, - markLibraryFetched, - markLibraryError, - clearLibraryError -} from '$lib/settings/libraries'; -import { - LibraryWebDAVClient, - createLibraryClient, - type LibraryFileMetadata -} from './library-webdav-client'; - -export type LibraryStatus = 'idle' | 'fetching' | 'ready' | 'error'; - -interface LibraryState { - status: LibraryStatus; - files: LibraryFileMetadata[]; - mokuroFiles: LibraryFileMetadata[]; - error?: string; -} - -// Per-library state store -const libraryStatesStore = writable>(new Map()); - -// WebDAV client instances (cached) -const clientCache = new Map(); - -/** - * Get or create a WebDAV client for a library - */ -function getClient(config: LibraryConfig): LibraryWebDAVClient { - let client = clientCache.get(config.id); - if (!client) { - client = createLibraryClient(config); - clientCache.set(config.id, client); - } - return client; -} - -/** - * Clear cached client for a library (call when config changes) - */ -export function clearClientCache(libraryId: string): void { - clientCache.delete(libraryId); -} - -/** - * Clear all cached clients - */ -export function clearAllClients(): void { - clientCache.clear(); -} - -/** - * Fetch files from a single library - */ -export async function fetchLibrary(config: LibraryConfig): Promise { - // Update status to fetching - libraryStatesStore.update((states) => { - const newStates = new Map(states); - newStates.set(config.id, { - status: 'fetching', - files: states.get(config.id)?.files || [], - mokuroFiles: states.get(config.id)?.mokuroFiles || [], - error: undefined - }); - return newStates; - }); - - try { - const client = getClient(config); - const [files, mokuroFiles] = await Promise.all([client.listFiles(), client.listMokuroFiles()]); - - // Update with success - libraryStatesStore.update((states) => { - const newStates = new Map(states); - newStates.set(config.id, { - status: 'ready', - files, - mokuroFiles, - error: undefined - }); - return newStates; - }); - - // Mark as fetched in library config - markLibraryFetched(config.id); - clearLibraryError(config.id); - - return files; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - // Update with error - libraryStatesStore.update((states) => { - const newStates = new Map(states); - newStates.set(config.id, { - status: 'error', - files: states.get(config.id)?.files || [], // Keep old files on error - mokuroFiles: states.get(config.id)?.mokuroFiles || [], // Keep old sidecars on error - error: errorMessage - }); - return newStates; - }); - - // Mark error in library config - markLibraryError(config.id, errorMessage); - - throw error; - } -} - -/** - * Fetch files from all configured libraries - */ -export async function fetchAllLibraries(): Promise { - const libraryList = get(libraries); - - // Fetch all libraries in parallel - const results = await Promise.allSettled(libraryList.map((lib) => fetchLibrary(lib))); - - // Log any failures - results.forEach((result, index) => { - if (result.status === 'rejected') { - console.warn(`Failed to fetch library "${libraryList[index].name}":`, result.reason); - } - }); -} - -/** - * Get status for a specific library - */ -export function getLibraryStatus(libraryId: string): LibraryStatus { - const states = get(libraryStatesStore); - return states.get(libraryId)?.status || 'idle'; -} - -/** - * Get files for a specific library - */ -export function getLibraryFiles(libraryId: string): LibraryFileMetadata[] { - const states = get(libraryStatesStore); - return states.get(libraryId)?.files || []; -} - -/** - * Get all files from all libraries - */ -export function getAllLibraryFiles(): LibraryFileMetadata[] { - const states = get(libraryStatesStore); - const allFiles: LibraryFileMetadata[] = []; - - for (const state of states.values()) { - allFiles.push(...state.files); - } - - return allFiles; -} - -/** - * Clear cache for a specific library - */ -export function clearLibraryCache(libraryId: string): void { - libraryStatesStore.update((states) => { - const newStates = new Map(states); - newStates.delete(libraryId); - return newStates; - }); - clearClientCache(libraryId); -} - -/** - * Clear all library caches - */ -export function clearAllLibraryCaches(): void { - libraryStatesStore.set(new Map()); - clearAllClients(); -} - -/** - * Remove a library from the cache (when library is deleted) - */ -export function removeLibraryFromCache(libraryId: string): void { - clearLibraryCache(libraryId); -} - -// ============================================================================ -// Reactive Stores -// ============================================================================ - -/** - * Reactive store of all library files - * Map - */ -export const libraryFilesStore: Readable> = derived( - libraryStatesStore, - ($states) => { - const filesMap = new Map(); - for (const [libraryId, state] of $states) { - if (state.files.length > 0) { - filesMap.set(libraryId, state.files); - } - } - return filesMap; - } -); - -/** - * Reactive store of library statuses - * Map - */ -export const libraryStatusStore: Readable> = derived( - libraryStatesStore, - ($states) => { - const statusMap = new Map(); - for (const [libraryId, state] of $states) { - statusMap.set(libraryId, state.status); - } - return statusMap; - } -); - -/** - * Reactive store of library mokuro sidecar files - * Map - */ -export const libraryMokuroFilesStore: Readable> = derived( - libraryStatesStore, - ($states) => { - const filesMap = new Map(); - for (const [libraryId, state] of $states) { - if (state.mokuroFiles.length > 0) { - filesMap.set(libraryId, state.mokuroFiles); - } - } - return filesMap; - } -); - -/** - * Reactive store indicating if any library is currently fetching - */ -export const isAnyLibraryFetching: Readable = derived(libraryStatesStore, ($states) => { - for (const state of $states.values()) { - if (state.status === 'fetching') { - return true; - } - } - return false; -}); - -/** - * Reactive store of total file count across all libraries - */ -export const totalLibraryFileCount: Readable = derived(libraryStatesStore, ($states) => { - let count = 0; - for (const state of $states.values()) { - count += state.files.length; - } - return count; -}); - -/** - * Reactive store: Map of libraryId -> error message (only for libraries with errors) - */ -export const libraryErrors: Readable> = derived( - libraryStatesStore, - ($states) => { - const errors = new Map(); - for (const [libraryId, state] of $states) { - if (state.error) { - errors.set(libraryId, state.error); - } - } - return errors; - } -); - -/** - * Get client for a library (for downloads) - */ -export function getLibraryClient(config: LibraryConfig): LibraryWebDAVClient { - return getClient(config); -} - -// Export for testing -export { libraryStatesStore as _libraryStatesStore }; diff --git a/src/lib/util/libraries/library-placeholders.ts b/src/lib/util/libraries/library-placeholders.ts deleted file mode 100644 index a001968c..00000000 --- a/src/lib/util/libraries/library-placeholders.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Generate placeholder VolumeMetadata for library files - * Similar to catalog/placeholders.ts but for library sources - */ - -import { browser } from '$app/environment'; -import type { VolumeMetadata } from '$lib/types'; -import type { LibraryFileMetadata } from './library-webdav-client'; -import { getLibraryById } from '$lib/settings/libraries'; -import { enqueueLibraryOcrUpgrade } from './library-ocr-upgrade-queue'; - -/** - * Generate a deterministic UUID from a string - */ -function generateUuidFromString(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; - } - - const hex = Math.abs(hash).toString(16).padStart(8, '0'); - return `library-${hex}`; -} - -/** - * Parse series and volume title from library file path - * Expected format: "SeriesTitle/VolumeTitle.cbz" or just "VolumeTitle.cbz" - */ -function parseLibraryPath(path: string): { seriesTitle: string; volumeTitle: string } | null { - const parts = path.split('/'); - - if (parts.length === 1) { - // Just a filename, no folder - const volumeTitle = parts[0].replace(/\.(cbz|zip)$/i, ''); - return { seriesTitle: volumeTitle, volumeTitle }; - } - - if (parts.length === 2) { - // SeriesTitle/VolumeTitle.cbz - const seriesTitle = parts[0]; - const volumeTitle = parts[1].replace(/\.(cbz|zip)$/i, ''); - return { seriesTitle, volumeTitle }; - } - - if (parts.length > 2) { - // Nested folders - use first folder as series, last part as volume - const seriesTitle = parts[0]; - const volumeTitle = parts[parts.length - 1].replace(/\.(cbz|zip)$/i, ''); - return { seriesTitle, volumeTitle }; - } - - return null; -} - -/** - * Create a placeholder VolumeMetadata for a library file - */ -function createLibraryPlaceholder( - libraryFile: LibraryFileMetadata, - seriesUuid: string, - libraryName: string -): VolumeMetadata | null { - const parsed = parseLibraryPath(libraryFile.path); - if (!parsed) return null; - - const { seriesTitle, volumeTitle } = parsed; - - // Use fileId + libraryId for volume UUID to ensure uniqueness across libraries - const volumeUuid = generateUuidFromString(`${libraryFile.libraryId}:${libraryFile.fileId}`); - - return { - mokuro_version: 'unknown', - series_title: seriesTitle, - series_uuid: seriesUuid, - volume_title: volumeTitle, - volume_uuid: volumeUuid, - page_count: 0, - character_count: 0, - page_char_counts: [], - - // Placeholder fields - isPlaceholder: true, - cloudProvider: 'webdav', - cloudFileId: libraryFile.fileId, - cloudModifiedTime: libraryFile.modifiedTime, - cloudSize: libraryFile.size, - - // Library-specific fields - libraryId: libraryFile.libraryId, - libraryName: libraryName - }; -} - -/** - * Generate placeholder VolumeMetadata for library files - * - * @param libraryFilesMap Map of libraryId -> LibraryFileMetadata[] - * @param localVolumes Array of local VolumeMetadata - * @param selectedLibraryId The currently selected library ID (null = show all) - */ -export function generateLibraryPlaceholders( - libraryFilesMap: Map, - libraryMokuroFilesMap: Map, - localVolumes: VolumeMetadata[], - selectedLibraryId: string | null -): VolumeMetadata[] { - // Skip during SSR/build - if (!browser) { - return []; - } - - // Create a set of local volume paths for fast lookup - // Include both local volumes and any cloud placeholders (to avoid duplicates) - const localPaths = new Set(); - const localVolumeByPath = new Map(); - for (const vol of localVolumes) { - const key = `${vol.series_title}/${vol.volume_title}.cbz`.toLowerCase(); - localPaths.add(key); - if (!vol.isPlaceholder && !localVolumeByPath.has(key)) { - localVolumeByPath.set(key, vol); - } - } - - // Create a map of series titles to their UUIDs from local volumes - const seriesTitleToUuid = new Map(); - for (const vol of localVolumes) { - const lowerTitle = vol.series_title.toLowerCase(); - if (!seriesTitleToUuid.has(lowerTitle)) { - seriesTitleToUuid.set(lowerTitle, vol.series_uuid); - } - } - - const placeholders: VolumeMetadata[] = []; - - for (const [libraryId, files] of libraryFilesMap) { - // Skip if filtering and this isn't the selected library - if (selectedLibraryId !== null && libraryId !== selectedLibraryId) { - continue; - } - - const mokuroLookup = new Map(); - const mokuroFiles = libraryMokuroFilesMap.get(libraryId) || []; - console.log( - `[Library OCR Upgrade] Matcher scan for library ${libraryId}: ${files.length} archives, ${mokuroFiles.length} mokuro sidecars` - ); - for (const sidecar of mokuroFiles) { - const cbzLikePath = sidecar.path.replace(/\.mokuro(?:\.gz)?$/i, '.cbz'); - const parsedSidecar = parseLibraryPath(cbzLikePath); - if (!parsedSidecar) continue; - const key = `${parsedSidecar.seriesTitle}/${parsedSidecar.volumeTitle}`.toLowerCase(); - // Prefer plain .mokuro over gz when both exist. - const existing = mokuroLookup.get(key); - if (!existing || existing.path.toLowerCase().endsWith('.mokuro.gz')) { - mokuroLookup.set(key, sidecar); - } - } - - // Get library name - const library = getLibraryById(libraryId); - const libraryName = library?.name || 'Unknown Library'; - - for (const file of files) { - const parsed = parseLibraryPath(file.path); - if (!parsed) continue; - - // Check if already exists locally (case-insensitive) - const localPath = `${parsed.seriesTitle}/${parsed.volumeTitle}.cbz`.toLowerCase(); - if (localPaths.has(localPath)) { - const localVolume = localVolumeByPath.get(localPath); - const mokuroKey = `${parsed.seriesTitle}/${parsed.volumeTitle}`.toLowerCase(); - const remoteMokuro = mokuroLookup.get(mokuroKey); - if ( - localVolume && - (typeof localVolume.mokuro_version !== 'string' || - localVolume.mokuro_version.trim() === '') && - remoteMokuro - ) { - console.log( - '[Library OCR Upgrade] Match found, enqueueing upgrade:', - `${localVolume.series_title}/${localVolume.volume_title}`, - 'using', - remoteMokuro.path - ); - enqueueLibraryOcrUpgrade(localVolume, remoteMokuro); - } else if (localVolume && !remoteMokuro) { - console.log( - '[Library OCR Upgrade] Local image-only match has no remote mokuro sidecar:', - `${localVolume.series_title}/${localVolume.volume_title}` - ); - } else if (localVolume) { - console.log( - '[Library OCR Upgrade] Local match already has OCR, skipping:', - `${localVolume.series_title}/${localVolume.volume_title}`, - 'mokuro_version=', - localVolume.mokuro_version - ); - } - continue; - } - - // Use existing series UUID if we have local volumes with this series title - // Otherwise generate a deterministic UUID - const lowerSeriesTitle = parsed.seriesTitle.toLowerCase(); - const seriesUuid = - seriesTitleToUuid.get(lowerSeriesTitle) || - generateUuidFromString(`library-series:${lowerSeriesTitle}`); - - const placeholder = createLibraryPlaceholder(file, seriesUuid, libraryName); - if (placeholder) { - placeholders.push(placeholder); - - // Add to local paths to prevent duplicates within same library batch - localPaths.add(localPath); - } - } - } - - return placeholders; -} - -/** - * Check if a volume is from a library - */ -export function isLibraryVolume(volume: VolumeMetadata): boolean { - return volume.libraryId !== undefined; -} diff --git a/src/lib/util/libraries/library-webdav-client.ts b/src/lib/util/libraries/library-webdav-client.ts deleted file mode 100644 index 4ed8780f..00000000 --- a/src/lib/util/libraries/library-webdav-client.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * Lightweight read-only WebDAV client for library browsing - * Simplified version of webdav-provider.ts - only supports listing and downloading - */ - -import type { LibraryConfig } from '$lib/settings/libraries'; -import type { WebDAVClient } from 'webdav'; -import { webdavAuthOptions } from '$lib/util/sync/core/providers/webdav-auth'; - -export interface LibraryFileMetadata { - libraryId: string; - fileId: string; // Full WebDAV path - path: string; // Relative path from base folder (e.g., "SeriesTitle/VolumeTitle.cbz") - modifiedTime: string; - size: number; -} - -export interface LibraryClientOptions { - timeout?: number; // Connection timeout in ms (default: 10000) -} - -export class LibraryWebDAVClient { - private client: WebDAVClient | null = null; - private config: LibraryConfig; - private supportsDepthInfinity: boolean | null = null; - - constructor(config: LibraryConfig) { - this.config = config; - } - - /** - * Get the library ID this client is associated with - */ - get libraryId(): string { - return this.config.id; - } - - /** - * Get the library name - */ - get libraryName(): string { - return this.config.name; - } - - /** - * Get the base path for this library - */ - private get basePath(): string { - // Normalize base path to not have trailing slash - const path = this.config.basePath || '/'; - return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path; - } - - /** - * Test connection to the WebDAV server - * Returns true if successful, throws on failure - */ - async testConnection(options?: LibraryClientOptions): Promise { - const timeout = options?.timeout ?? 10000; - - try { - const { createClient } = await import('webdav'); - - // Create client with optional credentials (UTF-8-safe Authorization header) - const normalizedUrl = this.config.serverUrl.replace(/\/$/, ''); - const client = createClient( - normalizedUrl, - webdavAuthOptions(this.config.username, this.config.password) - ); - - // Test with timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - await client.getDirectoryContents(this.basePath, { signal: controller.signal }); - } finally { - clearTimeout(timeoutId); - } - - this.client = client; - return true; - } catch (error) { - this.client = null; - throw this.wrapError(error); - } - } - - /** - * Connect to the library (initialize client) - */ - async connect(): Promise { - if (this.client) return; - - const { createClient } = await import('webdav'); - - const normalizedUrl = this.config.serverUrl.replace(/\/$/, ''); - this.client = createClient( - normalizedUrl, - webdavAuthOptions(this.config.username, this.config.password) - ); - } - - /** - * List all CBZ files in the library - */ - async listFiles(): Promise { - return this.listByExtensions(['.cbz', '.zip']); - } - - /** - * List all mokuro sidecar files in the library - */ - async listMokuroFiles(): Promise { - return this.listByExtensions(['.mokuro', '.mokuro.gz']); - } - - private async listByExtensions(extensions: string[]): Promise { - if (!this.client) { - await this.connect(); - } - - const client = this.client!; - const basePath = this.basePath; - const normalizedExtensions = extensions.map((ext) => ext.toLowerCase()); - - try { - // Try Depth: infinity first if not known to be unsupported - if (this.supportsDepthInfinity !== false) { - try { - const files = await this.listWithDepthInfinity(client, basePath, normalizedExtensions); - if (this.supportsDepthInfinity === null) { - console.log(`[Library ${this.config.name}] Server supports Depth: infinity`); - this.supportsDepthInfinity = true; - } - return files; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const isDepthInfinityError = - errorMessage.includes('403') || - errorMessage.includes('400') || - errorMessage.includes('infinity') || - errorMessage.includes('Depth'); - - if (isDepthInfinityError && this.supportsDepthInfinity === null) { - console.log( - `[Library ${this.config.name}] Server does not support Depth: infinity, using recursive` - ); - this.supportsDepthInfinity = false; - } else if (this.supportsDepthInfinity === null) { - console.warn( - `[Library ${this.config.name}] Depth: infinity failed unexpectedly, trying recursive:`, - errorMessage - ); - } else { - throw error; - } - } - } - - // Fall back to recursive listing - return await this.listRecursive(client, basePath, normalizedExtensions); - } catch (error) { - throw this.wrapError(error); - } - } - - /** - * List files using Depth: infinity (single request) - */ - private async listWithDepthInfinity( - client: WebDAVClient, - basePath: string, - extensions: string[] - ): Promise { - const contents = (await client.getDirectoryContents(basePath, { - deep: true - })) as Array<{ - type: string; - filename: string; - basename: string; - lastmod: string; - size: number; - }>; - - const files: LibraryFileMetadata[] = []; - - for (const item of contents) { - if (item.type === 'file') { - const name = item.basename.toLowerCase(); - if (extensions.some((ext) => name.endsWith(ext))) { - const relativePath = this.getRelativePath(item.filename, basePath); - files.push({ - libraryId: this.config.id, - fileId: item.filename, - path: relativePath, - modifiedTime: item.lastmod || new Date().toISOString(), - size: item.size || 0 - }); - } - } - } - - console.log(`[Library ${this.config.name}] Listed ${files.length} files (depth infinity)`); - return files; - } - - /** - * List files using recursive folder traversal - */ - private async listRecursive( - client: WebDAVClient, - basePath: string, - extensions: string[] - ): Promise { - const files: LibraryFileMetadata[] = []; - - const processFolder = async (folderPath: string): Promise => { - const contents = (await client.getDirectoryContents(folderPath)) as Array<{ - type: string; - filename: string; - basename: string; - lastmod: string; - size: number; - }>; - - for (const item of contents) { - if (item.type === 'directory') { - await processFolder(item.filename); - } else { - const name = item.basename.toLowerCase(); - if (extensions.some((ext) => name.endsWith(ext))) { - const relativePath = this.getRelativePath(item.filename, basePath); - files.push({ - libraryId: this.config.id, - fileId: item.filename, - path: relativePath, - modifiedTime: item.lastmod || new Date().toISOString(), - size: item.size || 0 - }); - } - } - } - }; - - await processFolder(basePath); - console.log(`[Library ${this.config.name}] Listed ${files.length} files (recursive)`); - return files; - } - - /** - * Download a file from the library - */ - async downloadFile(fileId: string): Promise { - if (!this.client) { - await this.connect(); - } - - try { - // URL encode path segments to handle special characters like # - const encodedPath = fileId - .split('/') - .map((segment) => encodeURIComponent(segment)) - .join('/'); - - const response = await this.client!.getFileContents(encodedPath); - - if (response instanceof ArrayBuffer) { - return new Blob([response]); - } else if (response instanceof Uint8Array) { - // Create new ArrayBuffer from Uint8Array for Blob compatibility - const buffer = new ArrayBuffer(response.byteLength); - new Uint8Array(buffer).set(response); - return new Blob([buffer]); - } else { - // String response - return new Blob([response as string]); - } - } catch (error) { - throw this.wrapError(error); - } - } - - /** - * Get credentials for worker downloads - */ - getWorkerCredentials(): { webdavUrl: string; webdavUsername?: string; webdavPassword?: string } { - return { - webdavUrl: this.config.serverUrl.replace(/\/$/, ''), - webdavUsername: this.config.username, - webdavPassword: this.config.password - }; - } - - /** - * Extract relative path from full WebDAV path - */ - private getRelativePath(fullPath: string, basePath: string): string { - const prefix = basePath === '/' ? '/' : basePath + '/'; - return fullPath.startsWith(prefix) ? fullPath.slice(prefix.length) : fullPath; - } - - /** - * Wrap errors with more descriptive messages - */ - private wrapError(error: unknown): Error { - const message = error instanceof Error ? error.message : String(error); - - if (message.includes('401') || message.includes('403') || message.includes('unauthorized')) { - return new Error(`Authentication failed for library "${this.config.name}": ${message}`); - } - - if ( - message.includes('Failed to fetch') || - message.includes('NetworkError') || - message.includes('ENOTFOUND') - ) { - return new Error(`Cannot connect to library "${this.config.name}": ${message}`); - } - - if (message.includes('404')) { - return new Error( - `Library path not found "${this.config.name}" (${this.basePath}): ${message}` - ); - } - - return new Error(`Library error "${this.config.name}": ${message}`); - } -} - -/** - * Create a library client instance - */ -export function createLibraryClient(config: LibraryConfig): LibraryWebDAVClient { - return new LibraryWebDAVClient(config); -} diff --git a/src/lib/util/modals.ts b/src/lib/util/modals.ts index 976ada1b..891d8c12 100644 --- a/src/lib/util/modals.ts +++ b/src/lib/util/modals.ts @@ -267,46 +267,3 @@ export function updateImportPreparing(details: Partial) { export function closeImportPreparing() { importPreparingModalStore.set(undefined); } - -export type AddLibraryModalParams = { - url?: string; - name?: string; - username?: string; - path?: string; -}; - -type AddLibraryModal = { - open: boolean; - editingId?: string; - params?: AddLibraryModalParams; - onSave?: () => void; - onCancel?: () => void; -}; - -export const addLibraryModalStore = writable(undefined); - -export function promptAddLibrary( - params?: AddLibraryModalParams, - onSave?: () => void, - onCancel?: () => void -) { - addLibraryModalStore.set({ - open: true, - params, - onSave, - onCancel - }); -} - -export function promptEditLibrary(libraryId: string, onSave?: () => void, onCancel?: () => void) { - addLibraryModalStore.set({ - open: true, - editingId: libraryId, - onSave, - onCancel - }); -} - -export function closeAddLibraryModal() { - addLibraryModalStore.set(undefined); -} diff --git a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts index c947febf..17eadf95 100644 --- a/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts +++ b/src/lib/util/sync/providers/filesystem/__tests__/filesystem-paths.test.ts @@ -31,7 +31,7 @@ describe('isSyncableFile', () => { ['Series/Volume.webp', true], ['volume-data.json', true], ['profiles.json', true], - ['libraries.json', false], // libraries feature not shipped — see syncable-file.ts + ['libraries.json', false], // removed libraries feature — see syncable-file.ts ['Series/cover.jpg', true], ['.DS_Store', false], ['Series/Notes.txt', false], diff --git a/src/lib/util/sync/syncable-file.test.ts b/src/lib/util/sync/syncable-file.test.ts index 74ca012b..df019473 100644 --- a/src/lib/util/sync/syncable-file.test.ts +++ b/src/lib/util/sync/syncable-file.test.ts @@ -19,7 +19,7 @@ describe('syncable-file', () => { expect(isSyncableFile('profiles.json')).toBe(true); }); - it('excludes libraries.json until the libraries feature ships', () => { + it('ignores libraries.json left behind by the removed libraries feature', () => { expect(isSyncableFile('libraries.json')).toBe(false); expect(isRootConfigFile('libraries.json')).toBe(false); }); diff --git a/src/lib/util/sync/syncable-file.ts b/src/lib/util/sync/syncable-file.ts index a03af7cd..44ffa254 100644 --- a/src/lib/util/sync/syncable-file.ts +++ b/src/lib/util/sync/syncable-file.ts @@ -8,10 +8,9 @@ * - Root config files: volume-data.json (read progress), profiles.json * (settings profiles) * - * libraries.json is deliberately NOT listed: the libraries feature has no - * specced/shipped UI yet, and excluding it here keeps its cloud download path - * (unified-sync-service findLibrariesFile → cache lookup) inert. Add it back - * when the feature ships. + * libraries.json is deliberately NOT listed: it belonged to the removed + * libraries feature. Stale copies may still exist in users' cloud folders — + * keep ignoring them. */ const ROOT_CONFIG_FILENAMES = new Set(['volume-data.json', 'profiles.json']); diff --git a/src/lib/util/sync/unified-sync-service.ts b/src/lib/util/sync/unified-sync-service.ts index 2efd0384..1df232bb 100644 --- a/src/lib/util/sync/unified-sync-service.ts +++ b/src/lib/util/sync/unified-sync-service.ts @@ -7,12 +7,6 @@ import { parseVolumesFromJson, migrateProfiles } from '$lib/settings'; -import { - librariesStore, - importLibraries, - exportLibraries, - type LibraryConfig -} from '$lib/settings/libraries'; import { showSnackbar } from '../snackbar'; import type { SyncProvider, ProviderType, CloudFileMetadata } from './provider-interface'; import { cacheManager } from './cache-manager'; @@ -211,15 +205,11 @@ class UnifiedSyncService { await this.syncVolumeData(provider); console.log('✅ Volume data synced'); - // Optionally sync profiles and libraries + // Optionally sync profiles if (options.syncProfiles) { console.log('🔄 options.syncProfiles is true, calling syncProfiles...'); await this.syncProfiles(provider); console.log('✅ syncProfiles completed'); - - console.log('🔄 Syncing libraries...'); - await this.syncLibraries(provider); - console.log('✅ Libraries synced'); } else { console.log('⏭️ Skipping profile sync (options.syncProfiles is false)'); } @@ -724,141 +714,6 @@ class UnifiedSyncService { return merged; } - - /** - * Find libraries.json file from provider using generic cache - * Returns CloudFileMetadata if file exists, null otherwise - */ - private findLibrariesFile(provider: SyncProvider): CloudFileMetadata | null { - const cache = cacheManager.getCache(provider.type); - if (!cache) { - return null; - } - - // Query cache for libraries.json file - return cache.get('libraries.json'); - } - - /** - * Download libraries.json file from provider - */ - private async downloadLibrariesFile(provider: SyncProvider): Promise { - try { - console.log('🔎 Finding libraries.json in cache...'); - const librariesFile = this.findLibrariesFile(provider); - console.log('🔎 findLibrariesFile result:', librariesFile); - - if (!librariesFile) { - console.log('⚠️ libraries.json not found in cache, returning null'); - return null; - } - - console.log('⬇️ Downloading libraries.json from cloud...'); - const blob = await provider.downloadFile(librariesFile); - console.log('⬇️ Downloaded blob, converting to JSON...'); - const json = await this.blobToJson(blob); - console.log('✅ Successfully parsed libraries JSON:', json); - - // Validate it's an array - if (!Array.isArray(json)) { - console.warn('⚠️ libraries.json is not an array, returning empty'); - return []; - } - - return json as LibraryConfig[]; - } catch (error) { - console.error('❌ Error downloading libraries:', error); - // File not found is not an error - if ( - error instanceof Error && - (error.message.includes('not found') || - error.message.includes('404') || - error.message.includes('ENOENT')) - ) { - console.log('📝 Error was "not found", returning null'); - return null; - } - console.log('🔥 Re-throwing error (not a "not found" error)'); - throw error; - } - } - - /** - * Upload libraries.json file to provider - */ - private async uploadLibrariesFile(provider: SyncProvider, data: LibraryConfig[]): Promise { - const blob = this.jsonToBlob(data); - const path = 'libraries.json'; - await provider.uploadFile(path, blob); - } - - /** - * Sync libraries with a provider - * Libraries are global (not per-profile) so we sync them separately - */ - private async syncLibraries(provider: SyncProvider): Promise { - console.log('🔵 syncLibraries() function called for provider:', provider.name); - - // Step 1: Download cloud libraries - console.log('📥 Downloading cloud libraries...'); - const cloudLibraries = await this.downloadLibrariesFile(provider); - console.log('📥 Downloaded cloud libraries:', cloudLibraries); - - // Step 2: Get local libraries - const localLibraries = exportLibraries(); - console.log('💾 Local libraries:', localLibraries); - - // Step 3: Merge libraries (newest wins by library ID) - console.log('🔀 About to merge libraries...'); - const mergedLibraries = this.mergeLibraries(localLibraries, cloudLibraries || []); - console.log('✅ Merged libraries:', mergedLibraries); - - // Step 4: Update local storage - importLibraries(mergedLibraries); - - // Step 5: Upload merged libraries if changed - const mergedJson = JSON.stringify(mergedLibraries); - const cloudJson = JSON.stringify(cloudLibraries || []); - - if (mergedJson !== cloudJson) { - console.log('📤 Uploading merged libraries to cloud...'); - await this.uploadLibrariesFile(provider, mergedLibraries); - console.log('✅ Libraries uploaded'); - } else { - console.log('⏭️ Libraries unchanged, skipping upload'); - } - } - - /** - * Merge libraries using newest-wins strategy by library ID - * Uses lastFetched or createdAt timestamp for conflict resolution - */ - private mergeLibraries(local: LibraryConfig[], cloud: LibraryConfig[]): LibraryConfig[] { - const mergedMap = new Map(); - - // Add all local libraries - for (const lib of local) { - mergedMap.set(lib.id, lib); - } - - // Merge cloud libraries (newest wins) - for (const cloudLib of cloud) { - const existing = mergedMap.get(cloudLib.id); - if (!existing) { - mergedMap.set(cloudLib.id, cloudLib); - } else { - // Compare timestamps - use lastFetched if available, otherwise createdAt - const existingTime = new Date(existing.lastFetched || existing.createdAt).getTime(); - const cloudTime = new Date(cloudLib.lastFetched || cloudLib.createdAt).getTime(); - - if (cloudTime > existingTime) { - mergedMap.set(cloudLib.id, cloudLib); - } - } - } - - return Array.from(mergedMap.values()); - } } export const unifiedSyncService = new UnifiedSyncService(); diff --git a/src/lib/views/AddLibraryView.svelte b/src/lib/views/AddLibraryView.svelte deleted file mode 100644 index aac9483e..00000000 --- a/src/lib/views/AddLibraryView.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - - -
- -
diff --git a/src/lib/views/LibraryManagerView.svelte b/src/lib/views/LibraryManagerView.svelte deleted file mode 100644 index d6a2dadc..00000000 --- a/src/lib/views/LibraryManagerView.svelte +++ /dev/null @@ -1,245 +0,0 @@ - - -
- -
-
- -
-

Libraries

-

- {libraryList.length} - {libraryList.length === 1 ? 'library' : 'libraries'} - {#if totalFiles > 0} - • {totalFiles} volumes available - {/if} -

-
-
-
- {#if libraryList.length > 0} - - {/if} - -
-
- - - {#if libraryList.length === 0} - -

- No libraries configured. Add a WebDAV library to browse and download manga from external - servers. -

- -
- {:else} -
- {#each libraryList as library} - {@const statusInfo = getStatusInfo(library)} - {@const isRefreshing = refreshingIds.has(library.id)} - -
-
-
-

{library.name}

- {statusInfo.text} -
-

- {library.serverUrl}{library.basePath !== '/' ? library.basePath : ''} -

- {#if library.lastFetched} -

- Last refreshed: {formatDate(library.lastFetched)} -

- {/if} - {#if errorMap.has(library.id)} -

- {errorMap.get(library.id)} -

- {/if} -
-
- - - -
-
-
- {/each} -
- {/if} - - -
-

About Libraries

-
    -
  • • Libraries are read-only WebDAV sources for browsing and downloading manga
  • -
  • • Downloaded volumes are imported into your local catalog
  • -
  • • Libraries are separate from cloud sync - they don't sync reading progress
  • -
  • • Share a library link with others: copy the "Add Library" URL from your server
  • -
-
-
- - diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f3896767..5a888744 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -14,9 +14,7 @@ cloud: () => import('$lib/views/CloudView.svelte'), upload: () => import('$lib/views/UploadView.svelte'), 'reading-speed': () => import('$lib/views/ReadingSpeedView.svelte'), - 'merge-series': () => import('$lib/views/MergeSeriesView.svelte'), - libraries: () => import('$lib/views/LibraryManagerView.svelte'), - 'add-library': () => import('$lib/views/AddLibraryView.svelte') + 'merge-series': () => import('$lib/views/MergeSeriesView.svelte') }; // Currently loaded component diff --git a/src/routes/[...catchall]/+page.svelte b/src/routes/[...catchall]/+page.svelte index 388d4ae2..556d5450 100644 --- a/src/routes/[...catchall]/+page.svelte +++ b/src/routes/[...catchall]/+page.svelte @@ -14,9 +14,7 @@ cloud: () => import('$lib/views/CloudView.svelte'), upload: () => import('$lib/views/UploadView.svelte'), 'reading-speed': () => import('$lib/views/ReadingSpeedView.svelte'), - 'merge-series': () => import('$lib/views/MergeSeriesView.svelte'), - libraries: () => import('$lib/views/LibraryManagerView.svelte'), - 'add-library': () => import('$lib/views/AddLibraryView.svelte') + 'merge-series': () => import('$lib/views/MergeSeriesView.svelte') }; // Currently loaded component