diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1103fb1..1dd4268 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,10 @@ jobs: bun-version: latest - name: Install Dependencies run: bun install - - name: Lint & Format + - name: Lint run: bun run lint + - name: Format check + run: bunx oxfmt --check . - name: Type Check run: bun run tsc --noEmit - name: Run Tests diff --git a/AGENTS.md b/AGENTS.md index 37d348d..5d3c7e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,152 +1,7 @@ -# Agentic Development Guidelines: Pod Repository +# AGENTS.md -This document serves as a comprehensive guide for AI coding assistants and autonomous agents working within the `pod` repository. It outlines the technical stack, architectural patterns, and stylistic preferences to ensure consistency and high-quality contributions. - ---- - -## 1. Environment & Setup - -The project is built on **Bun** and uses **TypeScript** with **SolidJS** for the TUI (`@opentui/solid`). We use **Just** as a command runner. - -### Prerequisites - -- **Runtime**: Bun (latest stable) -- **Language**: TypeScript 5+ -- **Framework**: SolidJS 1.9+ -- **UI Library**: `@opentui/solid` -- **Tooling**: Oxlint/Oxfmt (Linting & Formatting), Just (Command Runner) - -### Initialization - -```bash -bun install -``` - ---- - -## 2. Build & Test Commands - -We use `just` for task automation. See `justfile` for all commands. - -### Running the Application - -The app is a TUI requiring a specific preload script. - -- **Start (Dev)**: `just start` (or `just s`) - - Runs: `bun run --watch src/index.tsx` -- **Debug**: `just debug` (or `just d`) - - Runs with `DEBUG=true` - -### Testing - -Tests are in `src/__tests__/`. Use `just test` (or `just t`). - -- **Run All Tests**: - ```bash - just test - ``` -- **Run Specific Test File**: - ```bash - just test src/__tests__/drive.test.ts - ``` -- **Run Specific Test Case**: - ```bash - just test -t "should render drive list" - ``` -- **Watch Mode**: - ```bash - just test-watch - ``` - -### Linting & Formatting - -- **Check**: `just check` (or `just c`) - Runs `tsc --noEmit` -- **Lint**: `just lint` (or `just l`) -- **Format**: `just fmt` (or `just f`) -- **Full Check**: `just full-check` (or `just fc`) - Runs check + test - ---- - -## 3. Code Style Guidelines - -Enforced by `.oxlintrc.json`, `.oxfmt.json`, and `tsconfig.json`. - -### Naming Conventions - -- **Components**: `PascalCase` (e.g., `DriveItem.tsx`). -- **Functions**: `camelCase` (e.g., `fetchPodcasts`). -- **Variables**: `camelCase`; `UPPER_SNAKE_CASE` for constants. -- **Types/Interfaces**: `PascalCase`. No `I` prefix. - -### TypeScript - -- **Strict Mode**: Enabled. -- **No Any**: Avoid `any`. Use explicit types. -- **Return Types**: Explicitly annotate public function return types. -- **Path Aliases**: Use `@/` for `src/` (e.g., `import { theme } from "@/theme"`). - -### Import Order - -1. Built-in (fs, path) -2. External (solid-js, @opentui/\*) -3. Internal Alias (@/components/\*) -4. Relative (./utils) - ---- - -## 4. Component Design (SolidJS + TUI) - -### Reactive State - -- **Signals**: `createSignal` for primitives. -- **Stores**: `createStore` for objects/arrays (e.g., file lists). -- **Effects**: `createEffect` for side effects (logging, file I/O). - -### TUI Constraints - -- **Rendering**: Use `@opentui/solid` components (Box, Text). -- **Layout**: Flexbox-like. Use `flexDirection="column"` for stacks. -- **Input**: Handle `onKeyPress` events. Manage focus manually if needed. -- **No DOM**: There is no HTML DOM. Do not use `div`, `span`, or web APIs. - ---- - -## 5. Error Handling & Debugging - -- **No Console Log**: `console.log` breaks the TUI. Use `console.error` (redirected) or file logging. -- **Error Boundaries**: Wrap major sections in `ErrorBoundary` to prevent crash-to-shell. -- **Debug Mode**: Use `just debug` to enable verbose logging to file. - ---- - -## 6. Directory Structure - -``` -src/ -├── __tests__/ # Unit tests -├── components/ # UI Components (DriveList, Header) -├── data/ # Mock data -├── hooks/ # Custom SolidJS hooks -├── services/ # Core logic (fs, podcasts) -├── theme/ # Colors and styles -├── types/ # TS Interfaces -├── utils/ # Helpers (formatting, math) -└── index.tsx # Entry point -``` - ---- - -## 7. Instructions for Agents - -1. **Discovery**: Use `ls` and `grep` to find relevant files. Read `AGENTS.md` first. -2. **Task Runner**: Always prefer `just` commands over raw `bun` commands. -3. **Verification**: - - Run `just lint` to fix formatting. - - Run `just test` to ensure no regressions. - - Run `just check` to verify types. -4. **Implementation**: - - Write small, testable functions. - - Follow the "No DOM" rule strictly. -5. **Commits**: Write semantic commit messages (e.g., `feat: add drive selection`). - ---- +- Bun-only runtime (`src/index.tsx` exits under Node). Run via `just`, not raw `bun` (see `justfile`). +- No `createEffect`. Setup/cleanup in `onSettled`; keep state sync in event handlers/store actions. +- `console.log` breaks the TUI. Use `console.error` or file logging. +- Wrap major sections in `Errored` to avoid crash-to-shell (see `Root` in `src/index.tsx`). +- Verify with `just check`, `just test`, `just lint`, `just fmt-check`. diff --git a/CHANGELOG.md b/CHANGELOG.md index d74749b..82a4cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,13 @@ ## [0.3.0](https://github.com/joncrangle/podapple/compare/v0.2.1...v0.3.0) (2026-07-07) - ### Features -* improve drive detection stability, reliability, and event handling ([359a5e3](https://github.com/joncrangle/podapple/commit/359a5e369202386dba153664f7f937068b588353)) - +- improve drive detection stability, reliability, and event handling ([359a5e3](https://github.com/joncrangle/podapple/commit/359a5e369202386dba153664f7f937068b588353)) ### Bug Fixes -* delete empty show directories when manually removing episodes from drive ([0ae3cd2](https://github.com/joncrangle/podapple/commit/0ae3cd20cb8804a9576ed7ff0f9a488b500ad0a8)) +- delete empty show directories when manually removing episodes from drive ([0ae3cd2](https://github.com/joncrangle/podapple/commit/0ae3cd20cb8804a9576ed7ff0f9a488b500ad0a8)) ## [0.2.1](https://github.com/joncrangle/podapple/compare/v0.2.0...v0.2.1) (2026-04-29) diff --git a/bun.lock b/bun.lock index 424be3d..7c99a0e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,28 +5,30 @@ "": { "name": "pod", "dependencies": { - "@effect/schema": "^0.75.5", - "@opentui/core": "^0.5.11", - "@opentui/solid": "^0.5.11", - "effect": "^3.22.1", + "@opentui/core": "0.5.14", + "@opentui/solid": "0.5.14", + "effect": "4.0.1", "node-id3": "^0.2.9", - "solid-js": "^1.9.15", + "solid-js": "2.0.0-rc.13", }, "devDependencies": { - "@effect/language-service": "^0.87.2", - "@types/bun": "^1.4.1", - "oxfmt": "^0.67.0", - "oxlint": "^1.82.0", + "@effect/language-service": "^0.87.4", + "@types/bun": "^1.4.2", + "oxfmt": "^0.72.0", + "oxlint": "^1.87.0", }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "^0.5.11", - "@opentui/core-darwin-x64": "^0.5.11", + "@opentui/core-darwin-arm64": "0.5.14", + "@opentui/core-darwin-x64": "0.5.14", }, "peerDependencies": { "typescript": "^6.0.3", }, }, }, + "patchedDependencies": { + "@opentui/solid@0.5.14": "patches/@opentui%2Fsolid@0.5.14.patch", + }, "packages": { "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -86,9 +88,7 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], - "@effect/language-service": ["@effect/language-service@0.87.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-CfiSoaVQO8pZgaMZGw5VvMvRGybsJ2LaSDoLYaqiVGvo/YYPYVR2GzUKY0h1NtIweq/+SQ5XLrvtzPIttWQ0GQ=="], - - "@effect/schema": ["@effect/schema@0.75.5", "", { "dependencies": { "fast-check": "^3.21.0" }, "peerDependencies": { "effect": "^3.9.2" } }, "sha512-TQInulTVCuF+9EIbJpyLP6dvxbQJMphrnRqgexm/Ze39rSjfhJuufF7XvU3SxTgg3HnL7B/kpORTJbHhlE6thw=="], + "@effect/language-service": ["@effect/language-service@0.87.4", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-AfFn4K45l84LtbQSwoPWc06nrg7OzfV3uJOnIUqimSOWTCIsY1IKBXZfjSsVTq7jDuL9Jtoz8//bZp9+rkEihg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -98,105 +98,105 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@opentui/core": ["@opentui/core@0.5.11", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.11", "@opentui/core-darwin-x64": "0.5.11", "@opentui/core-linux-arm64": "0.5.11", "@opentui/core-linux-arm64-musl": "0.5.11", "@opentui/core-linux-x64": "0.5.11", "@opentui/core-linux-x64-musl": "0.5.11", "@opentui/core-win32-arm64": "0.5.11", "@opentui/core-win32-x64": "0.5.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-pImMfjCNx7JUp9Df1LRZBDLisWqgzOLXZKXO+hh3jA9ujBFPQnZOqbG+/N5uVAAX8IZGaqhYSQRwSZIFBmcfbQ=="], + "@opentui/core": ["@opentui/core@0.5.14", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.14", "@opentui/core-darwin-x64": "0.5.14", "@opentui/core-linux-arm64": "0.5.14", "@opentui/core-linux-arm64-musl": "0.5.14", "@opentui/core-linux-x64": "0.5.14", "@opentui/core-linux-x64-musl": "0.5.14", "@opentui/core-win32-arm64": "0.5.14", "@opentui/core-win32-x64": "0.5.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DRXY5ioq+n1ZNAMAcaFaBunr0cmi2gqucjbTW7lgFp8t9uN3fNZnLTDOqkCTQtT2XhrU4GXqySaPSQ4qFG/EAQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-yP/8GliJDiJNm8YYJKvgWuy6xyCEd8d4GwBVOIzCFOI7ZIbGP8GOTvmwIjRW4Paw20pTttWMWyRoQiCvOXvH2g=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-zBIsRFHlLUYFNhapRSNt9dz4mC8gZ4Wxcfy3A+2AwqsgCipcr2FkIuAXYqN08q+IvqFX7DfqgIFGWDNedHTPUg=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+xeR2LYibvIi/qQetRjJR008sFRve60QuDcO8ItxUwzFeKTDzl5CEiZpBXfm5I4FhRNuyuj0TSPIFadMvrjFQ=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-pSOXqOADrv+zINOgR3FDFA9zVRaim3zl8/yhtO+X9rEJ6f34z3gDund0Gf88hNJSMpZK5xWtipEVm28RY5VF8w=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-MyqOnSs8pTYG2xmFr1xt6xZIuHu2Xu4pkle9my9JdE+WClmusHf0YN9Eas6jQLAq5XUi34r22wMeK0juk93zyw=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-MGGRXIDJ//HyaqC5ndSr7/CUl+ICdYEAMcjbA00UWsthh4ZO/rYhPkyqOaTn5ck6+G4ca3/+M76J+jImMYTjNg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-sMEGX9rhiPd1gBa190jzj5uIdzKCMImxHmr22hJLxIRWqejxWvqGnRBkW68wT8NfMViDiMtZWavgWL7GgmYCAQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w=="], - "@opentui/solid": ["@opentui/solid@0.5.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.11", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-u8RJ4UMwzi+r9M1sopZdnT+7XFSHmLzVmhPhc6N2LfBrA9Db4lZ9sQA+ywgdSDkzdANpAOwGLNUQlY/F1OOCLA=="], + "@opentui/solid": ["@opentui/solid@0.5.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-bBRl34mZ0wFGhjHX6y1VNiZmJ3DSm2DVPm0PNSVDpvq9qxRHuywEPTCn/Lgte0C9450tgf11cDqcE3pR9cZwVA=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.67.0", "", { "os": "android", "cpu": "arm" }, "sha512-2olh3ioEmc4gRzQm7jxyB1b/PFBoFvTq8KdgYySeNpysDtA6DEg2Mvya4/I6flhL7G0eOrE8RD7JCNCIMhE16Q=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.72.0", "", { "os": "android", "cpu": "arm" }, "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.67.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ulfw8EHN1MBq/MFFDXw2/M1VAFu5mRUcnuZ8Hqbv9viAnFzO9t1jKSAsDqKYYDGMlytF/uj6Z5z5n/tHupnKhw=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.72.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GCnM+Ae2mWOKC+92y3FOb6eZJeVnBAVz82e3lITUUg+kxukYKzBNAvJ/hhHIfa2A0md55SYYhdWPddSSy3KPVA=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.67.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MfONZx/O2o9M5v2jDFol556G9+A+P9xCuJ4DZ+qhE+RnaCdoscy6Eu5nq1dbuNxhwdJyZ6kLI7fnG9mwEeOeGg=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.72.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LjQ8tlevdtwraFlWC6AnQ1Forzde0clRmnsP3QmK+ydSElDt/J+5Jc+m+//Ly1G97aP7WCNUw110QMIUw2K7xA=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.67.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CYnIx5LvFVJnyJcCqwH2jxMKjFjqo5678MPjdmNFoSGMhlOvZ/xRZqvhDcolKrXc8fezW3AKh+C4wyoFuWOSSg=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.72.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+ZbcxhB1C34RQR5IldSyY6Z/yalJIxHXhP36PGE4sEq0f/lgL5jCLmOKoDeVm8dqd9gFGDsd1bNWHSGCAwdSkw=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.67.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-7/iF1orvIS9mxhKUqnmtMgm+OrSQ5acPwuvdQrm6ECgqbwPmC+Pw9cdke3sNfVN6pT2hbJ58+jP8BCThl5HXOg=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.72.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yGPhA8sePEBFV9iGcprhyOk3Bz3fZshswMH3DpYIPZFLT1DG5LjiZ0j6AJQpSueaL6bUK0av1K+5+fe+ldy5EQ=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-yy+OGys07IZOpOmYPZoObKyUQLkfxeQqeCypk+1jaZd8HGo77hzvU1Jg8X3+W75o+9lszOjBfg0nkGtlwYywXw=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.72.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/g5axuqFMcAtoQrFLR5EJEMDjR5Ea4GuzpdDmMpPWAgrF1DmL4kD1NPNVlpXEOxpFJnD6lPvLKYj1lzdlrfifA=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-wPIeeigXgJpwNw3wydYRt3U9iN9Y/ejpOZuYL9IA7igxWs7LIQMOkhKxTumRvy6dIv0iXKk3RTw3Vmjg0i+2sg=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.72.0", "", { "os": "linux", "cpu": "arm" }, "sha512-InVitdHbS09PM+w+WinIiyX37rC9R3L7lUP9mKy9uKTEDSUFaLjWsLoreRIxKivnv7Ndqc6Trvi+K+b6ZzhiJg=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0+XNxcdbkTfxdcD4qW6Ci9n+mBNJ8xTBumnxKvKBmRFOdx0Wf8/KiHjCJayooXmYkqRpRVd98Q5egvzx5BLSgQ=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.72.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKjlyRAxpPjXoqeuwFBim5LscgvY+RNEiZ971C9JlRQZONhJTsXDki/oHFYQE+fzzRpzXh3owZvOdT3kNL2g+g=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-I75LKPJyNOYUzkqAiAMIE31+Ye7xtQXZdoty1IXn4B+bw5Zpmez5wfG19ejGpNnS/BzQ7LFS+7jxuTPb+vHiZw=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.72.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-poafnbsPpJhR+5Mbwpf8CscugTyiRAzjTOxRVn8ra3X2jnURs2xL5Du2xl5g2iW85lICMglpmLf8jDjZEbNgzQ=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.67.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-c2M5iRpe1QMZSRE/UvZoPdXBWb5Ic/ycvOyNiKCqPwQ/OyOKIMiJs02ynlNnjb7ZZJnRXYLmGcohoINOcwDK3w=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.72.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zNUi0gKRsO7S3IfPzsTbnm88OedPuX32hmZFWZRWHvWJlAoZRwVbeO6Nb3hng4vDgL7qUKp4TGqzPz1mhsycYg=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-dQzzYlV24Udhfm5ECuSdgqRvFJU/CGHzcYYEO3dLM6W6+CHiBFrq9OjIllkdCcPhsoSQ8o223Dja84MOSzed9A=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.72.0", "", { "os": "linux", "cpu": "none" }, "sha512-qTY7z/iyF/rWI2HWwWMevXqZW5PZUOofUy2afK7v6rseXnpld3yjgyIk6pCux3DqWQ0lB+f5atvLJNBwzLvFsw=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-rFNq1CgX4qMJANOq42LkAs90JE80GpiaEohAV2qn/gT2hGjQTW1zBO5zQBxArI4926pM1OSzo3CN0tBszGBIaA=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.72.0", "", { "os": "linux", "cpu": "none" }, "sha512-w18qfo8vEMD6EKEHtzTvjc6jH8yCyXIT1Bn7PFWJd0PpZyzH/VQDx4xvjCh0LGz6GRzqiezbisTdG9AoXtpkZQ=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.67.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Sky6rEdz2o5IGq01lPhS12yEvDdChVEcaYrcLHkveh4Fx0qPjljE/Iul6SX/bRMl6lNc8J7J/mDQdzgBdA++Pg=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.72.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-grW8HwEir+vK7fCcq1ZgL3NHQCfZ1PidiERE9Hbf+24xGys+DoDaoqTht4ccfn0PcBwbViLkwkda/KNpJu2KRQ=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vPXmlNORV8AZq2Ocxh07pxwMjfENUWCV/eZArnao0qC3NO/hDeTVkQvee7SJJUbIiF5PZbBa4kYmaXnu7Rk58w=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.72.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wQJRQfWBRIn88Pkr449EUViwRayrYUScg7ty+Ihiem21mQ9BUA6S9e/4RAgyNk9lMag0KBouyKskjq61rOC0WQ=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-x/WAtFqYtVr3vZ9ni8nr4kn9whSitg8fOljq/pZzBpxopRdY1BMLZCZkrbIbaBcYkm46qGbqVea2FCWmtQ2P9w=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.72.0", "", { "os": "linux", "cpu": "x64" }, "sha512-T1B0uQzafxuGPjjaPrZJ497nxoi1BiI65JfsYc56nNpmev3msfb/iaTmH2kQuNFqa8PKxpMlf3GhWrWBvBPORQ=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.67.0", "", { "os": "none", "cpu": "arm64" }, "sha512-eRw9Neh4/aA6i+q/R3WU1gGQINhVM0J4fXIm6t27caOamkr/37uAkp1IdBx4zlJH97hmXR63z/q9n5c5dN7MzA=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.72.0", "", { "os": "none", "cpu": "arm64" }, "sha512-OKxxgTurl7+hypVplO8QRAbbQlwEs0j1Y6FZv+ZN2qNbdxQk2muMlzuJNBGTCWwxQ0rkkOD1uK/DW1fvzoUSJA=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.67.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-YIMvb+sGNYN2uc6+QK2HLPeEKM2vl7QZ5onQzpAJRb6pnf0DwUFP5R8tdS9R0l8hdUil2gu4Uxd0Yxrop0iT4w=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.72.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-L78TKzURqvjuZOCo88ixMbPXf/RP7u31Y0cmjJ/KEmrHmfYhmhEvsDaTYPiyTRne64jUgCbADfY0YeoQk4/Xyw=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.67.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-LzmU9MyACPzwNDIK0ItMedHPz735Ug7ELWguxo4/kuy6zWuDoeglOAEFCY8jLg0PzRpFO3hDyLFe2Gu2eFDeGA=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.72.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-gQtFW+Ii8BWgtJTAD17TY/WsdNS5gUfAPov8iI1jeq4r9lRRYImmxPL08UU00/RLaCfBJwh2UgINnDY0iOiZ+A=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sbQOIDNLUEeVZcAJcSL5VURn7kfjvilPviody4Yl5n8lQCDtUm+C9oHTTwZS/m4d/Z6Vv3jNEiAofH932NPPCg=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.72.0", "", { "os": "win32", "cpu": "x64" }, "sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.82.0", "", { "os": "android", "cpu": "arm" }, "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.87.0", "", { "os": "android", "cpu": "arm" }, "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.82.0", "", { "os": "android", "cpu": "arm64" }, "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.87.0", "", { "os": "android", "cpu": "arm64" }, "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.82.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.87.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.82.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.87.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.82.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.87.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.87.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.87.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.82.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.87.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.82.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.87.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.82.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.87.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.82.0", "", { "os": "linux", "cpu": "none" }, "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.87.0", "", { "os": "linux", "cpu": "none" }, "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.82.0", "", { "os": "linux", "cpu": "none" }, "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.87.0", "", { "os": "linux", "cpu": "none" }, "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.82.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.87.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.82.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.87.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.82.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.87.0", "", { "os": "linux", "cpu": "x64" }, "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.82.0", "", { "os": "none", "cpu": "arm64" }, "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.87.0", "", { "os": "none", "cpu": "arm64" }, "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.82.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.87.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.82.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.87.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.82.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.87.0", "", { "os": "win32", "cpu": "x64" }, "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@solidjs/signals": ["@solidjs/signals@2.0.0-rc.13", "", {}, "sha512-4+pRdrAHtfyE9BUJWBup3TpzJojJjgW2mV1vm/Jik4tWa5epxXB/YrLkwqP1v8+S9XjyKKZu5BSLqmcpswMYeQ=="], - "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], @@ -218,7 +218,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="], - "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], @@ -230,7 +230,7 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "effect": ["effect@3.22.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ=="], + "effect": ["effect@4.0.1", "", {}, "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw=="], "electron-to-chromium": ["electron-to-chromium@1.5.413", "", {}, "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw=="], @@ -242,8 +242,6 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], - "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], @@ -288,9 +286,9 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], - "oxfmt": ["oxfmt@0.67.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.67.0", "@oxfmt/binding-android-arm64": "0.67.0", "@oxfmt/binding-darwin-arm64": "0.67.0", "@oxfmt/binding-darwin-x64": "0.67.0", "@oxfmt/binding-freebsd-x64": "0.67.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.67.0", "@oxfmt/binding-linux-arm-musleabihf": "0.67.0", "@oxfmt/binding-linux-arm64-gnu": "0.67.0", "@oxfmt/binding-linux-arm64-musl": "0.67.0", "@oxfmt/binding-linux-ppc64-gnu": "0.67.0", "@oxfmt/binding-linux-riscv64-gnu": "0.67.0", "@oxfmt/binding-linux-riscv64-musl": "0.67.0", "@oxfmt/binding-linux-s390x-gnu": "0.67.0", "@oxfmt/binding-linux-x64-gnu": "0.67.0", "@oxfmt/binding-linux-x64-musl": "0.67.0", "@oxfmt/binding-openharmony-arm64": "0.67.0", "@oxfmt/binding-win32-arm64-msvc": "0.67.0", "@oxfmt/binding-win32-ia32-msvc": "0.67.0", "@oxfmt/binding-win32-x64-msvc": "0.67.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-vV7sSiPsaO0mSxdoUdayipVDFPzW/UQ+hrezEHa20+Tx1dnMdZLSRHMT0PdS67FFbhd74M1n08asW21aLGeCrA=="], + "oxfmt": ["oxfmt@0.72.0", "", { "dependencies": { "tinypool": "2.2.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.72.0", "@oxfmt/binding-android-arm64": "0.72.0", "@oxfmt/binding-darwin-arm64": "0.72.0", "@oxfmt/binding-darwin-x64": "0.72.0", "@oxfmt/binding-freebsd-x64": "0.72.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", "@oxfmt/binding-linux-arm64-gnu": "0.72.0", "@oxfmt/binding-linux-arm64-musl": "0.72.0", "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", "@oxfmt/binding-linux-riscv64-musl": "0.72.0", "@oxfmt/binding-linux-s390x-gnu": "0.72.0", "@oxfmt/binding-linux-x64-gnu": "0.72.0", "@oxfmt/binding-linux-x64-musl": "0.72.0", "@oxfmt/binding-openharmony-arm64": "0.72.0", "@oxfmt/binding-win32-arm64-msvc": "0.72.0", "@oxfmt/binding-win32-ia32-msvc": "0.72.0", "@oxfmt/binding-win32-x64-msvc": "0.72.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g=="], - "oxlint": ["oxlint@1.82.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.82.0", "@oxlint/binding-android-arm64": "1.82.0", "@oxlint/binding-darwin-arm64": "1.82.0", "@oxlint/binding-darwin-x64": "1.82.0", "@oxlint/binding-freebsd-x64": "1.82.0", "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", "@oxlint/binding-linux-arm-musleabihf": "1.82.0", "@oxlint/binding-linux-arm64-gnu": "1.82.0", "@oxlint/binding-linux-arm64-musl": "1.82.0", "@oxlint/binding-linux-ppc64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-musl": "1.82.0", "@oxlint/binding-linux-s390x-gnu": "1.82.0", "@oxlint/binding-linux-x64-gnu": "1.82.0", "@oxlint/binding-linux-x64-musl": "1.82.0", "@oxlint/binding-openharmony-arm64": "1.82.0", "@oxlint/binding-win32-arm64-msvc": "1.82.0", "@oxlint/binding-win32-ia32-msvc": "1.82.0", "@oxlint/binding-win32-x64-msvc": "1.82.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ=="], + "oxlint": ["oxlint@1.87.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.87.0", "@oxlint/binding-android-arm64": "1.87.0", "@oxlint/binding-darwin-arm64": "1.87.0", "@oxlint/binding-darwin-x64": "1.87.0", "@oxlint/binding-freebsd-x64": "1.87.0", "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", "@oxlint/binding-linux-arm-musleabihf": "1.87.0", "@oxlint/binding-linux-arm64-gnu": "1.87.0", "@oxlint/binding-linux-arm64-musl": "1.87.0", "@oxlint/binding-linux-ppc64-gnu": "1.87.0", "@oxlint/binding-linux-riscv64-gnu": "1.87.0", "@oxlint/binding-linux-riscv64-musl": "1.87.0", "@oxlint/binding-linux-s390x-gnu": "1.87.0", "@oxlint/binding-linux-x64-gnu": "1.87.0", "@oxlint/binding-linux-x64-musl": "1.87.0", "@oxlint/binding-openharmony-arm64": "1.87.0", "@oxlint/binding-win32-arm64-msvc": "1.87.0", "@oxlint/binding-win32-ia32-msvc": "1.87.0", "@oxlint/binding-win32-x64-msvc": "1.87.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg=="], "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -310,8 +308,6 @@ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], - "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], @@ -322,11 +318,11 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], + "seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="], - "seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], + "seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], - "solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="], + "solid-js": ["solid-js@2.0.0-rc.13", "", { "dependencies": { "@solidjs/signals": "^2.0.0-rc.13", "csstype": "^3.1.0", "seroval": "~1.6.7", "seroval-plugins": "~1.6.7" } }, "sha512-62bYOI4JZ15KOqL5eReKyWSwAXrGb0fbX8SDnHsbJk2UX+SzSyy1gobas2cxW/0BXcobGfQ1V6ffPyQkMIBdoQ=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -334,7 +330,7 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "tinypool": ["tinypool@2.1.2", "", {}, "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww=="], + "tinypool": ["tinypool@2.2.0", "", {}, "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/justfile b/justfile index 17a56e5..efe34c5 100644 --- a/justfile +++ b/justfile @@ -46,6 +46,10 @@ fmt: bun run format alias f := fmt +# Check formatting without writing +fmt-check: + bunx oxfmt --check . + # Clean build artifacts clean: rm -rf node_modules/.cache diff --git a/package.json b/package.json index 4ba06c6..cb4328a 100644 --- a/package.json +++ b/package.json @@ -14,24 +14,26 @@ "build": "bun scripts/build.ts" }, "dependencies": { - "@effect/schema": "^0.75.5", - "@opentui/core": "^0.5.11", - "@opentui/solid": "^0.5.11", - "effect": "^3.22.1", + "@opentui/core": "0.5.14", + "@opentui/solid": "0.5.14", + "effect": "4.0.1", "node-id3": "^0.2.9", - "solid-js": "^1.9.15" + "solid-js": "2.0.0-rc.13" }, "devDependencies": { - "@effect/language-service": "^0.87.2", - "@types/bun": "^1.4.1", - "oxfmt": "^0.67.0", - "oxlint": "^1.82.0" + "@effect/language-service": "^0.87.4", + "@types/bun": "^1.4.2", + "oxfmt": "^0.72.0", + "oxlint": "^1.87.0" }, "peerDependencies": { "typescript": "^6.0.3" }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "^0.5.11", - "@opentui/core-darwin-x64": "^0.5.11" + "@opentui/core-darwin-arm64": "0.5.14", + "@opentui/core-darwin-x64": "0.5.14" + }, + "patchedDependencies": { + "@opentui/solid@0.5.14": "patches/@opentui%2Fsolid@0.5.14.patch" } } diff --git a/patches/@opentui%2Fsolid@0.5.14.patch b/patches/@opentui%2Fsolid@0.5.14.patch new file mode 100644 index 0000000..9af68c0 --- /dev/null +++ b/patches/@opentui%2Fsolid@0.5.14.patch @@ -0,0 +1,582 @@ +diff --git a/index.bun.js b/index.bun.js +index a68be404614cb4811e16e439325b9f46e39653ef..c54b5407889b9ac581aa94b2041a0f1f6c8a4784 100644 +--- a/index.bun.js ++++ b/index.bun.js +@@ -117,7 +117,7 @@ import { + engine, + Timeline + } from "@opentui/core"; +-import { createContext, createSignal, onCleanup, onMount, useContext } from "solid-js"; ++import { createContext, createSignal, onSettled, useContext } from "solid-js"; + var RendererContext = createContext(); + var useRenderer = () => { + const renderer = useContext(RendererContext); +@@ -128,11 +128,9 @@ var useRenderer = () => { + }; + var onResize = (callback) => { + const renderer = useRenderer(); +- onMount(() => { ++ onSettled(() => { + renderer.on("resize", callback); +- }); +- onCleanup(() => { +- renderer.off("resize", callback); ++ return () => renderer.off("resize", callback); + }); + }; + var useTerminalDimensions = () => { +@@ -147,73 +145,65 @@ var useTerminalDimensions = () => { + var useKeyboard = (callback, options) => { + const renderer = useRenderer(); + const keyHandler = renderer.keyInput; +- onMount(() => { ++ onSettled(() => { + keyHandler.on("keypress", callback); + if (options?.release) { + keyHandler.on("keyrelease", callback); + } +- }); +- onCleanup(() => { +- keyHandler.off("keypress", callback); +- if (options?.release) { +- keyHandler.off("keyrelease", callback); +- } ++ return () => { ++ keyHandler.off("keypress", callback); ++ if (options?.release) { ++ keyHandler.off("keyrelease", callback); ++ } ++ }; + }); + }; + var usePaste = (callback) => { + const renderer = useRenderer(); + const keyHandler = renderer.keyInput; +- onMount(() => { ++ onSettled(() => { + keyHandler.on("paste", callback); +- }); +- onCleanup(() => { +- keyHandler.off("paste", callback); ++ return () => keyHandler.off("paste", callback); + }); + }; + var useKeyHandler = useKeyboard; + var onFocus = (callback) => { + const renderer = useRenderer(); +- onMount(() => { ++ onSettled(() => { + renderer.on("focus", callback); +- }); +- onCleanup(() => { +- renderer.off("focus", callback); ++ return () => renderer.off("focus", callback); + }); + }; + var onBlur = (callback) => { + const renderer = useRenderer(); +- onMount(() => { ++ onSettled(() => { + renderer.on("blur", callback); +- }); +- onCleanup(() => { +- renderer.off("blur", callback); ++ return () => renderer.off("blur", callback); + }); + }; + var useSelectionHandler = (callback) => { + const renderer = useRenderer(); +- onMount(() => { ++ onSettled(() => { + renderer.on("selection", callback); +- }); +- onCleanup(() => { +- renderer.off("selection", callback); ++ return () => renderer.off("selection", callback); + }); + }; + var useTimeline = (options = {}) => { + const timeline = new Timeline(options); +- onMount(() => { ++ onSettled(() => { + if (options.autoplay !== false) { + timeline.play(); + } + engine.register(timeline); +- }); +- onCleanup(() => { +- timeline.pause(); +- engine.unregister(timeline); ++ return () => { ++ timeline.pause(); ++ engine.unregister(timeline); ++ }; + }); + return timeline; + }; + // src/elements/extras.ts +-import { createEffect, createMemo as createMemo2, getOwner, onCleanup as onCleanup3, runWithOwner, splitProps, untrack as untrack2 } from "solid-js"; ++import { createMemo as createMemo2, createRenderEffect as createRenderEffect2, getOwner, runWithOwner, untrack as untrack2 } from "solid-js"; + + // src/reconciler.ts + import { +@@ -239,7 +229,47 @@ import { decodeHTMLStrict } from "entities"; + import { onCleanup as onCleanup2, useContext as useContext2 } from "solid-js"; + + // src/renderer/universal.js +-import { createRoot, createRenderEffect, createMemo, createComponent, untrack, mergeProps } from "solid-js"; ++import { createRoot, createTrackedEffect, createMemo, createComponent, untrack } from "solid-js"; ++ ++// Solid 2 removed these convenience helpers from the public runtime. Keep the ++// renderer's legacy API surface while preserving reactive property access. ++function mergeProps(...sources) { ++ const result = {}; ++ for (const source of sources) { ++ if (!source) continue; ++ for (const key of Reflect.ownKeys(source)) { ++ Object.defineProperty(result, key, { ++ configurable: true, ++ enumerable: true, ++ get: () => source[key] ++ }); ++ } ++ } ++ return result; ++} ++function splitProps(props, keys) { ++ const selected = {}; ++ const rest = {}; ++ const selectedKeys = new Set(keys); ++ for (const key of Reflect.ownKeys(props)) { ++ const target = selectedKeys.has(key) ? selected : rest; ++ Object.defineProperty(target, key, { ++ configurable: true, ++ enumerable: true, ++ get: () => props[key] ++ }); ++ } ++ return [selected, rest]; ++} ++// OpenTUI's bundled reconciler still calls this with Solid 1's ++// `(compute, previousValue)` signature. Keep that adapter isolated here until ++// OpenTUI ships a Solid 2-native renderer. ++function createRenderEffect(compute, initial) { ++ let previous = initial; ++ return createTrackedEffect(() => { ++ previous = compute(previous); ++ }); ++} + var memo = (fn) => createMemo(() => fn()); + function createRenderer({ + createElement, +@@ -499,10 +529,9 @@ function createRenderer({ + } + + // src/renderer/index.ts +-import { mergeProps as mergeProps2 } from "solid-js"; + function createRenderer2(options) { + const renderer = createRenderer(options); +- renderer.mergeProps = mergeProps2; ++ renderer.mergeProps = mergeProps; + return renderer; + } + +@@ -843,28 +872,32 @@ function Portal(props) { + const renderer = useRenderer(); + const marker = createSlotNode(), mount = () => props.mount || renderer.root, owner = getOwner(); + let content; +- createEffect(() => { +- content || (content = runWithOwner(owner, () => createMemo2(() => props.children))); +- const el = mount(); +- const container = createElement("box"), renderRoot = container; +- Object.defineProperty(container, "_$host", { +- get() { +- return marker.parent; +- }, +- configurable: true +- }); +- insert(renderRoot, content); +- el.add(container); +- props.ref && props.ref(container); +- onCleanup3(() => { +- el.remove(container); +- process.nextTick(() => { +- if (!container.parent) { +- container.destroyRecursively(); +- } ++ createRenderEffect2( ++ () => props.children, ++ () => { ++ content || (content = runWithOwner(owner, () => createMemo2(() => props.children))); ++ const el = mount(); ++ const container = createElement("box"), renderRoot = container; ++ Object.defineProperty(container, "_$host", { ++ get() { ++ return marker.parent; ++ }, ++ configurable: true + }); +- }); +- }, undefined, { render: true }); ++ insert(renderRoot, content); ++ el.add(container); ++ props.ref && props.ref(container); ++ return () => { ++ el.remove(container); ++ process.nextTick(() => { ++ if (!container.parent) { ++ container.destroyRecursively(); ++ } ++ }); ++ }; ++ }, ++ { render: true } ++ ); + return marker; + } + function createDynamic(component, props) { +@@ -1310,7 +1343,7 @@ function createScrollbackWriter(node, options = {}) { + dispose?.(); + }; + try { +- dispose = _render(() => createComponent2(RendererContext.Provider, { ++ dispose = _render(() => createComponent2(RendererContext, { + get value() { + return snapshotRenderer.renderer; + }, +@@ -1351,7 +1384,7 @@ var TimeToFirstDraw = (props) => { + }; + // src/plugins/slot.tsx + import { createSlotRegistry } from "@opentui/core"; +-import { children, createMemo as createMemo3, createSignal as createSignal3, ErrorBoundary, For, onCleanup as onCleanup4, splitProps as splitProps2 } from "solid-js"; ++import { children, createMemo as createMemo3, createSignal as createSignal3, Errored, For, onCleanup as onCleanup4 } from "solid-js"; + var EMPTY_ENTRY_IDS = []; + function createSolidSlotRegistry(renderer, context, options = {}) { + return createSlotRegistry(renderer, "solid:slot-registry", context, options); +@@ -1367,7 +1400,7 @@ function createSlot(registry, options = {}) { + }; + } + function Slot(props) { +- const [local, slotProps] = splitProps2(props, ["registry", "name", "mode", "children", "pluginFailurePlaceholder"]); ++ const [local, slotProps] = splitProps(props, ["registry", "name", "mode", "children", "pluginFailurePlaceholder"]); + const registry = () => local.registry; + const pluginFailurePlaceholder = () => local.pluginFailurePlaceholder; + const [version, setVersion] = createSignal3(0); +@@ -1453,14 +1486,14 @@ function Slot(props) { + if (!hasInitialOutput) { + return resolveFallback(fallbackOnError); + } +- return createComponent2(ErrorBoundary, { ++ return createComponent2(Errored, { + fallback: (error) => { + const failure = registry().reportPluginError({ + pluginId: entry.id, + slot: slotName(), + phase: "render", + source: "solid", +- error ++ error: error() + }); + return renderPluginFailurePlaceholder(failure, fallbackOnError); + }, +@@ -1547,7 +1580,7 @@ var mountSolidRoot = (renderer, node) => { + originalDestroy(); + }; + try { +- dispose = _render(() => createComponent2(RendererContext.Provider, { ++ dispose = _render(() => createComponent2(RendererContext, { + get value() { + return renderer; + }, +diff --git a/index.js b/index.js +index 61e64c1a01e23da4243db0a45abef2ebddd246f0..4d28aa3f93115b4a68143033d804ca2d13b8db9a 100644 +--- a/index.js ++++ b/index.js +@@ -100,7 +100,7 @@ function getComponentCatalogue() { + } + // src/elements/hooks.ts + import { engine, Timeline } from "@opentui/core"; +-import { createContext, createSignal, onCleanup, onMount, useContext } from "solid-js/dist/solid.js"; ++import { createContext, createSignal, onSettled, useContext } from "solid-js/dist/solid.js"; + var RendererContext = createContext(); + var useRenderer = () => { + const renderer = useContext(RendererContext); +@@ -111,11 +111,9 @@ var useRenderer = () => { + }; + var onResize = (callback) => { + const renderer = useRenderer(); +- onMount(() => { ++ onSettled(() => { + renderer.on("resize", callback); +- }); +- onCleanup(() => { +- renderer.off("resize", callback); ++ return () => renderer.off("resize", callback); + }); + }; + var useTerminalDimensions = () => { +@@ -136,73 +134,65 @@ var useTerminalDimensions = () => { + var useKeyboard = (callback, options) => { + const renderer = useRenderer(); + const keyHandler = renderer.keyInput; +- onMount(() => { ++ onSettled(() => { + keyHandler.on("keypress", callback); + if (options?.release) { + keyHandler.on("keyrelease", callback); + } +- }); +- onCleanup(() => { +- keyHandler.off("keypress", callback); +- if (options?.release) { +- keyHandler.off("keyrelease", callback); +- } ++ return () => { ++ keyHandler.off("keypress", callback); ++ if (options?.release) { ++ keyHandler.off("keyrelease", callback); ++ } ++ }; + }); + }; + var usePaste = (callback) => { + const renderer = useRenderer(); + const keyHandler = renderer.keyInput; +- onMount(() => { ++ onSettled(() => { + keyHandler.on("paste", callback); +- }); +- onCleanup(() => { +- keyHandler.off("paste", callback); ++ return () => keyHandler.off("paste", callback); + }); + }; + var useKeyHandler = useKeyboard; + var onFocus = (callback) => { + const renderer = useRenderer(); +- onMount(() => { ++ onSettled(() => { + renderer.on("focus", callback); +- }); +- onCleanup(() => { +- renderer.off("focus", callback); ++ return () => renderer.off("focus", callback); + }); + }; + var onBlur = (callback) => { + const renderer = useRenderer(); +- onMount(() => { ++ onSettled(() => { + renderer.on("blur", callback); +- }); +- onCleanup(() => { +- renderer.off("blur", callback); ++ return () => renderer.off("blur", callback); + }); + }; + var useSelectionHandler = (callback) => { + const renderer = useRenderer(); +- onMount(() => { ++ onSettled(() => { + renderer.on("selection", callback); +- }); +- onCleanup(() => { +- renderer.off("selection", callback); ++ return () => renderer.off("selection", callback); + }); + }; + var useTimeline = (options = {}) => { + const timeline = new Timeline(options); +- onMount(() => { ++ onSettled(() => { + if (options.autoplay !== false) { + timeline.play(); + } + engine.register(timeline); +- }); +- onCleanup(() => { +- timeline.pause(); +- engine.unregister(timeline); ++ return () => { ++ timeline.pause(); ++ engine.unregister(timeline); ++ }; + }); + return timeline; + }; + // src/elements/extras.ts +-import { createEffect, createMemo as createMemo2, getOwner, onCleanup as onCleanup3, runWithOwner, splitProps, untrack as untrack2 } from "solid-js/dist/solid.js"; ++import { createMemo as createMemo2, createRenderEffect as createRenderEffect2, getOwner, runWithOwner, untrack as untrack2 } from "solid-js/dist/solid.js"; + + // src/reconciler.ts + import { BaseRenderable, createTextAttributes, ImageRenderable as ImageRenderable2, InputRenderable as InputRenderable2, InputRenderableEvents, isTextNodeRenderable, isEditBufferRenderable, parseColor, Renderable, RootTextNodeRenderable, ScrollBoxRenderable as ScrollBoxRenderable2, SelectRenderable as SelectRenderable2, SelectRenderableEvents, TabSelectRenderable as TabSelectRenderable2, TabSelectRenderableEvents, TextNodeRenderable as TextNodeRenderable2, TextRenderable as TextRenderable2 } from "@opentui/core"; +@@ -210,7 +200,47 @@ import { decodeHTMLStrict } from "entities"; + import { onCleanup as onCleanup2, useContext as useContext2 } from "solid-js/dist/solid.js"; + + // src/renderer/universal.js +-import { createRoot, createRenderEffect, createMemo, createComponent, untrack, mergeProps } from "solid-js/dist/solid.js"; ++import { createRoot, createTrackedEffect, createMemo, createComponent, untrack } from "solid-js/dist/solid.js"; ++ ++// Solid 2 removed these convenience helpers from the public runtime. Keep the ++// renderer's legacy API surface while preserving reactive property access. ++function mergeProps(...sources) { ++ const result = {}; ++ for (const source of sources) { ++ if (!source) continue; ++ for (const key of Reflect.ownKeys(source)) { ++ Object.defineProperty(result, key, { ++ configurable: true, ++ enumerable: true, ++ get: () => source[key] ++ }); ++ } ++ } ++ return result; ++} ++function splitProps(props, keys) { ++ const selected = {}; ++ const rest = {}; ++ const selectedKeys = new Set(keys); ++ for (const key of Reflect.ownKeys(props)) { ++ const target = selectedKeys.has(key) ? selected : rest; ++ Object.defineProperty(target, key, { ++ configurable: true, ++ enumerable: true, ++ get: () => props[key] ++ }); ++ } ++ return [selected, rest]; ++} ++// OpenTUI's bundled reconciler still calls this with Solid 1's ++// `(compute, previousValue)` signature. Keep that adapter isolated here until ++// OpenTUI ships a Solid 2-native renderer. ++function createRenderEffect(compute, initial) { ++ let previous = initial; ++ return createTrackedEffect(() => { ++ previous = compute(previous); ++ }); ++} + var memo = (fn) => createMemo(() => fn()); + function createRenderer({ + createElement, +@@ -470,10 +500,9 @@ function createRenderer({ + } + + // src/renderer/index.ts +-import { mergeProps as mergeProps2 } from "solid-js/dist/solid.js"; + function createRenderer2(options) { + const renderer = createRenderer(options); +- renderer.mergeProps = mergeProps2; ++ renderer.mergeProps = mergeProps; + return renderer; + } + +@@ -820,30 +849,32 @@ function Portal(props) { + const renderer = useRenderer(); + const marker = createSlotNode(), mount = () => props.mount || renderer.root, owner = getOwner(); + let content; +- createEffect(() => { +- content || (content = runWithOwner(owner, () => createMemo2(() => props.children))); +- const el = mount(); +- const container = createElement("box"), renderRoot = container; +- Object.defineProperty(container, "_$host", { +- get() { +- return marker.parent; +- }, +- configurable: true +- }); +- insert(renderRoot, content); +- el.add(container); +- props.ref && props.ref(container); +- onCleanup3(() => { +- el.remove(container); +- process.nextTick(() => { +- if (!container.parent) { +- container.destroyRecursively(); +- } ++ createRenderEffect2( ++ () => props.children, ++ () => { ++ content || (content = runWithOwner(owner, () => createMemo2(() => props.children))); ++ const el = mount(); ++ const container = createElement("box"), renderRoot = container; ++ Object.defineProperty(container, "_$host", { ++ get() { ++ return marker.parent; ++ }, ++ configurable: true + }); +- }); +- }, undefined, { +- render: true +- }); ++ insert(renderRoot, content); ++ el.add(container); ++ props.ref && props.ref(container); ++ return () => { ++ el.remove(container); ++ process.nextTick(() => { ++ if (!container.parent) { ++ container.destroyRecursively(); ++ } ++ }); ++ }; ++ }, ++ { render: true } ++ ); + return marker; + } + function createDynamic(component, props) { +@@ -1284,7 +1315,7 @@ function createScrollbackWriter(node, options = {}) { + dispose?.(); + }; + try { +- dispose = _render(() => createComponent2(RendererContext.Provider, { ++ dispose = _render(() => createComponent2(RendererContext, { + get value() { + return snapshotRenderer.renderer; + }, +@@ -1325,7 +1356,7 @@ var TimeToFirstDraw = (props) => { + }; + // src/plugins/slot.tsx + import { createSlotRegistry } from "@opentui/core"; +-import { children, createMemo as createMemo3, createSignal as createSignal3, ErrorBoundary, For, onCleanup as onCleanup4, splitProps as splitProps2 } from "solid-js/dist/solid.js"; ++import { children, createMemo as createMemo3, createSignal as createSignal3, Errored, For, onCleanup as onCleanup4 } from "solid-js/dist/solid.js"; + var EMPTY_ENTRY_IDS = []; + function createSolidSlotRegistry(renderer, context, options = {}) { + return createSlotRegistry(renderer, "solid:slot-registry", context, options); +@@ -1341,7 +1372,7 @@ function createSlot(registry, options = {}) { + }; + } + function Slot(props) { +- const [local, slotProps] = splitProps2(props, ["registry", "name", "mode", "children", "pluginFailurePlaceholder"]); ++ const [local, slotProps] = splitProps(props, ["registry", "name", "mode", "children", "pluginFailurePlaceholder"]); + const registry = () => local.registry; + const pluginFailurePlaceholder = () => local.pluginFailurePlaceholder; + const [version, setVersion] = createSignal3(0); +@@ -1427,14 +1458,14 @@ function Slot(props) { + if (!hasInitialOutput) { + return resolveFallback(fallbackOnError); + } +- return createComponent2(ErrorBoundary, { ++ return createComponent2(Errored, { + fallback: (error) => { + const failure = registry().reportPluginError({ + pluginId: entry.id, + slot: slotName(), + phase: "render", + source: "solid", +- error ++ error: error() + }); + return renderPluginFailurePlaceholder(failure, fallbackOnError); + }, +@@ -1521,7 +1552,7 @@ var mountSolidRoot = (renderer, node) => { + originalDestroy(); + }; + try { +- dispose = _render(() => createComponent2(RendererContext.Provider, { ++ dispose = _render(() => createComponent2(RendererContext, { + get value() { + return renderer; + }, diff --git a/src/__tests__/drive_matching.test.ts b/src/__tests__/drive_matching.test.ts new file mode 100644 index 0000000..812ddbf --- /dev/null +++ b/src/__tests__/drive_matching.test.ts @@ -0,0 +1,220 @@ +/** + * Regression tests: episodes are matched to files on the drive by show + publish + * date + title, not title alone. Exercises the real DriveScan, EpisodeMatcher and + * SyncEngine.createPlan against a temp directory. + */ + +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Layer } from "effect"; +import { DriveScan, DriveScanLive } from "@/services/effects/DriveScan"; +import { EpisodeMatcher, EpisodeMatcherLive } from "@/services/effects/EpisodeMatcher"; +import { FileSystemLive } from "@/services/effects/FileSystem"; +import { LoggerLive } from "@/services/effects/Logger"; +import { createMetadataEditorTest } from "@/services/effects/MetadataEditor"; +import { SyncEngine, SyncEngineLive } from "@/services/effects/SyncEngine"; +import type { Episode, Podcast } from "@/types/podcast"; +import { formatDate, markEpisodesOnDrive, mapPodcastsToEpisodes } from "@/utils/formatting"; + +const root = await mkdtemp(join(tmpdir(), "podapple-match-")); +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +let counter = 0; +/** Creates a fake drive with the given files under Podcasts/ and a Mac source dir. */ +const makeDrive = async (files: string[]) => { + const dir = join(root, `case-${counter++}`); + const drive = join(dir, "drive"); + const mac = join(dir, "mac"); + await mkdir(mac, { recursive: true }); + for (const f of files) { + await Bun.write(join(drive, "Podcasts", f), new Uint8Array([1, 2, 3])); + } + return { drive, mac }; +}; + +const layer = Layer.mergeAll( + DriveScanLive, + EpisodeMatcherLive, + FileSystemLive, + SyncEngineLive, + createMetadataEditorTest(), +).pipe(Layer.provideMerge(LoggerLive)); + +const localDate = (y: number, m: number, d: number) => new Date(y, m - 1, d, 9, 30); + +const macEpisode = async ( + mac: string, + id: string, + title: string, + published: Date, +): Promise => { + const filePath = join(mac, `${id}.mp3`); + await Bun.write(filePath, new Uint8Array([9, 9, 9, 9])); + return { + id, + title, + author: "Author", + duration: 0, + published, + onDrive: false, + filePath, + fileSize: 4, + }; +}; + +const podcastOf = (title: string, episodes: Episode[]): Podcast => ({ + id: title, + title, + author: "Author", + episodeCount: episodes.length, + episodes, +}); + +/** Scans the drive, builds the index and plans a sync of `podcasts`. */ +const plan = (drive: string, podcasts: Podcast[]) => + Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const index = yield* (yield* DriveScan).buildDriveIndex(drive); + return yield* (yield* SyncEngine).createPlan(podcasts, drive, index); + }), + layer, + ), + ); + +describe("drive matching includes the publish date", () => { + it("same title, different date: the newer episode is not on the drive and is planned", async () => { + const { drive, mac } = await makeDrive(["Show/2024-01-01 - Trailer.mp3"]); + const older = await macEpisode(mac, "old", "Trailer", localDate(2024, 1, 1)); + const newer = await macEpisode(mac, "new", "Trailer", localDate(2024, 6, 15)); + + const result = await plan(drive, [podcastOf("Show", [older, newer])]); + expect(result.toCopy.map((c) => c.episode.id)).toEqual(["new"]); + expect(result.toCopy[0]?.destPath).toBe(`${drive}/Podcasts/Show/2024-06-15 - Trailer.mp3`); + }); + + it("same title, different date: only the matching date is marked onDrive", async () => { + const { drive, mac } = await makeDrive(["Show/2024-01-01 - Trailer.mp3"]); + const older = await macEpisode(mac, "old", "Trailer", localDate(2024, 1, 1)); + const newer = await macEpisode(mac, "new", "Trailer", localDate(2024, 6, 15)); + + const scanned = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return yield* (yield* DriveScan).scanDrive(drive); + }), + layer, + ), + ); + const driveEpisodes = mapPodcastsToEpisodes(scanned).map((e) => ({ + ...e, + showName: "Show", + })); + const marked = markEpisodesOnDrive( + [older, newer].map((e) => ({ ...e, showName: "Show", selected: false })), + driveEpisodes, + ); + expect(marked.map((e) => [e.id, e.onDrive])).toEqual([ + ["old", true], + ["new", false], + ]); + }); + + it("same title and same date: matched, nothing planned", async () => { + const { drive, mac } = await makeDrive(["Show/2024-01-01 - Trailer.mp3"]); + const ep = await macEpisode(mac, "a", "Trailer", localDate(2024, 1, 1)); + + const result = await plan(drive, [podcastOf("Show", [ep])]); + expect(result.toCopy).toHaveLength(0); + }); + + it("a '(2)' deduped file still matches its episode", async () => { + const { drive, mac } = await makeDrive([ + "Show/2024-01-01 - Trailer.mp3", + "Show/2024-03-03 - Bonus (2).mp3", + ]); + // The "(2)" copy exists on the drive, the undeduped base name does not. + const bonus = await macEpisode(mac, "b", "Bonus", localDate(2024, 3, 3)); + const other = await macEpisode(mac, "c", "Bonus", localDate(2024, 3, 4)); + + const result = await plan(drive, [podcastOf("Show", [bonus, other])]); + expect(result.toCopy.map((c) => c.episode.id)).toEqual(["c"]); + + const scanned = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return yield* (yield* DriveScan).scanDrive(drive); + }), + layer, + ), + ); + const marked = markEpisodesOnDrive( + [bonus, other].map((e) => ({ ...e, showName: "Show", selected: false })), + mapPodcastsToEpisodes(scanned).map((e) => ({ ...e, showName: "Show" })), + ); + expect(marked.map((e) => e.onDrive)).toEqual([true, false]); + }); + + it("Unknown dates round-trip: an Unknown episode matches 'Unknown - Title' only", async () => { + const { drive, mac } = await makeDrive(["Show/Unknown - Trailer.mp3"]); + const unknown = await macEpisode(mac, "u", "Trailer", new Date(NaN)); + const dated = await macEpisode(mac, "d", "Trailer", localDate(2024, 1, 1)); + expect(formatDate(unknown.published)).toBe("Unknown"); + + const result = await plan(drive, [podcastOf("Show", [unknown, dated])]); + expect(result.toCopy.map((c) => c.episode.id)).toEqual(["d"]); + }); + + it("legacy undated drive files still match by show and title", async () => { + const { drive, mac } = await makeDrive(["Show/Trailer.mp3"]); + const ep = await macEpisode(mac, "a", "Trailer", localDate(2024, 1, 1)); + + const result = await plan(drive, [podcastOf("Show", [ep])]); + expect(result.toCopy).toHaveLength(0); + }); + + it("the matcher keys the date from a Date and from the filename literal identically", async () => { + const keys = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const matcher = yield* EpisodeMatcher; + return [ + matcher.buildExpectedDrivePath("Show", "Ep", localDate(2024, 12, 31)), + matcher.buildExpectedDrivePath("Show", "Ep", "2024-12-31"), + ]; + }), + EpisodeMatcherLive, + ), + ); + expect(keys[0]).toBe("Show/2024-12-31 - Ep.mp3"); + expect(keys[1]).toBe(keys[0]!); + }); +}); + +describe("DriveScan.scanDrive show-name normalization", () => { + it("groups an NFD show directory with an NFC one", async () => { + const nfc = "Café"; + const nfd = "Café"; + expect(nfc).not.toBe(nfd); + const { drive } = await makeDrive([ + `${nfc}/2024-01-01 - One.mp3`, + `${nfd}/2024-01-02 - Two.mp3`, + ]); + + const podcasts = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return yield* (yield* DriveScan).scanDrive(drive); + }), + layer, + ), + ); + expect(podcasts).toHaveLength(1); + expect(podcasts[0]?.id).toBe(nfc); + expect(podcasts[0]?.episodes).toHaveLength(2); + }); +}); diff --git a/src/__tests__/drive_selection.test.ts b/src/__tests__/drive_selection.test.ts new file mode 100644 index 0000000..83439d7 --- /dev/null +++ b/src/__tests__/drive_selection.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { actions, state } from "@/store"; +import type { Drive } from "@/types/drive"; +import type { PodcastEpisode } from "@/types/podcast"; +import { selectDrive } from "@/utils/driveSelection"; + +describe("drive selection", () => { + beforeEach(() => actions.resetState()); + + it("clears a stale error for selector-driven selection", () => { + const drive = { id: "drive-1", name: "Drive", mountPoint: "/Volumes/Drive" } as Drive; + const loadDrivePodcasts = mock(() => {}); + + actions.setErrorMsg("No drive selected"); + actions.setAppView("driveSelection"); + selectDrive(drive, loadDrivePodcasts); + + expect(state.errorMsg).toBe(""); + expect(state.currentDrive).toBe(drive); + expect(state.appView).toBe("normal"); + expect(loadDrivePodcasts).toHaveBeenCalledWith(drive); + }); + + it("clears the previous drive's listing immediately", () => { + const drive = { id: "drive-2", name: "Drive 2", mountPoint: "/Volumes/Drive2" } as Drive; + actions.setDrivePodcasts([{ id: "old", selected: false } as PodcastEpisode]); + actions.setDriveIndex(0); + + // The loader is a no-op, so anything left in drivePodcasts is stale. + selectDrive(drive, () => {}); + + expect(state.drivePodcasts).toEqual([]); + }); +}); diff --git a/src/__tests__/effects.test.ts b/src/__tests__/effects.test.ts index d8c24b6..86c64fb 100644 --- a/src/__tests__/effects.test.ts +++ b/src/__tests__/effects.test.ts @@ -7,7 +7,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "bun:test"; -import { Effect, Layer, Stream } from "effect"; +import { Cause, Effect, Layer, Result, Stream } from "effect"; import { createDriveDetectionTest, DriveDetection, @@ -88,13 +88,13 @@ describe("FileSystem Service", () => { }); const result = await Effect.runPromise( - Effect.either(Effect.provide(program, createFileSystemTest())), + Effect.result(Effect.provide(program, createFileSystemTest())), ); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(result.left).toBeInstanceOf(FileNotFoundError); - expect((result.left as FileNotFoundError).path).toBe("/missing.txt"); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure).toBeInstanceOf(FileNotFoundError); + expect((result.failure as FileNotFoundError).path).toBe("/missing.txt"); } }); }); @@ -139,12 +139,12 @@ describe("FileSystem Service", () => { }); const result = await Effect.runPromise( - Effect.either(Effect.provide(program, createFileSystemTest())), + Effect.result(Effect.provide(program, createFileSystemTest())), ); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(result.left).toBeInstanceOf(FileSystemCopyError); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure).toBeInstanceOf(FileSystemCopyError); } }); }); @@ -354,6 +354,7 @@ describe("SyncEngine Service", () => { { id: "ep-1", title: "Episode 1", + author: "Host A", duration: 3600, published: new Date("2024-01-01"), onDrive: false, @@ -363,6 +364,7 @@ describe("SyncEngine Service", () => { { id: "ep-2", title: "Episode 2", + author: "Host A", duration: 1800, published: new Date("2024-01-08"), onDrive: true, @@ -594,7 +596,7 @@ describe("DriveScan Service", () => { // Check index has correct key expect(result.size).toBe(1); - expect(result.has("Tech_Talk/Episode 1")).toBe(true); + expect(result.has("Tech_Talk/Episode_1.mp3")).toBe(true); }); }); @@ -725,8 +727,8 @@ describe("DriveScan Service", () => { ); expect(result.size).toBe(3); - expect(result.has("Tech_Talk/Episode_1.mp3")).toBe(true); - expect(result.has("Tech_Talk/Episode_2.mp3")).toBe(true); + expect(result.has("Tech_Talk/2024-01-15 - Episode_1.mp3")).toBe(true); + expect(result.has("Tech_Talk/2024-01-22 - Episode_2.mp3")).toBe(true); expect(result.has("Tech_Talk/InvalidDate_-_Episode_3.mp3")).toBe(true); }); @@ -759,7 +761,7 @@ describe("DriveScan Service", () => { it("buildExpectedDrivePath formats destination path correctly", async () => { const program = Effect.gen(function* () { const matcher = yield* EpisodeMatcher; - return matcher.buildExpectedDrivePath("Tech Talk", "Episode 1: Intro"); + return matcher.buildExpectedDrivePath("Tech Talk", "Episode 1: Intro", ""); }); const result = await Effect.runPromise(Effect.provide(program, EpisodeMatcherLive)); @@ -777,33 +779,37 @@ describe("DriveScan Service", () => { ]); // 1. Path match - const match1 = matcher.matchEpisode("Tech Talk", { title: "Episode 1" }, driveIndex); + const match1 = matcher.matchEpisode( + "Tech Talk", + { published: new Date(0), title: "Episode 1" }, + driveIndex, + ); // 2. Size match (same show) const match2 = matcher.matchEpisode( "Tech Talk", - { title: "Episode 2", fileSize: 2000 }, + { published: new Date(0), title: "Episode 2", fileSize: 2000 }, driveIndex, ); // 3. Duration match (within 2% tolerance, same show) const match3 = matcher.matchEpisode( "Tech Talk", - { title: "Episode 3", duration: 303 }, // 303 vs 300 is 1% diff + { published: new Date(0), title: "Episode 3", duration: 303 }, // 303 vs 300 is 1% diff driveIndex, ); // 4. Same size/duration but different show should NOT match const match4 = matcher.matchEpisode( "Other Show", - { title: "Episode 4", fileSize: 1000, duration: 100 }, + { published: new Date(0), title: "Episode 4", fileSize: 1000, duration: 100 }, driveIndex, ); // 5. Duration match outside tolerance (e.g. 310s for 300s) should NOT match const match5 = matcher.matchEpisode( "Tech Talk", - { title: "Episode 5", duration: 310 }, // 310 vs 300 is > 3% diff + { published: new Date(0), title: "Episode 5", duration: 310 }, // 310 vs 300 is > 3% diff driveIndex, ); @@ -876,9 +882,10 @@ describe("DriveScan Service", () => { const exit = yield* podcastService.loadMacPodcasts.pipe(Effect.exit); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(exit.cause._tag).toBe("Fail"); - if (exit.cause._tag === "Fail") { - expect(exit.cause.error).toBeInstanceOf(DatabaseNotFoundError); + const error = Cause.findError(exit.cause); + expect(Result.isSuccess(error)).toBe(true); + if (Result.isSuccess(error)) { + expect(error.success).toBeInstanceOf(DatabaseNotFoundError); } } }); diff --git a/src/__tests__/formatting.test.ts b/src/__tests__/formatting.test.ts index 88dffce..73ef194 100644 --- a/src/__tests__/formatting.test.ts +++ b/src/__tests__/formatting.test.ts @@ -81,6 +81,41 @@ describe("Formatting Utils", () => { expect(sanitizeFilename("")).toBe("untitled"); expect(sanitizeFilename(" ")).toBe("untitled"); }); + + test("never returns a directory reference", () => { + for (const name of [".", "..", "...", " .. "]) { + const out = sanitizeFilename(name); + expect(out).not.toMatch(/^\.+$/); + expect(out.length).toBeGreaterThan(0); + } + expect(sanitizeFilename(".")).toBe("_"); + expect(sanitizeFilename("..")).toBe("_"); + // Dots inside a real name are untouched. + expect(sanitizeFilename("Wait...")).toBe("Wait..."); + }); + + test("does not split a surrogate pair when truncating", () => { + // 99 ASCII chars then an emoji (2 UTF-16 units): a unit-based slice(0, 100) + // would keep only the high surrogate. + const out = sanitizeFilename(`${"a".repeat(99)}😀tail`); + expect(out).toBe(`${"a".repeat(99)}😀`); + expect(Array.from(out).length).toBe(100); + expect(out.isWellFormed()).toBe(true); + }); + + test("truncates by code point, not UTF-16 unit", () => { + const out = sanitizeFilename("😀".repeat(150)); + expect(Array.from(out).length).toBe(100); + expect(out.isWellFormed()).toBe(true); + }); + + test("NFC-normalizes so HFS+ (NFD) and NFC names produce the same key", () => { + const nfc = "Caf\u00e9"; + const nfd = "Cafe\u0301"; + expect(nfc).not.toBe(nfd); + expect(sanitizeFilename(nfd)).toBe(sanitizeFilename(nfc)); + expect(sanitizeFilename(nfd)).toBe(nfc); + }); }); describe("cleanFileUrl", () => { @@ -180,6 +215,7 @@ describe("Formatting Utils", () => { const mockEpisode: PodcastEpisode = { id: "1", title: "Ep 1", + author: "Host A", showName: "Show A", filePath: "/path/to/ep1.mp3", published: new Date("2023-01-01"), @@ -208,6 +244,46 @@ describe("Formatting Utils", () => { expect(showB).toBeDefined(); expect(showB?.episodes).toHaveLength(1); }); + + test("threads the episode author onto the grouped podcast", () => { + const episodes = [ + { ...mockEpisode, id: "1", title: "Ep 1", author: "Dan Carlin" }, + { ...mockEpisode, id: "2", title: "Ep 2", author: "Dan Carlin" }, + ]; + + const grouped = groupEpisodesByPodcast(episodes); + const showA = grouped.find((p) => p.title === "Show A"); + + expect(showA?.author).toBe("Dan Carlin"); + // The author is carried down onto each episode too. + expect(showA?.episodes.map((e) => e.author)).toEqual(["Dan Carlin", "Dan Carlin"]); + }); + + test("falls back to a non-blank author when no episode has one", () => { + const episodes = [ + { ...mockEpisode, id: "1", title: "Ep 1", author: "" }, + { ...mockEpisode, id: "2", title: "Ep 2", author: " " }, + ]; + + const grouped = groupEpisodesByPodcast(episodes); + const showA = grouped.find((p) => p.title === "Show A"); + + // Never the empty string: a blank ID3 artist tag is the bug being fixed. + expect(showA?.author).not.toBe(""); + expect(showA?.author).toBe("Unknown"); + }); + + test("uses the first non-empty author when authors are mixed", () => { + const episodes = [ + { ...mockEpisode, id: "1", title: "Ep 1", author: "" }, + { ...mockEpisode, id: "2", title: "Ep 2", author: "David Senra" }, + ]; + + const grouped = groupEpisodesByPodcast(episodes); + const showA = grouped.find((p) => p.title === "Show A"); + + expect(showA?.author).toBe("David Senra"); + }); }); describe("mapPodcastsToEpisodes", () => { @@ -222,6 +298,7 @@ describe("Formatting Utils", () => { { id: "1", title: "Ep 1", + author: "Author", duration: 3600, published: new Date("2023-01-01"), onDrive: true, diff --git a/src/__tests__/id3_tagging.test.ts b/src/__tests__/id3_tagging.test.ts new file mode 100644 index 0000000..6b8fb57 --- /dev/null +++ b/src/__tests__/id3_tagging.test.ts @@ -0,0 +1,100 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Layer } from "effect"; +import nodeID3 from "node-id3"; +import { FileSystemLive } from "@/services/effects/FileSystem"; +import { LoggerLive } from "@/services/effects/Logger"; +import { MetadataEditor, MetadataEditorLive } from "@/services/effects/MetadataEditor"; +import { groupEpisodesByPodcast } from "@/utils/formatting"; +import type { PodcastEpisode } from "@/types/podcast"; + +const dir = await mkdtemp(join(tmpdir(), "podapple-id3-")); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +/** A byte stream big enough for node-id3 to parse as a real audio file. */ +const fakeMp3 = (): Uint8Array => { + const bytes = new Uint8Array(1024); + for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7) % 251; + return bytes; +}; + +const writeTag = (file: string, artist: string, album: string) => + Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const editor = yield* MetadataEditor; + return yield* editor.write(file, { + title: "Ep 1", + artist, + album, + genre: "Podcast", + year: "2024", + comment: "Published: 2024-01-01", + }); + }), + Layer.mergeAll(MetadataEditorLive, FileSystemLive).pipe(Layer.provide(LoggerLive)), + ), + ); + +describe("ID3 tagging, end to end", () => { + it("puts the podcast author in the file's ID3 artist frame", async () => { + const file = join(dir, "ep1.mp3"); + await Bun.write(file, fakeMp3()); + + // Exactly what db.worker now produces from ZMTPODCAST.ZAUTHOR. + const episode: PodcastEpisode = { + id: "1", + title: "Ep 1", + author: "Dan Carlin", + duration: 60, + published: new Date("2024-01-01"), + onDrive: false, + filePath: file, + fileSize: 1024, + selected: true, + showName: "Hardcore History", + }; + + const podcast = groupEpisodesByPodcast([episode])[0]!; + expect(podcast.author).toBe("Dan Carlin"); + + await writeTag(file, podcast.author, podcast.title); + + // Read the file back off disk, rather than trusting what was passed in. + // This is the assertion the whole ZAUTHOR chain exists to satisfy: every + // file podapple ever synced carried a blank artist before this. + const tags = nodeID3.read(file); + expect(tags.artist).toBe("Dan Carlin"); + expect(tags.album).toBe("Hardcore History"); + }); + + it("never writes a blank artist, whatever the episode carries", async () => { + const file = join(dir, "ep2.mp3"); + await Bun.write(file, fakeMp3()); + + // No ZAUTHOR recorded, and a whitespace-only one, both have to fall back. + for (const author of ["", " "]) { + const episode: PodcastEpisode = { + id: "2", + title: "Ep 2", + author, + duration: 60, + published: new Date("2024-01-01"), + onDrive: false, + filePath: file, + fileSize: 1024, + selected: true, + showName: "Show", + }; + const podcast = groupEpisodesByPodcast([episode])[0]!; + expect(podcast.author).not.toBe(""); + await writeTag(file, podcast.author, podcast.title); + expect(nodeID3.read(file).artist).not.toBe(""); + } + }); +}); diff --git a/src/__tests__/keyboard.test.ts b/src/__tests__/keyboard.test.ts index 3ab2b9f..36a3d13 100644 --- a/src/__tests__/keyboard.test.ts +++ b/src/__tests__/keyboard.test.ts @@ -1,36 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { getFooterShortcuts, isNavigationKey } from "@/utils/keyboard"; +import { getFooterShortcuts } from "@/utils/keyboard"; describe("keyboard utilities", () => { - describe("isNavigationKey", () => { - test("should return true for j key", () => { - expect(isNavigationKey("j")).toBe(true); - }); - - test("should return true for k key", () => { - expect(isNavigationKey("k")).toBe(true); - }); - - test("should return true for up arrow", () => { - expect(isNavigationKey("up")).toBe(true); - }); - - test("should return true for down arrow", () => { - expect(isNavigationKey("down")).toBe(true); - }); - - test("should return false for tab (no longer navigation key)", () => { - expect(isNavigationKey("tab")).toBe(false); - }); - - test("should return false for non-navigation keys", () => { - expect(isNavigationKey("q")).toBe(false); - expect(isNavigationKey("s")).toBe(false); - expect(isNavigationKey("enter")).toBe(false); - expect(isNavigationKey("escape")).toBe(false); - }); - }); - describe("getFooterShortcuts", () => { test("should return two lines for normal view", () => { const shortcuts = getFooterShortcuts("normal"); diff --git a/src/__tests__/keyboard_integration.test.ts b/src/__tests__/keyboard_integration.test.ts index cd1e65f..27eb4c8 100644 --- a/src/__tests__/keyboard_integration.test.ts +++ b/src/__tests__/keyboard_integration.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { useAppKeyboard } from "@/hooks/useAppKeyboard"; import type { useAppLogic } from "@/hooks/useAppLogic"; import { actions, state } from "@/store"; +import type { Drive } from "@/types/drive"; import type { PodcastEpisode } from "@/types/podcast"; interface MockKeyboardEvent { @@ -85,6 +86,17 @@ describe("Keyboard Integration", () => { expect(mockLogic.scanForDrives).toHaveBeenCalled(); }); + it("should clear a stale drive error when selecting a drive with enter", () => { + actions.setDrives([{ id: "drive-1", name: "Drive", mountPoint: "/Volumes/Drive" } as Drive]); + actions.setErrorMsg("No drive selected"); + actions.setAppView("driveSelection"); + + pressKey("return"); + + expect(state.errorMsg).toBe(""); + expect(state.currentDrive?.id).toBe("drive-1"); + }); + it("should toggle selection with 'space'", () => { actions.setMacPodcasts([{ id: "1", selected: false } as PodcastEpisode]); actions.setMacIndex(0); @@ -146,4 +158,53 @@ describe("Keyboard Integration", () => { pressKey("y"); expect(mockLogic.deleteSelectedFromDrive).toHaveBeenCalled(); }); + + it("keeps the drive menu index at 0 when there are no drives", () => { + actions.setDrives([]); + actions.setAppView("driveSelection"); + + pressKey("j"); + expect(state.driveMenuIndex).toBe(0); + pressKey("down"); + expect(state.driveMenuIndex).toBe(0); + }); + + it("keeps the debug menu index at 0 when there are no messages", () => { + actions.clearDebugMessages(); + actions.setAppView("debug"); + + pressKey("j"); + expect(state.debugMenuIndex).toBe(0); + }); + + it("does not fire bare-letter actions when ctrl is held", () => { + actions.setMacPodcasts([{ id: "1", selected: true } as PodcastEpisode]); + actions.setMacIndex(0); + actions.setFocusedPane("mac"); + + pressKey("s", { ctrl: true }); + pressKey("r", { ctrl: true }); + pressKey("f", { ctrl: true }); + pressKey("a", { ctrl: true }); + pressKey("l", { ctrl: true }); + + expect(mockLogic.startSync).not.toHaveBeenCalled(); + expect(mockLogic.refreshData).not.toHaveBeenCalled(); + expect(mockLogic.scanForDrives).not.toHaveBeenCalled(); + expect(state.macPodcasts[0]?.selected).toBe(true); + expect(state.focusedPane).toBe("mac"); + expect(state.appView).toBe("normal"); + }); + + it("still handles ctrl+t and the ctrl+u/ctrl+d page remaps", () => { + pressKey("t", { ctrl: true }); + expect(state.appView).toBe("themeSelection"); + + actions.setAppView("normal"); + // Remapped to pageup/pagedown with ctrl cleared; must not reach plain-letter actions. + pressKey("d", { ctrl: true }); + pressKey("u", { ctrl: true }); + expect(state.appView).toBe("normal"); + expect(mockLogic.deleteSelectedFromDrive).not.toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/opentui_runtime.test.ts b/src/__tests__/opentui_runtime.test.ts new file mode 100644 index 0000000..3fd49c8 --- /dev/null +++ b/src/__tests__/opentui_runtime.test.ts @@ -0,0 +1,31 @@ +import { createComponent } from "solid-js"; +import { Portal, testRender } from "@opentui/solid"; +import { describe, expect, it } from "bun:test"; +import { Spinner } from "@/components/Spinner"; + +describe("OpenTUI Solid 2 runtime", () => { + it("mounts and disposes a renderer root", async () => { + const setup = await testRender(() => null); + + expect(setup.renderer).toBeDefined(); + setup.renderer.destroy(); + }); + + it("mounts and disposes components with settle-phase cleanup", async () => { + const setup = await testRender(() => createComponent(Spinner, { variant: "bouncingBall" })); + + expect(setup.renderer).toBeDefined(); + setup.renderer.destroy(); + }); + + it("mounts and disposes portals with render-phase cleanup", async () => { + const setup = await testRender(() => + createComponent(Portal, { + children: createComponent(Spinner, { variant: "bouncingBall" }), + }), + ); + + expect(setup.renderer).toBeDefined(); + setup.renderer.destroy(); + }); +}); diff --git a/src/__tests__/plist.test.ts b/src/__tests__/plist.test.ts index 2c04b26..8a1efce 100644 --- a/src/__tests__/plist.test.ts +++ b/src/__tests__/plist.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { Either } from "effect"; +import { Result } from "effect"; import { getPlistBoolean, getPlistNumber, @@ -19,7 +19,7 @@ describe("Plist Parser", () => { WALKMAN `; - const result = Either.getOrThrow(parsePlist(xml)) as PlistDict; + const result = Result.getOrThrow(parsePlist(xml)) as PlistDict; expect(getPlistString(result, "VolumeName")).toBe("WALKMAN"); }); @@ -33,7 +33,7 @@ describe("Plist Parser", () => { 16000000000 `; - const result = Either.getOrThrow(parsePlist(xml)) as PlistDict; + const result = Result.getOrThrow(parsePlist(xml)) as PlistDict; expect(getPlistNumber(result, "TotalSize")).toBe(32000000000); expect(getPlistNumber(result, "FreeSpace")).toBe(16000000000); }); @@ -48,7 +48,7 @@ describe("Plist Parser", () => { `; - const result = Either.getOrThrow(parsePlist(xml)) as PlistDict; + const result = Result.getOrThrow(parsePlist(xml)) as PlistDict; expect(getPlistBoolean(result, "Internal")).toBe(false); expect(getPlistBoolean(result, "Removable")).toBe(true); }); @@ -66,7 +66,7 @@ describe("Plist Parser", () => { `; - const result = Either.getOrThrow(parsePlist(xml)) as PlistDict; + const result = Result.getOrThrow(parsePlist(xml)) as PlistDict; const driveInfo = result.DriveInfo as PlistDict; expect(driveInfo).toBeDefined(); expect(getPlistString(driveInfo, "Name")).toBe("USB Drive"); @@ -84,7 +84,7 @@ describe("Plist Parser", () => { `; - const result = Either.getOrThrow(parsePlist(xml)) as PlistDict; + const result = Result.getOrThrow(parsePlist(xml)) as PlistDict; const volumes = result.Volumes as string[]; expect(volumes).toHaveLength(2); expect(volumes[0]).toBe("WALKMAN"); @@ -116,7 +116,7 @@ describe("Plist Parser", () => { WALKMAN `; - const result = Either.getOrThrow(parsePlist(xml)) as PlistDict; + const result = Result.getOrThrow(parsePlist(xml)) as PlistDict; expect(getPlistString(result, "BusProtocol")).toBe("USB"); expect(getPlistString(result, "VolumeName")).toBe("WALKMAN"); @@ -136,7 +136,7 @@ describe("Plist Parser", () => { Tom & Jerry's Drive `; - const result = Either.getOrThrow(parsePlist(xml)) as PlistDict; + const result = Result.getOrThrow(parsePlist(xml)) as PlistDict; expect(getPlistString(result, "Name")).toBe("Tom & Jerry's Drive"); }); }); @@ -168,4 +168,80 @@ describe("Plist Parser", () => { expect(getPlistBoolean(dict, "Missing")).toBeUndefined(); }); }); + + describe("hardening", () => { + const wrap = (body: string) => `${body}`; + const parse = (body: string) => Result.getOrThrow(parsePlist(wrap(body))) as PlistDict; + + it("does not double-decode entities", () => { + const dict = parse("K&lt; &amp; &#65;"); + expect(dict.K).toBe("< & A"); + }); + + it("decodes the named entities", () => { + const dict = parse( + "Ka <b> & "c" 'd'", + ); + expect(dict.K).toBe("a & \"c\" 'd'"); + }); + + it("decodes decimal and hex numeric entities", () => { + const dict = parse("KAB😀"); + expect(dict.K).toBe("AB\u{1F600}"); + }); + + it("leaves out-of-range numeric entities as written", () => { + const dict = parse("K��"); + expect(dict.K).toBe("��"); + }); + + it("decodes entities at the top level too", () => { + expect(Result.getOrThrow(parsePlist(wrap("&lt;")))).toBe("<"); + }); + + it("parses reals with exponents and signs", () => { + const dict = parse( + "A1.5e3B-2E-2C+4D.5E3.", + ); + expect(dict.A).toBe(1500); + expect(dict.B).toBe(-0.02); + expect(dict.C).toBe(4); + expect(dict.D).toBe(0.5); + expect(dict.E).toBe(3); + }); + + it("parses a top-level real with an exponent", () => { + expect(Result.getOrThrow(parsePlist(wrap("1e2")))).toBe(100); + }); + + it("skips one unparseable value without dropping the remaining keys", () => { + const dict = parse( + "BeforexBad?After7Last", + ); + expect(dict.Before).toBe("x"); + expect("Bad" in dict).toBe(false); + expect(dict.After).toBe(7); + expect(dict.Last).toBe(true); + }); + + it("skips an unparseable final value", () => { + const dict = parse("A1Bad"); + expect(dict).toEqual({ A: 1 }); + }); + + it("parses self-closing strings as empty", () => { + const dict = parse( + "ABC1", + ); + expect(dict.A).toBe(""); + expect(dict.B).toBe(""); + expect(dict.C).toBe(1); + expect(Result.getOrThrow(parsePlist(wrap("")))).toBe(""); + }); + + it("parses self-closing strings inside arrays", () => { + const arr = Result.getOrThrow(parsePlist(wrap("a"))); + expect(arr).toEqual(["", "a"]); + }); + }); }); diff --git a/src/__tests__/render_suites.test.ts b/src/__tests__/render_suites.test.ts new file mode 100644 index 0000000..f890c23 --- /dev/null +++ b/src/__tests__/render_suites.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; + +/** + * The component render tests in src/render_tests need Solid's client build, + * which `bun test` does not wire up from bunfig (it resolves the server build, + * where effects never run and nothing renders), and which breaks the + * store-oriented suites if enabled globally. Run them in a child process with + * the OpenTUI Solid preload so they exercise real rendering. + */ +describe("component render suites", () => { + it("pass under the OpenTUI Solid preload", async () => { + const proc = Bun.spawn( + ["bun", "test", "--preload", "@opentui/solid/preload", "./src/render_tests"], + { cwd: process.cwd(), stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (code !== 0) console.error(stdout + stderr); + expect(code).toBe(0); + }, 60_000); +}); diff --git a/src/__tests__/services_regressions.test.ts b/src/__tests__/services_regressions.test.ts new file mode 100644 index 0000000..13633d6 --- /dev/null +++ b/src/__tests__/services_regressions.test.ts @@ -0,0 +1,740 @@ +/** + * Regression tests for the effect services: FileSystem.readDir, SyncEngine + * (plan + tagging + cleanup), MetadataEditor merge semantics and the + * DriveDetection debounce. + */ + +import { afterAll, describe, expect, it } from "bun:test"; +import { Database } from "bun:sqlite"; +import { homedir } from "node:os"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Cause, Effect, Exit, Layer, Option, Ref, Result, Stream } from "effect"; +import nodeID3 from "node-id3"; +import { processActivityEvent } from "@/services/effects/DriveDetection"; +import { type EpisodeMatcher, EpisodeMatcherLive } from "@/services/effects/EpisodeMatcher"; +import { createFileSystemTest, FileSystem, FileSystemLive } from "@/services/effects/FileSystem"; +import { Logger, LoggerLive } from "@/services/effects/Logger"; +import { + MetadataEditor, + MetadataEditorLive, + type PodcastMetadata, +} from "@/services/effects/MetadataEditor"; +import { + PodcastError, + PodcastService, + PodcastServiceLive, +} from "@/services/effects/PodcastService"; +import { SettingsService, SettingsServiceLive } from "@/services/effects/SettingsService"; +import { dedupeDestPath, SyncEngine, SyncEngineLive } from "@/services/effects/SyncEngine"; +import type { Drive } from "@/types/drive"; +import type { Podcast } from "@/types/podcast"; + +const root = await mkdtemp(join(tmpdir(), "podapple-svc-")); +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +let counter = 0; +const freshDir = async (): Promise => { + const dir = join(root, `case-${counter++}`); + await mkdir(dir, { recursive: true }); + return dir; +}; + +const recordingEditor = (sink: Array<{ path: string; metadata: PodcastMetadata }>) => + Layer.succeed(MetadataEditor, { + write: (path, metadata) => + Effect.sync(() => { + sink.push({ path, metadata }); + }), + }); + +const syncLayer = (editor: Layer.Layer) => + Layer.mergeAll(SyncEngineLive, EpisodeMatcherLive, FileSystemLive, editor).pipe( + Layer.provide(LoggerLive), + ); + +const episode = (id: string, filePath: string, title = `Episode ${id}`) => ({ + id, + title, + author: "Host", + duration: 60, + published: new Date("2024-01-01T12:00:00Z"), + onDrive: false, + filePath, + fileSize: 0, +}); + +const podcastOf = (episodes: ReturnType[]): Podcast[] => [ + { id: "p", title: "Show", author: "Host", episodeCount: episodes.length, episodes }, +]; + +const runSync = ( + program: Effect.Effect, + editor: Layer.Layer, +) => Effect.runPromise(Effect.provide(program, syncLayer(editor)) as Effect.Effect); + +describe("FileSystem.readDir", () => { + it("returns directories as well as files (and skips dot entries)", async () => { + const dir = await freshDir(); + await mkdir(join(dir, "Show A")); + await Bun.write(join(dir, "loose.mp3"), new Uint8Array([1])); + await Bun.write(join(dir, ".DS_Store"), new Uint8Array([1])); + + const entries = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return yield* (yield* FileSystem).readDir(dir); + }), + FileSystemLive.pipe(Layer.provide(LoggerLive)), + ), + ); + expect(entries.sort()).toEqual(["Show A", "loose.mp3"]); + }); +}); + +describe("SyncEngine.cleanup", () => { + it("removes empty show dirs, including ones holding only system hidden files", async () => { + const drive = await freshDir(); + const podcasts = join(drive, "Podcasts"); + await mkdir(join(podcasts, "Empty"), { recursive: true }); + await mkdir(join(podcasts, "OnlyHidden"), { recursive: true }); + await Bun.write(join(podcasts, "OnlyHidden", ".DS_Store"), new Uint8Array([1])); + await Bun.write(join(podcasts, "OnlyHidden", "._ep.mp3"), new Uint8Array([1])); + await mkdir(join(podcasts, "Full"), { recursive: true }); + await Bun.write(join(podcasts, "Full", "a.mp3"), new Uint8Array([1, 2])); + await Bun.write(join(podcasts, "Full", ".DS_Store"), new Uint8Array([1])); + + await runSync( + Effect.gen(function* () { + yield* (yield* SyncEngine).cleanup(drive); + }), + recordingEditor([]), + ); + + expect(await Bun.file(join(podcasts, "Full", "a.mp3")).exists()).toBe(true); + // Hidden files are swept even from a show dir that stays. + expect(await Bun.file(join(podcasts, "Full", ".DS_Store")).exists()).toBe(false); + const fs = await import("node:fs/promises"); + const remaining = await fs.readdir(podcasts); + expect(remaining).toEqual(["Full"]); + }); +}); + +describe("SyncEngine tagging", () => { + it("tags .mp3 (any case) but copies .m4a untouched and not counted as discarded", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + const m4a = new Uint8Array([9, 8, 7, 6, 5]); + await Bun.write(join(dir, "a.m4a"), m4a); + await Bun.write(join(dir, "b.MP3"), new Uint8Array([1, 2, 3])); + + const sink: Array<{ path: string; metadata: PodcastMetadata }> = []; + const result = await runSync( + Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan( + podcastOf([episode("1", join(dir, "a.m4a")), episode("2", join(dir, "b.MP3"))]), + out, + new Map(), + ); + const progress = yield* Stream.runCollect(engine.execute(plan, out)); + return { plan, progress: Array.from(progress) }; + }), + recordingEditor(sink), + ); + + expect(sink).toHaveLength(1); + // Tagging happens on the .part, which is renamed into place afterwards. + expect(sink[0]?.path.endsWith(".MP3.part")).toBe(true); + expect(await Bun.file(sink[0]?.path as string).exists()).toBe(false); + const m4aDest = result.plan.toCopy.find((c) => c.sourcePath.endsWith(".m4a"))?.destPath; + expect(await Bun.file(m4aDest as string).bytes()).toEqual(m4a); + expect(result.progress.at(-1)?.discarded).toBe(0); + expect(result.progress.some((p) => p.currentFile.startsWith("Tagging:"))).toBe(true); + expect(result.progress.filter((p) => p.currentFile === "Tagging: Episode 1").length).toBe(0); + }); +}); + +describe("SyncEngine.createPlan", () => { + it("skips items whose source is missing or empty, and logs a warning", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + await Bun.write(join(dir, "ok.mp3"), new Uint8Array([1, 2, 3])); + await Bun.write(join(dir, "empty.mp3"), new Uint8Array()); + + const messages: string[] = []; + const logger = Layer.succeed(Logger, { + debug: () => Effect.void, + info: (m) => Effect.sync(() => void messages.push(m)), + error: (m) => Effect.sync(() => void messages.push(m)), + }); + const layer = Layer.mergeAll(SyncEngineLive, EpisodeMatcherLive, FileSystemLive).pipe( + Layer.provide(logger), + ); + + const plan = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return yield* (yield* SyncEngine).createPlan( + podcastOf([ + episode("1", join(dir, "ok.mp3")), + episode("2", join(dir, "empty.mp3")), + episode("3", join(dir, "missing.mp3")), + ]), + out, + new Map(), + ); + }), + layer, + ), + ); + + expect(plan.toCopy.map((c) => c.episode.id)).toEqual(["1"]); + expect(plan.totalBytes).toBe(3); + expect(messages.filter((m) => m.includes("skipping")).length).toBe(2); + }); + + it("gives colliding destinations distinct, deterministic paths and copies both", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + await Bun.write(join(dir, "a.mp3"), new Uint8Array([1, 1, 1])); + await Bun.write(join(dir, "b.mp3"), new Uint8Array([2, 2, 2, 2])); + const eps = podcastOf([ + episode("1", join(dir, "a.mp3"), "Same Title"), + episode("2", join(dir, "b.mp3"), "Same Title"), + ]); + + const plan = async () => + runSync( + Effect.gen(function* () { + return yield* (yield* SyncEngine).createPlan(eps, out, new Map()); + }), + recordingEditor([]), + ); + const first = await plan(); + const second = await plan(); + const paths = first.toCopy.map((c) => c.destPath); + + expect(new Set(paths).size).toBe(2); + expect(paths[0]?.endsWith("Same_Title.mp3")).toBe(true); + expect(paths[1]?.endsWith("Same_Title (2).mp3")).toBe(true); + expect(second.toCopy.map((c) => c.destPath)).toEqual(paths); + + await runSync( + Effect.gen(function* () { + yield* Stream.runDrain((yield* SyncEngine).execute(first, out)); + }), + recordingEditor([]), + ); + expect(await Bun.file(paths[0] as string).bytes()).toEqual(new Uint8Array([1, 1, 1])); + expect(await Bun.file(paths[1] as string).bytes()).toEqual(new Uint8Array([2, 2, 2, 2])); + }); + + it("dedupeDestPath is case-insensitive and keeps the extension last", () => { + const used = new Set(); + expect(dedupeDestPath("/d/A.mp3", ".mp3", used)).toBe("/d/A.mp3"); + expect(dedupeDestPath("/d/a.MP3", ".MP3", used)).toBe("/d/a (2).MP3"); + expect(dedupeDestPath("/d/A.mp3", ".mp3", used)).toBe("/d/A (3).mp3"); + }); + + it("skips an existing destination and re-plans a missing one", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + await Bun.write(join(dir, "ep.mp3"), new Uint8Array([1, 2, 3, 4, 5, 6])); + const eps = podcastOf([episode("1", join(dir, "ep.mp3"))]); + const planOnce = () => + runSync( + Effect.gen(function* () { + return yield* (yield* SyncEngine).createPlan(eps, out, new Map()); + }), + recordingEditor([]), + ); + + const initial = await planOnce(); + const dest = initial.toCopy[0]?.destPath as string; + expect(initial.toCopy).toHaveLength(1); + + // Even a smaller file at the final path counts as complete (tagging can shrink it). + await Bun.write(dest, new Uint8Array([1, 2])); + expect((await planOnce()).toCopy).toHaveLength(0); + }); +}); + +describe("SyncEngine atomic copy", () => { + const executeOnce = (eps: Podcast[], out: string, editor: Layer.Layer) => + runSync( + Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan(eps, out, new Map()); + const exit = yield* Effect.exit(Stream.runDrain(engine.execute(plan, out))); + return { plan, exit }; + }), + editor, + ); + + it("leaves no dest and no .part when the copy fails", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + // A directory opens for reading but fails on read: a mid-copy I/O error. + await mkdir(join(dir, "ep.mp3")); + const { plan, exit } = await executeOnce( + podcastOf([episode("1", join(dir, "ep.mp3"))]), + out, + recordingEditor([]), + ); + expect(plan.toCopy).toHaveLength(1); + expect(Exit.isFailure(exit)).toBe(true); + const dest = plan.toCopy[0]?.destPath as string; + expect(await Bun.file(dest).exists()).toBe(false); + expect(await Bun.file(`${dest}.part`).exists()).toBe(false); + }); + + it("leaves no dest and no .part when the copy is interrupted mid-file", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + await Bun.write(join(dir, "ep.mp3"), new Uint8Array(5 * 1024 * 1024).fill(7)); + const plan = await runSync( + Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan( + podcastOf([episode("1", join(dir, "ep.mp3"))]), + out, + new Map(), + ); + // Initial progress plus the first 1 MiB chunk, then the consumer stops. + yield* Stream.runDrain(Stream.take(engine.execute(plan, out), 2)); + return plan; + }), + recordingEditor([]), + ); + const dest = plan.toCopy[0]?.destPath as string; + expect(await Bun.file(dest).exists()).toBe(false); + expect(await Bun.file(`${dest}.part`).exists()).toBe(false); + }); + + it("removes a stale .part before copying and never leaves one behind", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + const content = new Uint8Array([4, 5, 6, 7]); + await Bun.write(join(dir, "ep.m4a"), content); + const eps = podcastOf([episode("1", join(dir, "ep.m4a"))]); + const plan = await runSync( + Effect.gen(function* () { + return yield* (yield* SyncEngine).createPlan(eps, out, new Map()); + }), + recordingEditor([]), + ); + const dest = plan.toCopy[0]?.destPath as string; + await Bun.write(`${dest}.part`, new Uint8Array(100).fill(9)); + await runSync( + Effect.gen(function* () { + yield* Stream.runDrain((yield* SyncEngine).execute(plan, out)); + }), + recordingEditor([]), + ); + expect(await Bun.file(dest).bytes()).toEqual(content); + expect(await Bun.file(`${dest}.part`).exists()).toBe(false); + }); + + it("does not re-plan an mp3 whose tagged output is smaller than the source", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + const source = new Uint8Array(4096).fill(3); + await Bun.write(join(dir, "ep.mp3"), source); + const eps = podcastOf([episode("1", join(dir, "ep.mp3"))]); + // A tagger that shrinks the file, as ID3 padding rewrites can. + const shrinking = Layer.succeed(MetadataEditor, { + write: (path) => + Effect.promise(async () => { + await Bun.write(path, source.subarray(0, 1000)); + }), + }); + const { plan, exit } = await executeOnce(eps, out, shrinking); + expect(Exit.isSuccess(exit)).toBe(true); + const dest = plan.toCopy[0]?.destPath as string; + expect((await Bun.file(dest).bytes()).length).toBe(1000); + expect(await Bun.file(`${dest}.part`).exists()).toBe(false); + + const second = await runSync( + Effect.gen(function* () { + return yield* (yield* SyncEngine).createPlan(eps, out, new Map()); + }), + shrinking, + ); + expect(second.toCopy).toHaveLength(0); + }); + + it("copies a non-mp3 byte-identical without tagging", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + const content = new Uint8Array(3000).map((_, i) => (i * 13) % 256); + await Bun.write(join(dir, "ep.m4a"), content); + const sink: Array<{ path: string; metadata: PodcastMetadata }> = []; + const { plan, exit } = await executeOnce( + podcastOf([episode("1", join(dir, "ep.m4a"))]), + out, + recordingEditor(sink), + ); + expect(Exit.isSuccess(exit)).toBe(true); + const dest = plan.toCopy[0]?.destPath as string; + expect(await Bun.file(dest).bytes()).toEqual(content); + expect(sink).toHaveLength(0); + }); +}); + +describe("MetadataEditor merge", () => { + it("keeps frames already in the file (cover art) when writing podcast tags", async () => { + const dir = await freshDir(); + const file = join(dir, "ep.mp3"); + const bytes = new Uint8Array(1024); + for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7) % 251; + await Bun.write(file, bytes); + + const cover = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4]); + nodeID3.write( + { + title: "Old", + image: { mime: "image/jpeg", type: { id: 3 }, description: "cover", imageBuffer: cover }, + }, + file, + ); + + await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + yield* (yield* MetadataEditor).write(file, { + title: "New", + artist: "Host", + album: "Show", + genre: "Podcast", + year: "2024", + comment: "Published: 2024-01-01", + }); + }), + MetadataEditorLive, + ), + ); + + const tags = nodeID3.read(file); + expect(tags.title).toBe("New"); + expect(tags.artist).toBe("Host"); + const image = tags.image as { imageBuffer: Buffer } | undefined; + expect(image?.imageBuffer.equals(cover)).toBe(true); + }); +}); + +describe("DriveDetection debounce", () => { + const drive: Drive = { + id: "UUID", + name: "USB", + bsdName: "disk4s1", + mountPoint: "/Volumes/USB", + totalSpace: 1, + freeSpace: 1, + }; + const logger = { + debug: () => Effect.void, + info: () => Effect.void, + error: () => Effect.void, + }; + + const setup = () => + Effect.gen(function* () { + return { + driveIdMap: yield* Ref.make(new Map()), + lastEventTimeMap: yield* Ref.make(new Map()), + }; + }); + + it("does not let an empty Appeared (pre-mount) swallow the VolumeMount that follows", async () => { + const result = await Effect.runPromise( + Effect.gen(function* () { + const state = yield* setup(); + const ev = { type: "Appeared", bsdName: "disk4s1" } as const; + const early = yield* processActivityEvent( + ev, + state, + () => Effect.succeed(Option.none()), + logger, + 10_000, + ); + const mounted = yield* processActivityEvent( + ev, + state, + () => Effect.succeed(Option.some(drive)), + logger, + 10_100, + ); + const dup = yield* processActivityEvent( + ev, + state, + () => Effect.succeed(Option.some(drive)), + logger, + 10_500, + ); + const later = yield* processActivityEvent( + ev, + state, + () => Effect.succeed(Option.some(drive)), + logger, + 11_200, + ); + return { early, mounted, dup, later }; + }), + ); + expect(Option.isNone(result.early)).toBe(true); + expect(Option.isSome(result.mounted)).toBe(true); + // A real emitted event still debounces its immediate duplicates. + expect(Option.isNone(result.dup)).toBe(true); + expect(Option.isSome(result.later)).toBe(true); + }); + + it("does not record a timestamp for a Disappeared of an unknown drive", async () => { + const result = await Effect.runPromise( + Effect.gen(function* () { + const state = yield* setup(); + const gone = { type: "Disappeared", bsdName: "disk4s1" } as const; + const unknown = yield* processActivityEvent( + gone, + state, + () => Effect.succeed(Option.none()), + logger, + 5_000, + ); + yield* Ref.update(state.driveIdMap, (m) => new Map(m).set("disk4s1", "UUID")); + const known = yield* processActivityEvent( + gone, + state, + () => Effect.succeed(Option.none()), + logger, + 5_100, + ); + return { unknown, known }; + }), + ); + expect(Option.isNone(result.unknown)).toBe(true); + expect(Option.getOrNull(result.known)).toEqual({ _tag: "Disappeared", driveId: "UUID" }); + }); +}); + +describe("FileSystem.getExtension", () => { + const ext = (path: string) => + Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return (yield* FileSystem).getExtension(path); + }), + FileSystemLive.pipe(Layer.provide(LoggerLive)), + ), + ); + + it("only considers the basename", async () => { + expect(await ext("/Volumes/My.Drive/episode")).toBe(".mp3"); + expect(await ext("/a.b/c.d/episode.m4a")).toBe(".m4a"); + expect(await ext("/a/ep.final.MP3")).toBe(".MP3"); + expect(await ext("/a/ep.")).toBe(".mp3"); + }); +}); + +describe("SyncEngine tagging, invalid dates", () => { + it("does not crash the stream on an invalid published date", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + await Bun.write(join(dir, "a.mp3"), new Uint8Array([1, 2, 3])); + const bad = { ...episode("1", join(dir, "a.mp3")), published: new Date(Number.NaN) }; + + const sink: Array<{ path: string; metadata: PodcastMetadata }> = []; + const last = await runSync( + Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan(podcastOf([bad]), out, new Map()); + const progress = Array.from(yield* Stream.runCollect(engine.execute(plan, out))); + return progress.at(-1); + }), + recordingEditor(sink), + ); + expect(last?.status).toBe("complete"); + expect(sink).toHaveLength(1); + expect(sink[0]?.metadata.year).toBeUndefined(); + expect(sink[0]?.metadata.comment).toBeUndefined(); + expect(sink[0]?.metadata.artist).toBe("Host"); + }); +}); + +describe("SettingsService", () => { + const settingsFile = join(homedir(), ".config", "podapple", "podapple.jsonc"); + const run = ( + files: Map, + program: Effect.Effect, + ) => + Effect.runPromise( + Effect.provide( + program, + SettingsServiceLive.pipe( + Layer.provide(createFileSystemTest(files)), + Layer.provide(LoggerLive), + ), + ), + ); + + it("refuses to overwrite a settings file that exists but does not parse", async () => { + const corrupt = new TextEncoder().encode('{ "theme": "Nord", '); + const files = new Map([[settingsFile, corrupt]]); + + const exit = await run( + files, + Effect.gen(function* () { + return yield* (yield* SettingsService).saveSettings({ theme: "Dracula" }).pipe(Effect.exit); + }), + ); + expect(exit._tag).toBe("Failure"); + expect(files.get(settingsFile)).toBe(corrupt); + // No temp file left behind. + expect(Array.from(files.keys())).toEqual([settingsFile]); + + // Loading still degrades to defaults rather than throwing. + const loaded = await run( + files, + Effect.gen(function* () { + return yield* (yield* SettingsService).loadSettings; + }), + ); + expect(loaded.theme).toBe("Catppuccin"); + }); + + it("persists both updates when two saves with different keys overlap", async () => { + const files = new Map(); + // Reads yield (the first save's read is also its check for a missing file), so unserialised saves would both merge into the same + // stale snapshot and the later write would drop the earlier key. + const slowReads = Layer.effect( + FileSystem, + Effect.gen(function* () { + const base = yield* FileSystem; + return { + ...base, + exists: (path: string) => + Effect.sleep("10 millis").pipe(Effect.andThen(base.exists(path))), + readFile: (path: string) => + Effect.sleep("10 millis").pipe(Effect.andThen(base.readFile(path))), + }; + }), + ).pipe(Layer.provide(createFileSystemTest(files))); + + await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const svc = yield* SettingsService; + yield* Effect.all( + [svc.saveSettings({ theme: "Nord" }), svc.saveSettings({ favoriteDrives: ["a", "b"] })], + { concurrency: "unbounded" }, + ); + }), + SettingsServiceLive.pipe(Layer.provide(slowReads), Layer.provide(LoggerLive)), + ), + ); + + const saved = JSON.parse(new TextDecoder().decode(files.get(settingsFile))); + expect(saved.theme).toBe("Nord"); + expect(saved.favoriteDrives).toEqual(["a", "b"]); + }); + + it("creates a missing settings file via temp file + rename", async () => { + const files = new Map(); + await run( + files, + Effect.gen(function* () { + yield* (yield* SettingsService).saveSettings({ theme: "Nord" }); + }), + ); + expect(Array.from(files.keys())).toEqual([settingsFile]); + expect(JSON.parse(new TextDecoder().decode(files.get(settingsFile))).theme).toBe("Nord"); + }); +}); + +describe("PodcastService worker startup", () => { + it("maps a throwing `new Worker` to PodcastError, not a defect", async () => { + const RealWorker = globalThis.Worker; + globalThis.Worker = class { + constructor() { + throw new Error("worker boom"); + } + } as unknown as typeof Worker; + try { + const exit = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const svc = yield* PodcastService; + return yield* svc.loadMacPodcasts.pipe(Effect.exit); + }), + PodcastServiceLive.pipe( + Layer.provide( + // The DB file "exists" as far as the service can tell. + createFileSystemTest( + new Map([ + [ + join( + homedir(), + "Library/Group Containers/243LU875E5.groups.com.apple.podcasts/Documents/MTLibrary.sqlite", + ), + new Uint8Array([1]), + ], + ]), + ), + ), + Layer.provide(LoggerLive), + ), + ), + ); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + const error = Cause.findError(exit.cause); + expect(Result.isSuccess(error)).toBe(true); + if (Result.isSuccess(error)) expect(error.success).toBeInstanceOf(PodcastError); + } + } finally { + globalThis.Worker = RealWorker; + } + }); +}); + +describe("db.worker", () => { + it("still loads episodes from a real SQLite file (connection closed in finally)", async () => { + const dir = await freshDir(); + const dbPath = join(dir, "MTLibrary.sqlite"); + const db = new Database(dbPath); + db.run("CREATE TABLE ZMTPODCAST (ZUUID TEXT, ZTITLE TEXT, ZAUTHOR TEXT)"); + db.run( + "CREATE TABLE ZMTEPISODE (Z_PK INTEGER, ZTITLE TEXT, ZPODCASTUUID TEXT, ZASSETURL TEXT, ZPUBDATE REAL, ZDURATION REAL)", + ); + db.run("INSERT INTO ZMTPODCAST VALUES ('u1', 'Show', 'Host')"); + db.run("INSERT INTO ZMTEPISODE VALUES (1, 'Ep', 'u1', 'file:///tmp/a%20b.mp3', 1000, 60)"); + db.close(); + + const worker = new Worker(new URL("../services/workers/db.worker.ts", import.meta.url)); + try { + const message = await new Promise<{ + type: string; + data?: Array<{ filePath: string; author: string }>; + }>((resolve, reject) => { + worker.onmessage = (e) => resolve(e.data); + worker.onerror = (e) => reject(e); + worker.postMessage({ type: "LOAD", dbPath }); + }); + expect(message.type).toBe("SUCCESS"); + expect(message.data?.[0]?.filePath).toBe("/tmp/a b.mp3"); + expect(message.data?.[0]?.author).toBe("Host"); + + // An unreadable database reports ERROR instead of hanging. + const failure = await new Promise<{ type: string }>((resolve) => { + worker.onmessage = (e) => resolve(e.data); + worker.postMessage({ type: "LOAD", dbPath: join(dir, "missing.sqlite") }); + }); + expect(failure.type).toBe("ERROR"); + } finally { + worker.terminate(); + } + }); +}); diff --git a/src/__tests__/store.test.ts b/src/__tests__/store.test.ts index 03bac6a..daf8226 100644 --- a/src/__tests__/store.test.ts +++ b/src/__tests__/store.test.ts @@ -54,10 +54,105 @@ describe("store actions", () => { expect(state.transferProgress.totalFiles).toBe(10); }); + it("should clear a pending error when a success is set", () => { + // Seed the error first, otherwise the assertion below cannot fail. + actions.setErrorMsg("something broke"); + expect(state.errorMsg).toBe("something broke"); + + actions.setSuccessMsg("Sync complete"); + expect(state.successMsg).toBe("Sync complete"); + // A success message must not leave the error channel populated. + expect(state.errorMsg).toBe(""); + + actions.setSuccessMsg(""); + }); + + it("should clear successMsg when an error is set", () => { + actions.setSuccessMsg("Sync complete"); + // Seed the success first, otherwise the assertion below cannot fail. + expect(state.successMsg).toBe("Sync complete"); + + // Only one status line at a time: a fresh error must not render + // alongside a stale success. + actions.setErrorMsg("something broke"); + expect(state.errorMsg).toBe("something broke"); + expect(state.successMsg).toBe(""); + + actions.setErrorMsg(""); + }); + + it("should clear errorMsg when a success is set", () => { + actions.setErrorMsg("something broke"); + expect(state.errorMsg).toBe("something broke"); + + actions.setSuccessMsg("Sync complete"); + expect(state.successMsg).toBe("Sync complete"); + expect(state.errorMsg).toBe(""); + + actions.setSuccessMsg(""); + }); + it("should add debug messages", () => { actions.clearDebugMessages(); actions.addDebugMessage("Test message", "info"); expect(state.debugMessages.length).toBe(1); expect(state.debugMessages[0]?.message).toBe("Test message"); }); + + it("should fully reset state without mutating the initial state", () => { + actions.resetState(); + actions.addDebugMessage("x"); + actions.updateTransferProgress({ filesDone: 3 }); + actions.toggleFavoriteDrive("d"); + expect(state.debugMessages.length).toBe(1); + expect(state.transferProgress.filesDone).toBe(3); + expect(state.favoriteDrives).toEqual(["d"]); + + actions.resetState(); + expect(state.debugMessages.length).toBe(0); + expect(state.transferProgress.filesDone).toBe(0); + expect(state.favoriteDrives).toEqual([]); + + // A second round proves each reset hands out fresh objects. + actions.addDebugMessage("y"); + actions.updateTransferProgress({ filesDone: 5 }); + actions.toggleFavoriteDrive("e"); + actions.resetState(); + expect(state.debugMessages.length).toBe(0); + expect(state.transferProgress.filesDone).toBe(0); + expect(state.favoriteDrives).toEqual([]); + }); + + it("clamps macIndex when the Mac list shrinks", () => { + actions.resetState(); + actions.setMacPodcasts([{ id: "1" }, { id: "2" }, { id: "3" }] as PodcastEpisode[]); + actions.setMacIndex(2); + + actions.setMacPodcasts([{ id: "1" }] as PodcastEpisode[]); + expect(state.macIndex).toBe(0); + + actions.setMacPodcasts([]); + expect(state.macIndex).toBe(0); + }); + + it("clamps driveIndex when the drive list shrinks", () => { + actions.resetState(); + actions.setDrivePodcasts([{ id: "1" }, { id: "2" }, { id: "3" }] as PodcastEpisode[]); + actions.setDriveIndex(2); + + actions.setDrivePodcasts([{ id: "1" }, { id: "2" }] as PodcastEpisode[]); + expect(state.driveIndex).toBe(1); + + actions.setDrivePodcasts([]); + expect(state.driveIndex).toBe(0); + }); + + it("leaves an in-range index alone when a list is replaced", () => { + actions.resetState(); + actions.setMacPodcasts([{ id: "1" }, { id: "2" }, { id: "3" }] as PodcastEpisode[]); + actions.setMacIndex(1); + + actions.setMacPodcasts((prev) => prev.map((ep) => ({ ...ep }))); + expect(state.macIndex).toBe(1); + }); }); diff --git a/src/__tests__/sync.test.ts b/src/__tests__/sync.test.ts index e0db1e0..af397b9 100644 --- a/src/__tests__/sync.test.ts +++ b/src/__tests__/sync.test.ts @@ -8,12 +8,21 @@ * - Progress streaming */ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "bun:test"; import { Effect, Layer, Stream } from "effect"; import { EpisodeMatcherLive } from "@/services/effects/EpisodeMatcher"; -import { createFileSystemTest } from "@/services/effects/FileSystem"; +import { createFileSystemTest, FileSystemLive } from "@/services/effects/FileSystem"; +import { LoggerLive } from "@/services/effects/Logger"; import { createMetadataEditorTest } from "@/services/effects/MetadataEditor"; -import { createSyncEngineTest, formatDestPath, SyncEngine } from "@/services/effects/SyncEngine"; +import { + createSyncEngineTest, + formatDestPath, + SyncEngine, + SyncEngineLive, +} from "@/services/effects/SyncEngine"; import type { Podcast } from "@/types/podcast"; import { sanitizeFilename } from "@/utils/formatting"; @@ -29,6 +38,7 @@ describe("SyncEngine", () => { { id: "ep-1", title: "Episode 1: Getting Started", + author: "Host A", duration: 3600, published: new Date("2024-01-15"), onDrive: false, @@ -38,6 +48,7 @@ describe("SyncEngine", () => { { id: "ep-2", title: "Episode 2: Deep Dive", + author: "Host A", duration: 1800, published: new Date("2024-01-22"), onDrive: true, // Already synced - should be skipped @@ -47,6 +58,7 @@ describe("SyncEngine", () => { { id: "ep-3", title: "Episode 3: Q&A Session", + author: "Host A", duration: 2400, published: new Date("2024-01-29"), onDrive: false, @@ -64,6 +76,7 @@ describe("SyncEngine", () => { { id: "ep-4", title: "The Universe Explained", + author: "Host B", duration: 4200, published: new Date("2024-02-01"), onDrive: false, @@ -359,6 +372,7 @@ describe("SyncEngine", () => { { id: "e1", title: "Ep", + author: "A", duration: 100, published: new Date("2024-01-01"), onDrive: false, @@ -398,6 +412,31 @@ describe("SyncEngine", () => { }); describe("copyFileWithProgress", () => { + it("copies files using live file handles", async () => { + const directory = await mkdtemp(join(tmpdir(), "podapple-sync-")); + const sourcePath = join(directory, "source.mp3"); + const destinationPath = join(directory, "destination.mp3"); + const content = new Uint8Array([1, 2, 3, 4, 5]); + + try { + await Bun.write(sourcePath, content); + + const program = Effect.gen(function* () { + const engine = yield* SyncEngine; + yield* engine.copyFileWithProgress(sourcePath, destinationPath, () => {}); + }); + + const layer = Layer.mergeAll(SyncEngineLive, FileSystemLive).pipe( + Layer.provide(LoggerLive), + ); + await Effect.runPromise(Effect.provide(program, layer)); + + expect(await Bun.file(destinationPath).bytes()).toEqual(content); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + it("copies file and reports progress", async () => { const content = new Uint8Array([1, 2, 3, 4, 5]); const mockFiles = new Map([["/src/file.mp3", content]]); @@ -438,7 +477,7 @@ describe("SyncEngine", () => { }); const result = await Effect.runPromise( - Effect.either( + Effect.result( Effect.provide( program, Layer.mergeAll(createSyncEngineTest(), EpisodeMatcherLive, createFileSystemTest()), @@ -446,7 +485,7 @@ describe("SyncEngine", () => { ), ); - expect(result._tag).toBe("Left"); + expect(result._tag).toBe("Failure"); }); }); diff --git a/src/__tests__/syncEngineLive.test.ts b/src/__tests__/syncEngineLive.test.ts new file mode 100644 index 0000000..8d58d73 --- /dev/null +++ b/src/__tests__/syncEngineLive.test.ts @@ -0,0 +1,232 @@ +/** + * SyncEngineLive execute tests. + * + * These exercise the real engine (not the test double) so that the + * currentIndex contract and the tag-failure cleanup are pinned against + * production code paths. + */ + +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "bun:test"; +import { Effect, Layer, Stream } from "effect"; +import { EpisodeMatcherLive } from "@/services/effects/EpisodeMatcher"; +import { FileSystemLive } from "@/services/effects/FileSystem"; +import { LoggerLive } from "@/services/effects/Logger"; +import { + createMetadataEditorTest, + MetadataEditor, + MetadataError, + type PodcastMetadata, +} from "@/services/effects/MetadataEditor"; +import { SyncEngine, SyncEngineLive } from "@/services/effects/SyncEngine"; +import type { Podcast } from "@/types/podcast"; + +const makePodcast = (root: string, count: number): Podcast[] => [ + { + id: "live-1", + title: "Live Show", + author: "Dan Carlin", + episodeCount: count, + episodes: Array.from({ length: count }, (_, i) => ({ + id: `live-ep-${i + 1}`, + title: `Live Episode ${i + 1}`, + author: "Dan Carlin", + duration: 60, + published: new Date("2024-01-01"), + onDrive: false, + filePath: join(root, `ep${i + 1}.mp3`), + fileSize: 0, + })), + }, +]; + +/** A MetadataEditor whose write always fails, to exercise the tag-failure path. */ +const failingMetadataEditor = () => + Layer.succeed(MetadataEditor, { + write: (path) => Effect.fail(new MetadataError({ path, cause: new Error("tag boom") })), + }); + +/** A MetadataEditor that records what it was asked to write. */ +const recordingMetadataEditor = (sink: PodcastMetadata[]) => + Layer.succeed(MetadataEditor, { + write: (_path, metadata) => + Effect.sync(() => { + sink.push(metadata); + }), + }); + +describe("SyncEngineLive.execute", () => { + it("emits 1-based currentIndex", async () => { + const directory = await mkdtemp(join(tmpdir(), "podapple-live-")); + try { + for (let i = 1; i <= 3; i++) { + await Bun.write(join(directory, `ep${i}.mp3`), new Uint8Array([1, 2, 3])); + } + + const program = Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan( + makePodcast(directory, 3), + join(directory, "out"), + new Map(), + ); + const seen: number[] = []; + const discarded: number[] = []; + yield* Stream.runForEach(engine.execute(plan, join(directory, "out")), (prog) => + Effect.sync(() => { + seen.push(prog.currentIndex); + discarded.push(prog.discarded); + }), + ); + return { seen, discarded }; + }); + + const { seen, discarded } = await Effect.runPromise( + Effect.provide( + program, + Layer.mergeAll( + SyncEngineLive, + EpisodeMatcherLive, + FileSystemLive, + createMetadataEditorTest(), + ).pipe(Layer.provide(LoggerLive)), + ), + ); + + expect(seen.length).toBeGreaterThan(0); + // currentIndex is a 1-based position, emitted before the copy starts. + expect(seen.every((v) => v >= 1)).toBe(true); + // The first file in flight reports 1, not 0. + expect(seen[0]).toBe(1); + // Nothing was discarded: every file tagged cleanly. Compared against a + // same-length array of zeros so an empty run cannot pass vacuously. + expect(discarded).toEqual(seen.map(() => 0)); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("writes the podcast author as the ID3 artist", async () => { + const directory = await mkdtemp(join(tmpdir(), "podapple-artist-")); + const destDir = join(directory, "out"); + try { + await Bun.write(join(directory, "ep1.mp3"), new Uint8Array([1, 2, 3])); + + const written: PodcastMetadata[] = []; + const program = Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan(makePodcast(directory, 1), destDir, new Map()); + yield* Stream.runForEach(engine.execute(plan, destDir), () => Effect.void); + }); + + await Effect.runPromise( + Effect.provide( + program, + Layer.mergeAll( + SyncEngineLive, + EpisodeMatcherLive, + FileSystemLive, + recordingMetadataEditor(written), + ).pipe(Layer.provide(LoggerLive)), + ), + ); + + // The author is what lands in the ID3 artist frame; a blank here is the bug. + expect(written).toHaveLength(1); + expect(written[0]?.artist).toBe("Dan Carlin"); + expect(written[0]?.artist?.trim()).not.toBe(""); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("unlinks the copied file when tagging fails, and still completes", async () => { + const directory = await mkdtemp(join(tmpdir(), "podapple-tagfail-")); + const destDir = join(directory, "out"); + try { + await Bun.write(join(directory, "ep1.mp3"), new Uint8Array([1, 2, 3])); + + const program = Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan(makePodcast(directory, 1), destDir, new Map()); + const statuses: string[] = []; + const counts: number[] = []; + yield* Stream.runForEach(engine.execute(plan, destDir), (prog) => + Effect.sync(() => { + statuses.push(prog.status); + counts.push(prog.discarded); + }), + ); + return { statuses, counts, destPath: plan.toCopy[0]?.destPath }; + }); + + const result = await Effect.runPromise( + Effect.provide( + program, + Layer.mergeAll( + SyncEngineLive, + EpisodeMatcherLive, + FileSystemLive, + failingMetadataEditor(), + ).pipe(Layer.provide(LoggerLive)), + ), + ); + + // A failed tag never fails the sync. + expect(result.statuses[result.statuses.length - 1]).toBe("complete"); + // The half-tagged file must not linger on the drive. + expect(result.destPath).toBeDefined(); + expect(await Bun.file(result.destPath as string).exists()).toBe(false); + // The discard is counted, so the success message can report it + // instead of claiming everything landed. + expect(result.counts[result.counts.length - 1]).toBe(1); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("counts one discard per file across a multi-file run", async () => { + const directory = await mkdtemp(join(tmpdir(), "podapple-count-")); + const destDir = join(directory, "out"); + try { + for (let i = 1; i <= 3; i++) { + await Bun.write(join(directory, `ep${i}.mp3`), new Uint8Array([1, 2, 3])); + } + + const program = Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan(makePodcast(directory, 3), destDir, new Map()); + const counts: number[] = []; + yield* Stream.runForEach(engine.execute(plan, destDir), (prog) => + Effect.sync(() => { + counts.push(prog.discarded); + }), + ); + return counts; + }); + + const counts = await Effect.runPromise( + Effect.provide( + program, + Layer.mergeAll( + SyncEngineLive, + EpisodeMatcherLive, + FileSystemLive, + failingMetadataEditor(), + ).pipe(Layer.provide(LoggerLive)), + ), + ); + + // Three files, three tag failures, three discards. This is what a + // once-per-run or double-counted implementation would get wrong. + expect(counts[counts.length - 1]).toBe(3); + // The count is monotonic, and never exceeds the number of files. + expect(counts.every((v, i) => (i === 0 ? v <= 1 : v >= counts[i - 1]!))).toBe(true); + expect(Math.max(...counts)).toBeLessThanOrEqual(3); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/__tests__/useAppLogic.test.ts b/src/__tests__/useAppLogic.test.ts new file mode 100644 index 0000000..9e5a327 --- /dev/null +++ b/src/__tests__/useAppLogic.test.ts @@ -0,0 +1,900 @@ +import { beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"; +import { + describeError, + makeAppLayerWith, + syncSuccessMessage, + useAppLogic, +} from "@/hooks/useAppLogic"; +import { DriveDetection, DriveDetectionError } from "@/services/effects/DriveDetection"; +import { DriveScan, DriveScanError } from "@/services/effects/DriveScan"; +import { CleanupError, SyncEngine, SyncError } from "@/services/effects/SyncEngine"; +import type { SyncProgress } from "@/types/sync"; +import { actions, state } from "@/store"; +import { selectDrive } from "@/utils/driveSelection"; +import type { Drive } from "@/types/drive"; +import type { PodcastEpisode } from "@/types/podcast"; + +const drive: Drive = { + id: "d1", + name: "USB", + bsdName: "USB", + mountPoint: "/Volumes/USB", + totalSpace: 32_000_000_000, + freeSpace: 16_000_000_000, +}; + +const episode = (id: string): PodcastEpisode => ({ + id, + title: `Episode ${id}`, + author: "Dan Carlin", + duration: 60, + published: new Date("2024-01-01"), + onDrive: false, + filePath: `/mac/${id}.mp3`, + fileSize: 1000, + selected: true, + showName: "Show", +}); + +describe("syncSuccessMessage", () => { + it("reports a clean sync plainly", () => { + expect(syncSuccessMessage(0)).toBe("Sync complete"); + }); + + it("counts a single discarded file in the singular", () => { + // The singular/plural branch is the whole reason this is a function. + expect(syncSuccessMessage(1)).toBe("Sync complete (1 file discarded: tagging failed)"); + }); + + it("pluralises multiple discarded files", () => { + expect(syncSuccessMessage(2)).toBe("Sync complete (2 files discarded: tagging failed)"); + expect(syncSuccessMessage(17)).toBe("Sync complete (17 files discarded: tagging failed)"); + }); + + it("never claims a clean sync while files were discarded", () => { + for (const n of [1, 2, 5, 100]) { + expect(syncSuccessMessage(n)).not.toBe("Sync complete"); + expect(syncSuccessMessage(n)).toContain(String(n)); + } + }); +}); + +describe("useAppLogic.startSync", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("reports a missing drive and sets no success message", () => { + const logic = useAppLogic(); + actions.setCurrentDrive(null); + + logic.startSync([episode("1")]); + + expect(state.errorMsg).toBe("No drive selected"); + // A refused sync is not a success. + expect(state.successMsg).toBe(""); + }); + + it("clears a stale success message when a new sync starts", () => { + const logic = useAppLogic(); + actions.setCurrentDrive(null); + + // Stand in for a previous run that finished cleanly. + actions.setSuccessMsg("Sync complete"); + expect(state.successMsg).toBe("Sync complete"); + + logic.startSync([episode("1")]); + + // The refused sync set an error, and the two are mutually exclusive, so + // the stale success must not survive alongside it. + expect(state.errorMsg).toBe("No drive selected"); + expect(state.successMsg).toBe(""); + }); + + it("clears a stale success message when a drive is selected", () => { + actions.setSuccessMsg("Sync complete"); + expect(state.successMsg).toBe("Sync complete"); + + // selectDrive is the path a drive selection actually takes; it clears + // the status so a previous run's result does not follow the user to the + // new drive. + selectDrive(drive, () => {}); + + expect(state.currentDrive).toBe(drive); + expect(state.successMsg).toBe(""); + }); + + it("does not clear the status when only rescanning the same drive", () => { + const logic = useAppLogic(); + actions.setSuccessMsg("Sync complete"); + + // loadDrivePodcasts is a bare rescan, not a selection. It reports its own + // errors but is not a "new destination", so it leaves the status alone. + logic.loadDrivePodcasts(drive); + + expect(state.successMsg).toBe("Sync complete"); + }); +}); + +describe("status channel exclusivity", () => { + beforeEach(() => { + actions.resetState(); + }); + + it("never holds a success and an error at the same time", () => { + // The invariant the two setters enforce, exercised the way the app does it. + actions.setSuccessMsg("Sync complete"); + actions.setErrorMsg("drive rescan failed"); + expect(state.successMsg).toBe(""); + expect(state.errorMsg).toBe("drive rescan failed"); + + actions.setSuccessMsg("Sync complete"); + expect(state.errorMsg).toBe(""); + expect(state.successMsg).toBe("Sync complete"); + }); +}); + +describe("describeError", () => { + it("prefers a plain Error's message", () => { + expect(describeError(new Error("disk on fire"))).toBe("disk on fire"); + }); + + it("unwraps an Effect tagged error, whose message is empty", () => { + // A Data.TaggedError IS an Error with an empty `message`, so the naive + // `instanceof Error ? err.message : String(err)` yields "" and the user + // is shown nothing at all. + const err = new DriveScanError({ cause: new Error("rescan failed") }); + expect(err instanceof Error).toBe(true); + expect(err.message).toBe(""); + expect(describeError(err)).toBe("rescan failed"); + }); + + it("unwraps an Effect Cause to the underlying error", () => { + // The sync-failure path. Cause.toString() renders the whole wrapper, + // "Cause([Fail(SyncError (cause: Error: no space left))])", where the + // user needs to read "no space left". + const cause = Cause.fail(new SyncError({ episode: "Ep 1", cause: new Error("no space left") })); + expect(cause.toString()).toContain("Cause("); + expect(describeError(cause)).toBe("no space left"); + + expect( + describeError(Cause.fail(new DriveScanError({ cause: new Error("rescan failed") }))), + ).toBe("rescan failed"); + }); + + it("names an interruption rather than rendering a cause wrapper", async () => { + // A real interrupt, the way cancelSync produces one: fork, interrupt, + // then await to collect the Exit. + const forked = Effect.runFork(Effect.never); + await new Promise((resolve) => setTimeout(resolve, 20)); + await Effect.runPromise(Fiber.interrupt(forked)); + const exit = await Effect.runPromise(Fiber.await(forked)); + expect(Exit.isFailure(exit)).toBe(true); + const cause = (exit as unknown as { cause: Cause.Cause }).cause; + expect(Cause.hasInterruptsOnly(cause)).toBe(true); + expect(describeError(cause)).toBe("Cancelled"); + }); + + it("falls back to the tag when there is no message or cause", () => { + expect(describeError(new DriveScanError({ cause: undefined }))).toBe("DriveScanError"); + }); + + it("does not throw on a hostile constructor name", () => { + // It runs inside Effect.catch handlers and the Errored boundary, so a + // throw here would replace the error with a defect, and in index.tsx + // crash the app. + expect(() => describeError({ constructor: { name: "(" } })).not.toThrow(); + expect(describeError({ constructor: { name: "(" } })).toBe("Unknown error"); + }); + + it("never returns something meaningless", () => { + // An empty status renders as nothing, and so does "[object Object]". + expect(describeError(new Error(" "))).toBe("Unknown error"); + expect(describeError(new Error(""))).toBe("Unknown error"); + expect(describeError(null)).toBe("Unknown error"); + expect(describeError(undefined)).toBe("Unknown error"); + expect(describeError({})).toBe("Unknown error"); + }); + + it("still reports primitives, where String() is the whole story", () => { + expect(describeError("disk full")).toBe("disk full"); + expect(describeError(42)).toBe("42"); + expect(describeError(false)).toBe("false"); + }); +}); + +/** + * A DriveScan that fails the post-sync rescan on demand. + * + * This cannot be provoked against a real tempdir: the live scanDrive returns [] + * for a missing Podcasts folder and swallows its own list errors, so it + * essentially never fails on a well-formed directory. + */ +const driveScanDouble = (failScan: boolean) => + Layer.succeed(DriveScan, { + scanDrive: (_drivePath) => + failScan + ? Effect.fail(new DriveScanError({ cause: new Error("rescan failed") })) + : Effect.succeed([]), + buildDriveIndex: () => Effect.succeed(new Map()), + hasPodcastsFolder: () => Effect.succeed(false), + }); + +/** A SyncEngine with nothing to copy, so startSync succeeds immediately. */ +const emptyPlanEngine = Layer.succeed(SyncEngine, { + createPlan: () => Effect.succeed({ toCopy: [], toDelete: [], totalFiles: 0, totalBytes: 0 }), + execute: () => + Stream.succeed({ + currentFile: "", + currentIndex: 0, + totalFiles: 0, + bytesTransferred: 0, + totalBytes: 0, + discarded: 0, + startTime: 0, + status: "complete", + }), + copyFileWithProgress: () => Effect.void, + cleanup: () => Effect.void, +}); + +/** The live layer with only DriveScan and SyncEngine replaced. */ +const testLayer = (failScan: boolean) => + makeAppLayerWith({ scan: driveScanDouble(failScan), sync: emptyPlanEngine }); + +/** Lets the fire-and-forget effects started by startSync run to completion. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + +describe("useAppLogic post-sync rescan gate", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("does not claim success when the rescan after a sync fails", async () => { + const logic = useAppLogic(testLayer(true)); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + + // The rescan's error must survive, and must be visible: the underlying + // cause is what the user needs, since the drive listing is now empty. + expect(state.errorMsg).toBe("rescan failed"); + // Reporting success here would clear that error and claim a clean run. + expect(state.successMsg).toBe(""); + }); + + it("reports success when the rescan succeeds", async () => { + const logic = useAppLogic(testLayer(false)); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + + expect(state.successMsg).toBe("All episodes already synced"); + expect(state.errorMsg).toBe(""); + }); +}); + +/** A scan double whose result for each call is released by the test. */ +const gatedScan = () => { + const gates: Array<(value: unknown[]) => void> = []; + let calls = 0; + const layer = Layer.succeed(DriveScan, { + scanDrive: () => { + calls++; + return Effect.promise( + () => new Promise((resolve) => gates.push(resolve as (v: unknown[]) => void)), + ); + }, + buildDriveIndex: () => Effect.succeed(new Map()), + hasPodcastsFolder: () => Effect.succeed(false), + }); + return { layer, gates, calls: () => calls }; +}; + +const scannedShow = (title: string) => ({ + id: "p1", + title: "Show", + author: "Dan Carlin", + episodeCount: 1, + episodes: [ + { + id: title, + title, + author: "Dan Carlin", + duration: 60, + published: new Date("2024-01-01"), + onDrive: false, + filePath: `/Volumes/USB/${title}.mp3`, + fileSize: 1000, + }, + ], +}); + +describe("useAppLogic drive load staleness", () => { + const otherDrive: Drive = { ...drive, id: "d2", name: "Other", mountPoint: "/Volumes/Other" }; + + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("discards scan results when the current drive changed mid-scan", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const pending = logic.loadDrivePodcasts(drive); + await settle(); + actions.setCurrentDrive(otherDrive); + scan.gates[0]?.([scannedShow("Stale")]); + await pending; + + expect(state.drivePodcasts).toEqual([]); + expect(state.loadingDrive).toBe(false); + }); + + it("discards scan results when the drive was unplugged mid-scan", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const pending = logic.loadDrivePodcasts(drive); + await settle(); + actions.setCurrentDrive(null); + scan.gates[0]?.([scannedShow("Stale")]); + await pending; + + expect(state.drivePodcasts).toEqual([]); + }); + + it("applies scan results for the drive that is still current", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const pending = logic.loadDrivePodcasts(drive); + await settle(); + scan.gates[0]?.([scannedShow("Fresh")]); + await pending; + + expect(state.drivePodcasts.map((e) => e.title)).toEqual(["Fresh"]); + }); + + it("does not surface a stale scan's error", async () => { + const logic = useAppLogic(testLayer(true)); + actions.setCurrentDrive(otherDrive); + + await logic.loadDrivePodcasts(drive); + + expect(state.errorMsg).toBe(""); + }); + + it("clears onDrive markers on Mac episodes when the drive load fails", async () => { + const logic = useAppLogic(testLayer(true)); + actions.setCurrentDrive(drive); + actions.setMacPodcasts([{ ...episode("1"), onDrive: true }]); + + await logic.loadDrivePodcasts(drive); + + expect(state.errorMsg).toBe("rescan failed"); + expect(state.macPodcasts[0]?.onDrive).toBe(false); + }); +}); + +const progressOf = (over: Partial): SyncProgress => ({ + currentFile: "Episode 1", + currentIndex: 1, + totalFiles: 2, + bytesTransferred: 500, + totalBytes: 1000, + discarded: 0, + startTime: Date.now(), + status: "syncing", + ...over, +}); + +describe("useAppLogic transfer progress", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("counts the in-progress file as not yet done and never reports a non-finite speed", async () => { + const nowSpy = spyOn(Date, "now").mockImplementation(() => 1_000_000); + try { + const seen: Array<{ filesDone: number; speed: number }> = []; + const snapshot = () => + seen.push({ + filesDone: state.transferProgress.filesDone, + speed: state.transferProgress.speed, + }); + const items = [ + progressOf({ currentIndex: 1 }), + progressOf({ currentIndex: 2, currentFile: "Episode 2", bytesTransferred: 1000 }), + progressOf({ currentIndex: 2, status: "complete", bytesTransferred: 1000 }), + ]; + const engine = Layer.succeed(SyncEngine, { + createPlan: () => + Effect.succeed({ toCopy: [], toDelete: [], totalFiles: 2, totalBytes: 1000 }), + // Each emission records the state left by the one before it. + execute: () => Stream.fromIterable(items).pipe(Stream.tap(() => Effect.sync(snapshot))), + copyFileWithProgress: () => Effect.void, + cleanup: () => Effect.void, + }); + const logic = useAppLogic(makeAppLayerWith({ scan: driveScanDouble(false), sync: engine })); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + + // Before the first file: 0 done. During file 2: only file 1 is done. + expect(seen.map((s) => s.filesDone)).toEqual([0, 0, 1]); + // startTime == now, so zero ms have elapsed: speed must be 0, not Infinity/NaN. + for (const s of seen) expect(Number.isFinite(s.speed)).toBe(true); + expect(Number.isFinite(state.transferProgress.speed)).toBe(true); + // "complete" reports every file done. + expect(state.transferProgress.filesDone).toBe(2); + } finally { + nowSpy.mockRestore(); + } + }); +}); + +describe("useAppLogic cancelled sync", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + const cancellableEngine = (cleanedUp: string[], failCleanup = false) => + Layer.succeed(SyncEngine, { + createPlan: () => + Effect.succeed({ toCopy: [], toDelete: [], totalFiles: 2, totalBytes: 1000 }), + execute: () => Stream.concat(Stream.make(progressOf({})), Stream.never), + copyFileWithProgress: () => Effect.void, + cleanup: (path) => + Effect.sync(() => { + cleanedUp.push(path); + }).pipe( + Effect.flatMap(() => + failCleanup + ? Effect.fail(new CleanupError({ path, cause: new Error("boom") })) + : Effect.void, + ), + ), + }); + + it("cleans up and rescans the drive without claiming success", async () => { + const cleanedUp: string[] = []; + const scan = gatedScan(); + const logic = useAppLogic( + makeAppLayerWith({ scan: scan.layer, sync: cancellableEngine(cleanedUp) }), + ); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + // The pre-sync index and the rescan are separate scans; only the rescan uses scanDrive. + expect(scan.calls()).toBe(0); + + logic.cancelSync(); + await settle(); + + expect(cleanedUp).toEqual([drive.mountPoint]); + expect(scan.calls()).toBe(1); + scan.gates[0]?.([scannedShow("Partial")]); + await settle(); + + expect(state.drivePodcasts.map((e) => e.title)).toEqual(["Partial"]); + expect(state.successMsg).toBe(""); + expect(state.errorMsg).toBe(""); + expect(state.appView).toBe("normal"); + }); + + it("still rescans when cleanup fails", async () => { + const cleanedUp: string[] = []; + const scan = gatedScan(); + const logic = useAppLogic( + makeAppLayerWith({ scan: scan.layer, sync: cancellableEngine(cleanedUp, true) }), + ); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + logic.cancelSync(); + await settle(); + + expect(cleanedUp).toEqual([drive.mountPoint]); + expect(scan.calls()).toBe(1); + expect(state.successMsg).toBe(""); + }); +}); + +/** A SyncEngine whose plan and execution the test controls. */ +const planEngine = (opts: { + totalBytes: number; + executed: { count: number }; + execute?: () => Stream.Stream; +}) => + Layer.succeed(SyncEngine, { + createPlan: () => + Effect.succeed({ toCopy: [], toDelete: [], totalFiles: 1, totalBytes: opts.totalBytes }), + execute: () => { + opts.executed.count++; + return ( + opts.execute?.() ?? + Stream.succeed(progressOf({ status: "complete", currentIndex: 1, totalFiles: 1 })) + ); + }, + copyFileWithProgress: () => Effect.void, + cleanup: () => Effect.void, + }); + +describe("useAppLogic free-space check", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("refuses a plan larger than the drive's free space without copying", async () => { + const executed = { count: 0 }; + const logic = useAppLogic( + makeAppLayerWith({ + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 2 * 1024 ** 3, executed }), + }), + ); + actions.setCurrentDrive({ ...drive, freeSpace: 1024 ** 3 }); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(0); + expect(state.errorMsg).toBe("Not enough space on USB: need 2.0 GB, have 1.0 GB"); + expect(state.successMsg).toBe(""); + expect(state.appView).toBe("normal"); + }); + + it("allows a plan that exactly fits", async () => { + const executed = { count: 0 }; + const logic = useAppLogic( + makeAppLayerWith({ + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 1000, executed }), + }), + ); + actions.setCurrentDrive({ ...drive, freeSpace: 1000 }); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(1); + expect(state.errorMsg).toBe(""); + expect(state.successMsg).toBe("Sync complete"); + }); + + it("skips the check when free space is unknown (negative)", async () => { + const executed = { count: 0 }; + const logic = useAppLogic( + makeAppLayerWith({ + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 5 * 1024 ** 3, executed }), + }), + ); + actions.setCurrentDrive({ ...drive, freeSpace: -1 }); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(1); + expect(state.errorMsg).toBe(""); + }); +}); + +/** A DriveDetection whose free-space read the test controls; `free` is read on every call. */ +const detectionWith = (free: { value: number | "fail" }) => + Layer.succeed(DriveDetection, { + scanDrives: () => Effect.succeed([]), + getDriveInfo: () => Effect.succeed(null), + getFreeSpace: () => + free.value === "fail" + ? Effect.fail(new DriveDetectionError({ cause: new Error("statfs failed") })) + : Effect.succeed(free.value), + isScanning: () => Effect.succeed(false), + driveEvents: Stream.empty, + }); + +describe("useAppLogic free-space refresh", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("uses fresh free space, not the value captured at detection", async () => { + const executed = { count: 0 }; + const free = { value: (2 * 1024 ** 3) as number | "fail" }; + const logic = useAppLogic( + makeAppLayerWith({ + detection: detectionWith(free), + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 2 * 1024 ** 3, executed }), + }), + ); + // Stale: detected when the drive was nearly full. + const stale = { ...drive, freeSpace: 1024 ** 3 }; + actions.setDrives([stale]); + actions.setCurrentDrive(stale); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(1); + expect(state.errorMsg).toBe(""); + expect(state.currentDrive?.freeSpace).toBe(2 * 1024 ** 3); + expect(state.drives[0]?.freeSpace).toBe(2 * 1024 ** 3); + }); + + it("refuses when fresh free space is smaller than the stale value", async () => { + const executed = { count: 0 }; + const free = { value: (1024 ** 3) as number | "fail" }; + const logic = useAppLogic( + makeAppLayerWith({ + detection: detectionWith(free), + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 2 * 1024 ** 3, executed }), + }), + ); + actions.setCurrentDrive({ ...drive, freeSpace: 16 * 1024 ** 3 }); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(0); + expect(state.errorMsg).toBe("Not enough space on USB: need 2.0 GB, have 1.0 GB"); + }); + + it("falls back to the previous value when the refresh fails", async () => { + const executed = { count: 0 }; + const free = { value: "fail" as number | "fail" }; + const logic = useAppLogic( + makeAppLayerWith({ + detection: detectionWith(free), + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 2 * 1024 ** 3, executed }), + }), + ); + actions.setCurrentDrive({ ...drive, freeSpace: 1024 ** 3 }); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(0); + expect(state.errorMsg).toBe("Not enough space on USB: need 2.0 GB, have 1.0 GB"); + expect(state.currentDrive?.freeSpace).toBe(1024 ** 3); + }); + + it("does not write free space onto a drive that is no longer current", async () => { + const other: Drive = { ...drive, id: "d2", name: "Other", mountPoint: "/Volumes/Other" }; + // The user switches drives while the free-space read is in flight. + const switching = Layer.succeed(DriveDetection, { + scanDrives: () => Effect.succeed([]), + getDriveInfo: () => Effect.succeed(null), + getFreeSpace: () => + Effect.sync(() => { + actions.setCurrentDrive(other); + return 5 * 1024 ** 3; + }), + isScanning: () => Effect.succeed(false), + driveEvents: Stream.empty, + }); + const logic = useAppLogic( + makeAppLayerWith({ detection: switching, scan: driveScanDouble(false) }), + ); + actions.setDrives([drive, other]); + actions.setCurrentDrive(drive); + + logic.deleteSelectedFromDrive(); + await settle(); + + expect(state.currentDrive?.id).toBe("d2"); + expect(state.currentDrive?.freeSpace).toBe(other.freeSpace); + expect(state.drives.map((d) => d.freeSpace)).toEqual([drive.freeSpace, other.freeSpace]); + }); + + it("refreshes free space after episodes are deleted from the drive", async () => { + const free = { value: (9 * 1024 ** 3) as number | "fail" }; + const logic = useAppLogic( + makeAppLayerWith({ detection: detectionWith(free), scan: driveScanDouble(false) }), + ); + actions.setDrives([drive]); + actions.setCurrentDrive(drive); + + logic.deleteSelectedFromDrive(); + await settle(); + + expect(state.currentDrive?.freeSpace).toBe(9 * 1024 ** 3); + expect(state.drives[0]?.freeSpace).toBe(9 * 1024 ** 3); + }); + + it("refreshes free space after a sync completes", async () => { + const free = { value: (7 * 1024 ** 3) as number | "fail" }; + const logic = useAppLogic( + makeAppLayerWith({ + detection: detectionWith(free), + scan: driveScanDouble(false), + sync: emptyPlanEngine, + }), + ); + actions.setDrives([drive]); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + + expect(state.currentDrive?.freeSpace).toBe(7 * 1024 ** 3); + }); +}); + +describe("useAppLogic sync re-entrancy", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("ignores a second startSync while one runs, and keeps the first cancellable", async () => { + const executed = { count: 0 }; + const cleaned: string[] = []; + const engine = Layer.succeed(SyncEngine, { + createPlan: () => Effect.succeed({ toCopy: [], toDelete: [], totalFiles: 1, totalBytes: 10 }), + execute: () => { + executed.count++; + return Stream.concat(Stream.make(progressOf({})), Stream.never); + }, + copyFileWithProgress: () => Effect.void, + cleanup: (path) => + Effect.sync(() => { + cleaned.push(path); + }), + }); + const logic = useAppLogic(makeAppLayerWith({ scan: driveScanDouble(false), sync: engine })); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + // Synchronous second call: before the first has even forked its fiber. + logic.startSync([episode("2")]); + await settle(); + logic.startSync([episode("3")]); + await settle(); + + expect(executed.count).toBe(1); + expect(state.debugMessages.some((m) => JSON.stringify(m).includes("already in progress"))).toBe( + true, + ); + + // The first sync's fiber must still be the one cancelSync reaches. + logic.cancelSync(); + await settle(); + expect(cleaned).toEqual([drive.mountPoint]); + expect(state.appView).toBe("normal"); + }); + + it("allows a new sync once the previous one finished", async () => { + const executed = { count: 0 }; + const logic = useAppLogic( + makeAppLayerWith({ + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 10, executed }), + }), + ); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(2); + }); + + it("allows a new sync after the previous one failed", async () => { + const executed = { count: 0 }; + const logic = useAppLogic( + makeAppLayerWith({ + scan: driveScanDouble(false), + sync: planEngine({ + totalBytes: 10, + executed, + execute: () => + Stream.fail(new SyncError({ episode: "Ep", cause: new Error("boom") })) as never, + }), + }), + ); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + expect(state.errorMsg).toBe("boom"); + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(2); + }); +}); + +describe("useAppLogic loadingDrive generations", () => { + const otherDrive: Drive = { ...drive, id: "d2", name: "Other", mountPoint: "/Volumes/Other" }; + + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("keeps loadingDrive true when a superseded load finishes before the newer one", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const first = logic.loadDrivePodcasts(drive); + await settle(); + const second = logic.loadDrivePodcasts(drive); + await settle(); + expect(scan.gates.length).toBe(2); + + // The stale (first) load finishes while the newer one still runs. + scan.gates[0]?.([]); + await first; + expect(state.loadingDrive).toBe(true); + + scan.gates[1]?.([]); + await second; + expect(state.loadingDrive).toBe(false); + }); + + it("clears loadingDrive when the newest load finishes first, and stays cleared", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const first = logic.loadDrivePodcasts(drive); + await settle(); + const second = logic.loadDrivePodcasts(drive); + await settle(); + + scan.gates[1]?.([]); + await second; + expect(state.loadingDrive).toBe(false); + + scan.gates[0]?.([]); + await first; + expect(state.loadingDrive).toBe(false); + }); + + it("clears loadingDrive when the latest load fails", async () => { + const logic = useAppLogic(testLayer(true)); + actions.setCurrentDrive(drive); + + await logic.loadDrivePodcasts(drive); + + expect(state.errorMsg).toBe("rescan failed"); + expect(state.loadingDrive).toBe(false); + }); + + it("clears loadingDrive when the last load is for a drive that is no longer current", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const pending = logic.loadDrivePodcasts(drive); + await settle(); + actions.setCurrentDrive(otherDrive); + scan.gates[0]?.([]); + await pending; + + expect(state.loadingDrive).toBe(false); + }); +}); diff --git a/src/components/DebugPopup.tsx b/src/components/DebugPopup.tsx index 3f5a06a..69cf4be 100644 --- a/src/components/DebugPopup.tsx +++ b/src/components/DebugPopup.tsx @@ -1,4 +1,4 @@ -import { state } from "@/store"; +import { actions, state } from "@/store"; import { Colors } from "@/theme/colors"; import { Selector } from "./Selector"; @@ -45,6 +45,7 @@ export function DebugPopup(props: DebugPopupProps) { width='85%' height='85%' selectedIndex={state.debugMenuIndex} + onIndexChange={(index) => actions.setDebugMenuIndex(index)} onClose={props.onClose} onShortcutClick={props.onShortcutClick} onSelect={() => {}} diff --git a/src/components/DriveItem.tsx b/src/components/DriveItem.tsx deleted file mode 100644 index fbb49d3..0000000 --- a/src/components/DriveItem.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Colors } from "@/theme/colors"; -import type { Drive } from "@/types/drive"; -import { formatDriveInfo } from "@/utils/drive"; - -export interface DriveItemProps { - drive: Drive; - selected: boolean; -} - -export function DriveItem(props: DriveItemProps) { - const backgroundColor = () => (props.selected ? Colors.list.focused : undefined); - const textColor = () => (props.selected ? Colors.text.inverse : Colors.text.primary); - - return ( - - - {props.selected ? "> " : " "} - {formatDriveInfo(props.drive)} - - - ); -} diff --git a/src/components/EpisodeItem.tsx b/src/components/EpisodeItem.tsx index 5bb8578..2e279ab 100644 --- a/src/components/EpisodeItem.tsx +++ b/src/components/EpisodeItem.tsx @@ -4,6 +4,8 @@ import type { PodcastEpisode } from "@/types/podcast"; import { getEpisodeDescription, getEpisodeTitle } from "@/utils/formatting"; export interface EpisodeItemProps { + id?: string; + onLayout?: () => void; episode: PodcastEpisode; isSelected: boolean; isFocused: boolean; @@ -41,6 +43,8 @@ export function EpisodeItem(props: EpisodeItemProps) { return ( (props.focused ? Colors.border.focused : Colors.border.unfocused); + const follow = createScrollFollow(() => props.selectedIndex); const backgroundColor = () => (props.focused ? Colors.focused : Colors.background); return ( @@ -55,6 +57,7 @@ export function EpisodeList(props: EpisodeListProps) { {(episode, index) => ( { @@ -31,6 +32,8 @@ export function Selector(props: SelectorProps) { props.selectedIndex !== undefined ? props.selectedIndex : internalIndex(), ); + const follow = createScrollFollow(currentIndex); + const handleInteraction = (index: number, item: T) => { if (currentIndex() === index) { props.onSelect(item); @@ -54,7 +57,7 @@ export function Selector(props: SelectorProps) { > - + @@ -63,6 +66,7 @@ export function Selector(props: SelectorProps) { fallback={{props.emptyText || "No items found"}} > (props: SelectorProps) { return ( (props: SelectorProps) { ], ]} onShortcutClick={(key) => { - if (key === "esc") props.onClose(); + // "esc" is fully handled by closing; forwarding it would make the + // parent run its escape action against the already-closed view. + if (key === "esc") { + props.onClose(); + return; + } props.onShortcutClick?.(key); }} /> diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 5dde96a..cb4bb8e 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -1,9 +1,8 @@ -import { createSignal, onCleanup, onMount } from "solid-js"; +import { createSignal, onSettled } from "solid-js"; import { Colors } from "@/theme/colors"; import { getSpinnerFrame, getSpinnerStyle, type SpinnerVariant } from "@/utils/spinners"; export interface SpinnerProps { - active: boolean; label?: string; variant?: SpinnerVariant; } @@ -13,45 +12,16 @@ export function Spinner(props: SpinnerProps) { const getInterval = () => getSpinnerStyle(props.variant).interval; - onMount(() => { - if (!props.active) return; - - const interval = setInterval(() => { - setFrameIndex((i) => i + 1); - }, getInterval()); - - onCleanup(() => clearInterval(interval)); - }); - - // Effect to handle active changes - let intervalId: ReturnType | null = null; - - const startAnimation = () => { - if (intervalId) return; - intervalId = setInterval(() => { + onSettled(() => { + const intervalId = setInterval(() => { setFrameIndex((i) => i + 1); }, getInterval()); - }; - - const stopAnimation = () => { - if (intervalId) { - clearInterval(intervalId); - intervalId = null; - } - }; - onMount(() => { - if (props.active) { - startAnimation(); - } + return () => clearInterval(intervalId); }); - onCleanup(() => stopAnimation()); - const spinnerChar = () => getSpinnerFrame(frameIndex(), props.variant); - if (!props.active) return null; - return ( {spinnerChar()} diff --git a/src/components/ThemePicker.tsx b/src/components/ThemePicker.tsx index 0177477..ff3ab90 100644 --- a/src/components/ThemePicker.tsx +++ b/src/components/ThemePicker.tsx @@ -1,7 +1,6 @@ -import { createEffect } from "solid-js"; import type { useAppLogic } from "@/hooks/useAppLogic"; import { actions, state } from "@/store"; -import { setTheme, Themes } from "@/theme/colors"; +import { previewThemeAt, setTheme, Themes } from "@/theme/colors"; import { Selector } from "./Selector"; interface ThemePickerProps { @@ -12,15 +11,10 @@ interface ThemePickerProps { export function ThemePicker(props: ThemePickerProps) { const themeNames = Themes; - // Sync theme preview with selected index in store - createEffect(() => { - if (state.appView === "themeSelection") { - const themeName = themeNames[state.themeMenuIndex]; - if (themeName) { - setTheme(themeName); - } - } - }); + const handleIndexChange = (index: number) => { + actions.setThemeMenuIndex(index); + previewThemeAt(index); + }; const handleSelect = (themeName: string) => { setTheme(themeName); @@ -40,7 +34,7 @@ export function ThemePicker(props: ThemePickerProps) { visible={true} items={themeNames} selectedIndex={state.themeMenuIndex} - onIndexChange={(index) => actions.setThemeMenuIndex(index)} + onIndexChange={handleIndexChange} formatItem={(name) => ({ name })} onSelect={handleSelect} onClose={handleClose} diff --git a/src/components/TransferPopup.tsx b/src/components/TransferPopup.tsx index 9fd72df..5eb14d5 100644 --- a/src/components/TransferPopup.tsx +++ b/src/components/TransferPopup.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, Show } from "solid-js"; +import { Show } from "solid-js"; import { Spinner } from "@/components/Spinner"; import { Colors } from "@/theme/colors"; import { formatBytes, truncateString } from "@/utils/formatting"; @@ -10,6 +10,7 @@ import { Modal } from "./Modal"; export interface TransferPopupProps { visible: boolean; currentFile: string; + lastFile: string; filesDone: number; totalFiles: number; bytesTransferred: number; @@ -23,15 +24,7 @@ export interface TransferPopupProps { * Shows progress bars, percentages, transfer speed, and counts. */ export function TransferPopup(props: TransferPopupProps) { - const [lastFile, setLastFile] = createSignal(""); - - createEffect(() => { - if (props.currentFile && props.currentFile !== "Preparing...") { - setLastFile(props.currentFile); - } - }); - - const displayFile = () => props.currentFile || lastFile(); + const displayFile = () => props.currentFile || props.lastFile; const progress = () => calculateProgress(props.filesDone, props.totalFiles, props.bytesTransferred, props.totalBytes); @@ -59,7 +52,7 @@ export function TransferPopup(props: TransferPopupProps) { {progressBar()} {percentage()}% - + diff --git a/src/components/index.ts b/src/components/index.ts deleted file mode 100644 index effe161..0000000 --- a/src/components/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { ConfirmPopup, type ConfirmPopupProps } from "./ConfirmPopup"; -export { type DebugMessage, DebugPopup, type DebugPopupProps } from "./DebugPopup"; -export { DriveItem, type DriveItemProps } from "./DriveItem"; -export { DriveSelector, type DriveSelectorProps } from "./DriveSelector"; -export { EpisodeItem, type EpisodeItemProps } from "./EpisodeItem"; -export { EpisodeList, type EpisodeListProps } from "./EpisodeList"; -export { Footer, type FooterProps, type Shortcut } from "./Footer"; -export { Header, type HeaderProps } from "./Header"; -export { Modal, type ModalProps } from "./Modal"; -export { Selector, type SelectorProps } from "./Selector"; -export { Spinner, type SpinnerProps } from "./Spinner"; -export { TransferPopup, type TransferPopupProps } from "./TransferPopup"; diff --git a/src/components/scrollFollow.ts b/src/components/scrollFollow.ts new file mode 100644 index 0000000..7a00ad3 --- /dev/null +++ b/src/components/scrollFollow.ts @@ -0,0 +1,39 @@ +import type { ScrollBoxRenderable } from "@opentui/core"; +import { createRenderEffect } from "solid-js"; + +let scopeCounter = 0; + +/** + * Keeps the selected row of a `` visible. + * + * Wire-up: pass `follow.ref` as the scrollbox `ref`, and spread + * `follow.rowProps(() => index)` onto each row. + * + * Two triggers cover the two cases a plain effect cannot: + * - the selected index changing (render effect; layout from the previous + * frame is still valid because rows keep their size when selected), and + * - the selected row receiving its first layout (`onSizeChange`), which is + * when it is actually measurable on open or after the list is replaced. + */ +export function createScrollFollow(selectedIndex: () => number) { + const scope = `scroll-follow-${scopeCounter++}`; + let box: ScrollBoxRenderable | undefined; + + const rowId = (index: number) => `${scope}-row-${index}`; + + const sync = (index: number) => { + box?.scrollChildIntoView(rowId(index)); + }; + + createRenderEffect(selectedIndex, sync); + + return { + ref: (el: ScrollBoxRenderable) => { + box = el; + }, + rowId, + onRowLayout: (index: () => number) => () => { + if (index() === selectedIndex()) sync(index()); + }, + }; +} diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index 4051ef9..c6bfb10 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -1,7 +1,8 @@ import { useKeyboard, useRenderer } from "@opentui/solid"; import { actions, state } from "@/store"; -import { setTheme, Themes } from "@/theme/colors"; +import { previewThemeAt, setTheme, Themes } from "@/theme/colors"; import { copyToClipboard } from "@/utils/clipboard"; +import { selectDrive } from "@/utils/driveSelection"; import { quitApp } from "@/utils/terminal"; import type { useAppLogic } from "./useAppLogic"; @@ -41,9 +42,13 @@ export const useAppKeyboard = (logic: ReturnType) => { setTheme(state.lastSavedTheme); actions.setAppView("normal"); } else if (key === "up" || key === "k") { - actions.setThemeMenuIndex((i) => Math.max(0, i - 1)); + const index = Math.max(0, state.themeMenuIndex - 1); + actions.setThemeMenuIndex(index); + previewThemeAt(index); } else if (key === "down" || key === "j") { - actions.setThemeMenuIndex((i) => Math.min(Themes.length - 1, i + 1)); + const index = Math.min(Themes.length - 1, state.themeMenuIndex + 1); + actions.setThemeMenuIndex(index); + previewThemeAt(index); } else if (key === "return" || key === "enter") { const themeName = Themes[state.themeMenuIndex]; if (themeName) { @@ -63,11 +68,20 @@ export const useAppKeyboard = (logic: ReturnType) => { const handleNormalActions = (key: string, ctrl: boolean): void => { if (ctrl && key === "t") { const currentIndex = (Themes as string[]).indexOf(state.lastSavedTheme); - actions.setThemeMenuIndex(currentIndex >= 0 ? currentIndex : 0); + const index = currentIndex >= 0 ? currentIndex : 0; + actions.setThemeMenuIndex(index); + if (currentIndex >= 0) previewThemeAt(index); + else setTheme(state.lastSavedTheme); actions.setAppView("themeSelection"); return; } + // Letter (and space) actions are for the plain key only. Ctrl+ is a + // different chord: ctrl+t is handled above, and ctrl+u/ctrl+d arrive remapped + // to page keys with ctrl cleared, so anything still carrying ctrl here must + // not fall through to the plain action. + if (ctrl && (key.length === 1 || key === "space")) return; + if (key === "f1") { actions.setDebugMenuIndex(Math.max(0, state.debugMessages.length - 1)); actions.setAppView("debug"); @@ -185,13 +199,11 @@ export const useAppKeyboard = (logic: ReturnType) => { } else if (key === "up" || key === "k") { actions.setDriveMenuIndex((i) => Math.max(0, i - 1)); } else if (key === "down" || key === "j") { - actions.setDriveMenuIndex((i) => Math.min(state.drives.length - 1, i + 1)); + actions.setDriveMenuIndex((i) => Math.max(0, Math.min(state.drives.length - 1, i + 1))); } else if (key === "return" || key === "enter") { const drive = state.drives[state.driveMenuIndex]; if (drive) { - actions.setCurrentDrive(drive); - actions.setAppView("normal"); - logic.loadDrivePodcasts(drive); + selectDrive(drive, logic.loadDrivePodcasts); } } else if (ctrl && (key === "f" || key === "F")) { const drive = state.drives[state.driveMenuIndex]; @@ -215,7 +227,9 @@ export const useAppKeyboard = (logic: ReturnType) => { } else if (key === "up" || key === "k") { actions.setDebugMenuIndex((i) => Math.max(0, i - 1)); } else if (key === "down" || key === "j") { - actions.setDebugMenuIndex((i) => Math.min(state.debugMessages.length - 1, i + 1)); + actions.setDebugMenuIndex((i) => + Math.max(0, Math.min(state.debugMessages.length - 1, i + 1)), + ); } else if (key === "return" || key === "enter") { const message = state.debugMessages[state.debugMenuIndex]; if (message) { diff --git a/src/hooks/useAppLogic.ts b/src/hooks/useAppLogic.ts index ebcefce..4132109 100644 --- a/src/hooks/useAppLogic.ts +++ b/src/hooks/useAppLogic.ts @@ -1,6 +1,6 @@ import { dirname } from "node:path"; -import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"; -import { onMount, onCleanup } from "solid-js"; +import { Cause, Effect, Exit, Fiber, Layer, Option, Stream } from "effect"; +import { onSettled } from "solid-js"; import { DriveDetection, DriveDetectionLive, @@ -27,6 +27,7 @@ import { setTheme } from "@/theme/colors"; import type { Drive } from "@/types/drive"; import type { PodcastEpisode } from "@/types/podcast"; import { + formatBytes, groupEpisodesByPodcast, mapPodcastsToEpisodes, markEpisodesOnDrive, @@ -76,29 +77,168 @@ type AppRequirements = | MetadataEditor | Logger; +/** + * Assembles the service graph from its leaves. + * + * Each service is a parameter so tests can substitute one. The alternatives are + * a drive, a database or an unplugged cable, none of which belong in a unit + * test. Passed the live layers, the result is the app's production wiring. + */ +const buildLayer = (services: { + detection: Layer.Layer; + scan: Layer.Layer; + sync: Layer.Layer; + scanDeps: Layer.Layer; + syncDeps: Layer.Layer; +}): Layer.Layer => + Layer.mergeAll( + services.detection, + Podcast.pipe(Layer.provide(services.scanDeps)), + services.scanDeps, + services.scan, + services.syncDeps, + services.sync, + Settings.pipe(Layer.provide(services.scanDeps)), + ) as Layer.Layer; + +/** + * The live service layer. Exposed so tests can supply their own. + */ +export const makeAppLayer = (): Layer.Layer => + AppLayer as unknown as Layer.Layer; + +/** + * The production wiring with a substitute for the services a test needs to + * control. Only DriveScan and SyncEngine are substitutable: the others are + * leaves, and a double for one would need its own dependencies wired in. + */ +export const makeAppLayerWith = (overrides: { + detection?: Layer.Layer; + scan?: Layer.Layer; + sync?: Layer.Layer; +}): Layer.Layer => { + // The substitution has to win over the live layer it shadows, and in a merge + // the later layer does. Scan and Sync arrive already provided Base, so the + // override is merged at the leaves rather than over the whole graph. + const scanLayer = overrides.scan + ? (Layer.mergeAll(Scan, overrides.scan) as Layer.Layer) + : Scan; + const syncLayer = overrides.sync + ? (Layer.mergeAll(Sync, overrides.sync) as Layer.Layer) + : Sync; + const detectionLayer = overrides.detection + ? (Layer.mergeAll( + Detection.pipe(Layer.provide(Base)), + overrides.detection, + ) as Layer.Layer) + : Detection.pipe(Layer.provide(Base)); + return buildLayer({ + detection: detectionLayer, + scan: scanLayer, + sync: syncLayer, + scanDeps: Base, + syncDeps: Layer.mergeAll(Base, scanLayer), + }) as unknown as Layer.Layer; +}; + +const nonEmpty = (value: unknown): value is string => + typeof value === "string" && value.trim() !== ""; + +/** + * Renders an error for the status line. + * + * Deliberately not `err instanceof Error ? err.message : String(err)`: Effect's + * tagged errors (Data.TaggedError) are Errors with an empty `message`, so that + * check silently produces an empty status and the user sees nothing at all. + * + * Prefers a real message, then the cause, then the tag. Anything that would + * stringify to something meaningless (an empty string, a bare class name, + * "[object Object]", "null") becomes "Unknown error", because a status line + * showing that is worse than one admitting it does not know. + */ +export const describeError = (err: unknown): string => { + // An Effect Cause wraps the real failure. Unwrap it, or the user reads + // "Cause([Fail(SyncError (cause: Error: disk full))])" instead of "disk full". + if (Cause.isCause(err)) { + return Option.match(Cause.findErrorOption(err), { + onNone: () => (Cause.hasInterruptsOnly(err) ? "Cancelled" : describeError(Cause.pretty(err))), + onSome: (e) => describeError(e), + }); + } + + if (err instanceof Error && nonEmpty(err.message)) return err.message; + + if (typeof err === "object" && err !== null) { + const { cause } = err as { cause?: unknown }; + if (nonEmpty(cause)) return cause; + if (cause instanceof Error && nonEmpty(cause.message)) return cause.message; + const { _tag } = err as { _tag?: unknown }; + if (nonEmpty(_tag)) return _tag; + } + + // Nothing usable on the value itself. String() is only worth it for + // primitives, where it is the whole story. + if (typeof err === "string") return err.trim() === "" ? "Unknown error" : err; + if (typeof err === "number" || typeof err === "boolean") return String(err); + + return "Unknown error"; +}; + +/** + * Builds the success message for a completed sync. + * + * Files are discarded when tagging fails, so the sync still succeeds but the + * drive may be missing files. That has to be visible in the message, or the + * user is told everything landed when it did not. + */ +export const syncSuccessMessage = (discarded: number): string => + discarded > 0 + ? `Sync complete (${discarded} ${discarded === 1 ? "file" : "files"} discarded: tagging failed)` + : "Sync complete"; + +/** + * Bytes per second since `startTime`, or 0 when no time has elapsed (which would + * otherwise divide by zero and yield Infinity or NaN). + */ +const transferSpeed = (bytesTransferred: number, startTime: number): number => { + if (startTime <= 0) return 0; + const elapsedMs = Date.now() - startTime; + return elapsedMs > 0 ? bytesTransferred / (elapsedMs / 1000) : 0; +}; + /** * Hook containing the core application logic, orchestrating various Effect services. + * + * @param layer Service layer to run against. Defaults to the live services; + * tests pass fakes to drive the real control flow. */ -export const useAppLogic = () => { - let driveListenerFiber: Fiber.RuntimeFiber | undefined; - let activeSyncFiber: Fiber.RuntimeFiber | undefined; +export const useAppLogic = (layer: Layer.Layer = makeAppLayer()) => { + let driveListenerFiber: Fiber.Fiber | undefined; + let activeSyncFiber: Fiber.Fiber | undefined; + // True from startSync until its whole run (copy, rescan) ends. activeSyncFiber + // is only set once the fiber is forked, so it cannot guard a second call that + // arrives in between. + let syncRunning = false; + // Bumped by every drive load (and by the current drive disappearing). Only the + // load holding the latest value may clear loadingDrive, so a superseded load + // finishing late cannot switch the spinner off while a newer one still runs. + let loadGeneration = 0; + let runningLoads = 0; /** * Runs an effect to completion using the AppLayer. */ const run = (effect: Effect.Effect) => - Effect.runPromise(Effect.provide(effect, AppLayer as unknown as Layer.Layer)).catch( - (err) => { - console.error("Unhandled Effect Promise rejection:", err); - actions.addDebugMessage(`Unhandled error: ${String(err)}`, "error"); - }, - ); + Effect.runPromise(Effect.provide(effect, layer as unknown as Layer.Layer)).catch((err) => { + console.error("Unhandled Effect Promise rejection:", err); + actions.addDebugMessage(`Unhandled error: ${String(err)}`, "error"); + }); /** * Runs an effect as a fiber using the AppLayer. */ const runFork = (effect: Effect.Effect) => - Effect.runFork(Effect.provide(effect, AppLayer as unknown as Layer.Layer)); + Effect.runFork(Effect.provide(effect, layer as unknown as Layer.Layer)); /** * Helper for view transitions that ensures a view is set during effect execution @@ -113,33 +253,91 @@ export const useAppLogic = () => { () => Effect.sync(() => actions.setAppView("normal")), ); + const isCurrentDrive = (drive: Drive): boolean => state.currentDrive?.id === drive.id; + /** - * Loads podcasts from a drive using the DriveScan service. + * Re-reads the drive's free space, which is otherwise frozen at detection + * time and goes stale after a sync or delete. The store is updated only if + * the drive is still current. If the read fails, the last known value stands. + * Resolves to the best known free space for `drive`. */ - const loadDrivePodcastsEffect = (drive: Drive) => + const refreshFreeSpace = (drive: Drive): Effect.Effect => Effect.gen(function* () { - const logger = yield* Logger; - actions.setLoadingDrive(true); - const driveScan = yield* DriveScan; - const podcasts = yield* driveScan.scanDrive(drive.mountPoint); - const episodes = mapPodcastsToEpisodes(podcasts); + const detection = yield* DriveDetection; + const known = + state.currentDrive?.id === drive.id ? state.currentDrive.freeSpace : drive.freeSpace; + const fresh = yield* detection + .getFreeSpace(drive.mountPoint) + .pipe(Effect.catch(() => Effect.succeed(undefined))); + if (fresh === undefined) return known; + if (isCurrentDrive(drive)) { + const withFree = (d: Drive): Drive => (d.id === drive.id ? { ...d, freeSpace: fresh } : d); + actions.setCurrentDrive((prev) => (prev ? withFree(prev) : prev)); + actions.setDrives((prev) => prev.map(withFree)); + } + return fresh; + }); - actions.setDrivePodcasts(episodes); - yield* logger.debug(`Loaded ${episodes.length} drive episodes`); + /** + * With no readable drive listing, no Mac episode can claim to be on the drive. + */ + const clearOnDriveMarkers = () => { + actions.setMacPodcasts((prev) => markEpisodesOnDrive(prev, [])); + }; - // Mark Mac podcasts that are on the drive - const updated = markEpisodesOnDrive(state.macPodcasts, episodes); - actions.setMacPodcasts(updated); - }).pipe( - Effect.catchAll((err) => { - const errorMessage = err instanceof Error ? err.message : String(err); - actions.setErrorMsg(errorMessage); - actions.addDebugMessage(errorMessage, "error"); - actions.setDrivePodcasts([]); - return Effect.void; - }), - Effect.onExit(() => Effect.sync(() => actions.setLoadingDrive(false))), - ); + /** + * Loads podcasts from a drive using the DriveScan service. + */ + const loadDrivePodcastsEffect = ( + drive: Drive, + ): Effect.Effect => + // Suspended so the generation is taken when the load runs, not when the + // effect is constructed. + Effect.suspend(() => { + const generation = ++loadGeneration; + runningLoads++; + return Effect.gen(function* () { + const logger = yield* Logger; + actions.setLoadingDrive(true); + const driveScan = yield* DriveScan; + const podcasts = yield* driveScan.scanDrive(drive.mountPoint); + + // The scan is slow and the user can switch or unplug the drive while it + // runs. Results for a drive that is no longer current are discarded, or + // they would show one drive's episodes under another. + if (!isCurrentDrive(drive)) return false; + + const episodes = mapPodcastsToEpisodes(podcasts); + + actions.setDrivePodcasts(episodes); + yield* logger.debug(`Loaded ${episodes.length} drive episodes`); + + // Mark Mac podcasts that are on the drive + const updated = markEpisodesOnDrive(state.macPodcasts, episodes); + actions.setMacPodcasts(updated); + return true; + }).pipe( + // Yields false when the rescan failed, so a caller that is about to report + // success does not overwrite the error set here. The two status fields are + // mutually exclusive, so a later setSuccessMsg would clear it. + Effect.catch((err) => { + if (!isCurrentDrive(drive)) return Effect.succeed(false); + const errorMessage = describeError(err); + actions.setErrorMsg(errorMessage); + actions.addDebugMessage(errorMessage, "error"); + actions.setDrivePodcasts([]); + clearOnDriveMarkers(); + return Effect.succeed(false); + }), + // Runs on success, failure and interruption alike. + Effect.onExit(() => + Effect.sync(() => { + runningLoads--; + if (generation === loadGeneration) actions.setLoadingDrive(false); + }), + ), + ); + }); /** * Imperative wrapper for loadDrivePodcastsEffect. @@ -180,8 +378,8 @@ export const useAppLogic = () => { } } }).pipe( - Effect.catchAll((err) => { - const errorMessage = err instanceof Error ? err.message : String(err); + Effect.catch((err) => { + const errorMessage = describeError(err); actions.setErrorMsg(errorMessage); actions.addDebugMessage(errorMessage, "error"); actions.setDrives([]); @@ -190,7 +388,8 @@ export const useAppLogic = () => { Effect.onExit(() => Effect.sync(() => { actions.setIsScanning(false); - actions.setLoadingDrive(false); + // A load started meanwhile (the user picked a drive) clears its own flag. + if (runningLoads === 0) actions.setLoadingDrive(false); }), ), ); @@ -234,14 +433,19 @@ export const useAppLogic = () => { if (state.currentDrive?.id === event.driveId) { actions.setCurrentDrive(null); actions.setDrivePodcasts([]); + clearOnDriveMarkers(); + // A load for the unplugged drive is now pointless. Supersede it so + // the spinner stops here instead of waiting on a scan that may hang. + loadGeneration++; + actions.setLoadingDrive(false); } yield* logger.info(`Drive disappeared: ${event.driveId}`); } }), ); }).pipe( - Effect.catchAll((err) => { - const errorMessage = err instanceof Error ? err.message : String(err); + Effect.catch((err) => { + const errorMessage = describeError(err); actions.addDebugMessage(`Drive listener error: ${errorMessage}`, "error"); return Effect.void; }), @@ -263,7 +467,7 @@ export const useAppLogic = () => { } return settings; }).pipe( - Effect.catchAll(() => { + Effect.catch(() => { actions.addDebugMessage("Failed to load settings", "error"); return Effect.succeed({ theme: "Catppuccin", favoriteDrives: [] }); }), @@ -283,7 +487,7 @@ export const useAppLogic = () => { }); yield* logger.info(`Toggled favorite: ${driveId}`); }).pipe( - Effect.catchAll(() => { + Effect.catch(() => { actions.addDebugMessage("Failed to save favorites", "error"); return Effect.void; }), @@ -303,7 +507,7 @@ export const useAppLogic = () => { actions.setLastSavedTheme(themeName); yield* logger.info(`Saved theme: ${themeName}`); }).pipe( - Effect.catchAll(() => { + Effect.catch(() => { actions.addDebugMessage("Failed to save theme", "error"); return Effect.void; }), @@ -329,8 +533,8 @@ export const useAppLogic = () => { actions.setMacPodcasts(episodes); } }).pipe( - Effect.catchAll((err) => { - const errorMessage = err instanceof Error ? err.message : String(err); + Effect.catch((err) => { + const errorMessage = describeError(err); actions.setErrorMsg(errorMessage); actions.addDebugMessage(errorMessage, "error"); return Effect.void; @@ -354,8 +558,8 @@ export const useAppLogic = () => { const episodes = yield* podcastService.loadMacPodcasts; actions.setMacPodcasts(episodes); }).pipe( - Effect.catchAll((err) => { - actions.setErrorMsg(err instanceof Error ? err.message : String(err)); + Effect.catch((err) => { + actions.setErrorMsg(describeError(err)); return Effect.void; }), Effect.onExit(() => Effect.sync(() => actions.setLoadingMac(false))), @@ -372,6 +576,14 @@ export const useAppLogic = () => { * Starts the sync operation for selected episodes. */ const startSync = (episodesToSync: PodcastEpisode[]) => { + // A second sync would overwrite activeSyncFiber and make the first + // uncancellable. Ignore it, and leave the current status untouched. + if (syncRunning) { + actions.addDebugMessage("Sync already in progress; ignoring request"); + return; + } + // A new sync supersedes any previous outcome message. + actions.setSuccessMsg(""); const drive = state.currentDrive; if (!drive) { actions.setErrorMsg("No drive selected"); @@ -383,6 +595,7 @@ export const useAppLogic = () => { return; } + syncRunning = true; actions.updateTransferProgress({ currentFile: "Preparing...", filesDone: 0, @@ -412,6 +625,16 @@ export const useAppLogic = () => { return { success: true, message: "All episodes already synced" }; } + // Refuse up front rather than fail midway with a half-copied drive. A + // negative freeSpace means the drive could not report it, so no check. + const freeSpace = yield* refreshFreeSpace(drive); + if (freeSpace >= 0 && plan.totalBytes > freeSpace) { + return { + success: false, + message: `Not enough space on ${drive.name}: need ${formatBytes(plan.totalBytes)}, have ${formatBytes(freeSpace)}`, + }; + } + // Initialize progress with plan info actions.updateTransferProgress({ totalFiles: plan.totalFiles, @@ -420,18 +643,26 @@ export const useAppLogic = () => { const stream = syncEngine.execute(plan, drive.mountPoint); + // Files copied but discarded because tagging failed. The sync still + // succeeds, so the count rides along in the success message rather + // than failing the run. + let discarded = 0; + yield* Stream.runForEach(stream, (progress) => Effect.sync(() => { + discarded = progress.discarded; + actions.updateTransferProgress({ currentFile: progress.currentFile, - filesDone: progress.status === "complete" ? progress.totalFiles : progress.currentIndex, + // currentIndex is the 1-based file in progress, so it is not done yet. + filesDone: + progress.status === "complete" + ? progress.totalFiles + : Math.max(0, progress.currentIndex - 1), totalFiles: progress.totalFiles, bytesTransferred: progress.bytesTransferred, totalBytes: progress.totalBytes, - speed: - progress.startTime > 0 - ? progress.bytesTransferred / ((Date.now() - progress.startTime) / 1000) - : 0, + speed: transferSpeed(progress.bytesTransferred, progress.startTime), }); if (progress.status === "error" && progress.error) { @@ -443,37 +674,62 @@ export const useAppLogic = () => { actions.updateTransferProgress({ currentFile: "Finalizing drive..." }); yield* syncEngine.cleanup(drive.mountPoint); - return { success: true, message: "Sync complete" }; + return { success: true, message: syncSuccessMessage(discarded) }; }); run( Effect.gen(function* () { const logger = yield* Logger; - const fiber = yield* Effect.fork(syncProgram); + const syncEngine = yield* SyncEngine; + const fiber = yield* Effect.forkChild(syncProgram); activeSyncFiber = fiber; const exit = yield* Fiber.await(fiber); activeSyncFiber = undefined; + // Whatever the outcome, files may have landed: the cached free space + // is stale. + yield* refreshFreeSpace(drive); + if (Exit.isSuccess(exit)) { if (exit.value.success) { - yield* loadDrivePodcastsEffect(drive); + const rescanOk = yield* loadDrivePodcastsEffect(drive); actions.setMacPodcasts((prev) => prev.map((ep) => ({ ...ep, selected: false }))); + // The rescan sets its own error if it failed; setting success here + // would clear it and claim a clean outcome that did not happen. + if (rescanOk) { + actions.setSuccessMsg(exit.value.message); + } } else { actions.setErrorMsg(exit.value.message); } } else { const cause = exit.cause; - if (Cause.isInterruptedOnly(cause)) { + if (Cause.hasInterruptsOnly(cause)) { yield* logger.info("Sync cancelled by user"); actions.setErrorMsg(""); + // Files copied before the cancel are on the drive: tidy up what the + // interrupted run left behind and rescan so they show. No success + // message, since the sync did not finish. + yield* syncEngine.cleanup(drive.mountPoint).pipe(Effect.catch(() => Effect.void)); + yield* loadDrivePodcastsEffect(drive); } else { - const err = cause.toString(); yield* logger.error("Sync failed", cause); - actions.setErrorMsg(String(err)); + // Not cause.toString(): that renders the whole Effect cause + // wrapper, e.g. "Cause([Fail(SyncError (cause: Error: disk full))])", + // where the user needs to read "disk full". + actions.setErrorMsg(describeError(cause)); } } - }).pipe(withView("syncing")), + }).pipe( + withView("syncing"), + Effect.ensuring( + Effect.sync(() => { + syncRunning = false; + activeSyncFiber = undefined; + }), + ), + ), ); }; @@ -495,7 +751,7 @@ export const useAppLogic = () => { // Delete the episode files yield* Effect.forEach( selected, - (ep) => fs.remove(ep.filePath).pipe(Effect.catchAll(() => Effect.void)), + (ep) => fs.remove(ep.filePath).pipe(Effect.catch(() => Effect.void)), { discard: true }, ); @@ -506,10 +762,10 @@ export const useAppLogic = () => { Effect.gen(function* () { const exists = yield* fs.exists(dir); if (exists) { - yield* fs.cleanupSystemHiddenFiles(dir).pipe(Effect.catchAll(() => Effect.void)); + yield* fs.cleanupSystemHiddenFiles(dir).pipe(Effect.catch(() => Effect.void)); const empty = yield* fs.isDirEmpty(dir); if (empty) { - yield* fs.remove(dir).pipe(Effect.catchAll(() => Effect.void)); + yield* fs.remove(dir).pipe(Effect.catch(() => Effect.void)); } } }), @@ -517,6 +773,7 @@ export const useAppLogic = () => { ); if (drive) { + yield* refreshFreeSpace(drive); yield* loadDrivePodcastsEffect(drive); } actions.setDrivePodcasts((prev) => prev.map((ep) => ({ ...ep, selected: false }))); @@ -534,17 +791,16 @@ export const useAppLogic = () => { } }; - onMount(() => { + onSettled(() => { initialize(); - }); - - onCleanup(() => { - if (driveListenerFiber) { - Effect.runFork(Fiber.interrupt(driveListenerFiber)); - } - if (activeSyncFiber) { - Effect.runFork(Fiber.interrupt(activeSyncFiber)); - } + return () => { + if (driveListenerFiber) { + Effect.runFork(Fiber.interrupt(driveListenerFiber)); + } + if (activeSyncFiber) { + Effect.runFork(Fiber.interrupt(activeSyncFiber)); + } + }; }); return { diff --git a/src/index.tsx b/src/index.tsx index f09b999..32824aa 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,5 @@ import { render, useTerminalDimensions } from "@opentui/solid"; -import { createMemo, ErrorBoundary, Show } from "solid-js"; +import { createMemo, Errored, Show } from "solid-js"; import { ConfirmPopup } from "@/components/ConfirmPopup"; import { DebugPopup } from "@/components/DebugPopup"; import { DriveSelector } from "@/components/DriveSelector"; @@ -8,9 +8,10 @@ import { Header } from "@/components/Header"; import { ThemePicker } from "@/components/ThemePicker"; import { TransferPopup } from "@/components/TransferPopup"; import { useAppKeyboard } from "@/hooks/useAppKeyboard"; -import { useAppLogic } from "@/hooks/useAppLogic"; +import { describeError, useAppLogic } from "@/hooks/useAppLogic"; import { actions, state } from "@/store"; import { Colors } from "@/theme/colors"; +import { selectDrive } from "@/utils/driveSelection"; import { getFooterShortcuts } from "@/utils/keyboard"; import { DriveView } from "@/views/DriveView"; import { PodcastView } from "@/views/PodcastView"; @@ -114,6 +115,12 @@ const App = () => { + + + {state.successMsg} + + + @@ -128,9 +135,7 @@ const App = () => { visible={true} isScanning={state.isScanning} onSelect={(drive) => { - actions.setCurrentDrive(drive); - logic.loadDrivePodcasts(drive); - actions.setAppView("normal"); + selectDrive(drive, logic.loadDrivePodcasts); }} onClose={() => actions.setAppView("normal")} onShortcutClick={handleShortcutClick} @@ -141,6 +146,7 @@ const App = () => { { const Root = () => { const terminalDimensions = useTerminalDimensions(); return ( - ( - - Fatal Error Occurred: - - {err.message} - - - Press Ctrl+C to exit. + { + const error = err(); + return ( + + Fatal Error Occurred: + + {describeError(error)} + + + Press Ctrl+C to exit. + - - )} + ); + }} > - + ); }; diff --git a/src/render_tests/episode_list.test.tsx b/src/render_tests/episode_list.test.tsx new file mode 100644 index 0000000..7523ddd --- /dev/null +++ b/src/render_tests/episode_list.test.tsx @@ -0,0 +1,79 @@ +import { createSignal } from "solid-js"; +import { testRender } from "@opentui/solid"; +import { describe, expect, it } from "bun:test"; +import { DebugPopup } from "@/components/DebugPopup"; +import { EpisodeList } from "@/components/EpisodeList"; +import { state } from "@/store"; +import type { PodcastEpisode } from "@/types/podcast"; + +const episodes: PodcastEpisode[] = Array.from({ length: 40 }, (_, n) => ({ + id: `e${n}`, + title: `episode-${n}`, + author: "author", + duration: 60, + published: new Date(0), + onDrive: false, + filePath: `/tmp/e${n}.mp3`, + fileSize: 1, + showName: "show", + selected: false, +})); + +const settle = async (setup: { renderOnce: () => Promise }) => { + await setup.renderOnce(); + await setup.renderOnce(); + await setup.renderOnce(); +}; + +describe("EpisodeList (rendered)", () => { + it("keeps the selected episode visible on open and as the selection moves", async () => { + const [index, setIndex] = createSignal(30); + const setup = await testRender( + () => ( + + ), + { width: 60, height: 20 }, + ); + await settle(setup); + expect(setup.captureCharFrame()).toContain("episode-30"); + + setIndex(1); + await settle(setup); + expect(setup.captureCharFrame()).toContain("episode-1"); + expect(setup.captureCharFrame()).not.toContain("episode-30"); + + setIndex(38); + await settle(setup); + expect(setup.captureCharFrame()).toContain("episode-38"); + setup.renderer.destroy(); + }); +}); + +describe("DebugPopup (rendered)", () => { + it("clicking a row moves the debug menu selection", async () => { + const messages = Array.from({ length: 5 }, (_, n) => ({ + timestamp: 0, + message: `debug-message-${n}`, + type: "info" as const, + })); + const setup = await testRender( + () => {}} />, + { width: 80, height: 30 }, + ); + await settle(setup); + expect(state.debugMenuIndex).toBe(0); + + const lines = setup.captureCharFrame().split("\n"); + const row = lines.findIndex((line) => line.includes("debug-message-3")); + expect(row).toBeGreaterThanOrEqual(0); + await setup.mockMouse.click(lines[row]!.indexOf("debug-message-3"), row); + await settle(setup); + expect(state.debugMenuIndex).toBe(3); + setup.renderer.destroy(); + }); +}); diff --git a/src/render_tests/selector.test.tsx b/src/render_tests/selector.test.tsx new file mode 100644 index 0000000..e9212b0 --- /dev/null +++ b/src/render_tests/selector.test.tsx @@ -0,0 +1,66 @@ +import { createSignal } from "solid-js"; +import { testRender } from "@opentui/solid"; +import { describe, expect, it, mock } from "bun:test"; +import { Selector } from "@/components/Selector"; + +const items = Array.from({ length: 40 }, (_, n) => ({ name: `item-${n}` })); + +const mount = async (selectedIndex: () => number, extra: Record = {}) => { + const setup = await testRender( + () => ( + ({ name: item.name })} + onSelect={() => {}} + onClose={() => {}} + {...extra} + /> + ), + { width: 60, height: 30 }, + ); + await setup.renderOnce(); + await setup.renderOnce(); + return setup; +}; + +describe("Selector (rendered)", () => { + it("scrolls the initially selected row into view on open", async () => { + const setup = await mount(() => 30); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("item-30"); + setup.renderer.destroy(); + }); + + it("keeps the selection visible as the index moves down and back up", async () => { + const [index, setIndex] = createSignal(0); + const setup = await mount(index); + expect(setup.captureCharFrame()).toContain("item-0"); + setIndex(25); + await setup.renderOnce(); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("item-25"); + expect(setup.captureCharFrame()).not.toContain("item-0 "); + setIndex(2); + await setup.renderOnce(); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("item-2"); + setup.renderer.destroy(); + }); + + it("clicking the footer esc closes without forwarding esc to the parent", async () => { + const onClose = mock(() => {}); + const onShortcutClick = mock((_key: string) => {}); + const setup = await mount(() => 0, { onClose, onShortcutClick }); + const frame = setup.captureCharFrame().split("\n"); + const row = frame.findIndex((line) => line.includes("esc")); + expect(row).toBeGreaterThanOrEqual(0); + await setup.mockMouse.click(frame[row]!.indexOf("esc") + 1, row); + await setup.renderOnce(); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onShortcutClick).not.toHaveBeenCalledWith("esc"); + setup.renderer.destroy(); + }); +}); diff --git a/src/services/effects/DriveDetection.ts b/src/services/effects/DriveDetection.ts index 66bf987..cf81b9d 100644 --- a/src/services/effects/DriveDetection.ts +++ b/src/services/effects/DriveDetection.ts @@ -5,7 +5,8 @@ * Uses Bun.spawn for native process execution and parsing of 'diskutil activity'. */ -import { Context, Data, Effect, Either, Layer, Option, Ref, Stream } from "effect"; +import { statfs } from "node:fs/promises"; +import { Context, Data, Effect, Layer, Option, Ref, Result, Stream } from "effect"; import type { Drive } from "@/types/drive"; import { getPlistBoolean, @@ -27,19 +28,21 @@ export type DriveEvent = /** * DriveDetection Service Tag */ -export class DriveDetection extends Context.Tag("DriveDetection")< +export class DriveDetection extends Context.Service< DriveDetection, { /** Scans for available external/removable volumes */ readonly scanDrives: () => Effect.Effect; /** Gets detailed information for a specific drive by its mount point */ readonly getDriveInfo: (mountPoint: string) => Effect.Effect; + /** Reads the bytes currently available to the user at a mount point (cheap; no diskutil) */ + readonly getFreeSpace: (mountPoint: string) => Effect.Effect; /** Checks if a background activity scan is currently active */ readonly isScanning: () => Effect.Effect; /** A stream of drive appearance and disappearance events */ readonly driveEvents: Stream.Stream; } ->() {} +>()("DriveDetection") {} const EXCLUDED_NAMES = new Set([ "Macintosh HD", @@ -105,11 +108,11 @@ const runDiskutil = (args: string[]) => throw new Error(`diskutil exited with code ${exitCode}`); } - const parsedEither = parsePlist(output); - if (Either.isLeft(parsedEither)) { - throw parsedEither.left; + const parsedResult = parsePlist(output); + if (Result.isFailure(parsedResult)) { + throw parsedResult.failure; } - const parsed = parsedEither.right; + const parsed = parsedResult.success; if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { return parsed as PlistDict; } @@ -118,9 +121,9 @@ const runDiskutil = (args: string[]) => catch: (cause) => new DriveDetectionError({ cause }), }); -const getDriveDetails = (identifier: string, logger: Context.Tag.Service) => - Effect.gen(function* (_) { - const info = yield* _(runDiskutil(["info", identifier])); +const getDriveDetails = (identifier: string, logger: Logger["Service"]) => + Effect.gen(function* () { + const info = yield* runDiskutil(["info", identifier]); const exclusionReason = getExclusionReason(info); if (exclusionReason) { @@ -166,7 +169,7 @@ const getDriveDetails = (identifier: string, logger: Context.Tag.Service totalSpace, freeSpace: freeSpace ?? -1, } as Drive); - }).pipe(Effect.catchAll(() => Effect.succeed(Option.none()))); + }).pipe(Effect.catch(() => Effect.succeed(Option.none()))); /** * Parses a line from `diskutil activity` output. @@ -183,7 +186,11 @@ export function parseActivityLine( const action = match[1]; const bsdName = match[2]; - if (action === "DiskAppeared" || action === "VolumeMount" || action === "DiskDescriptionChanged") { + if ( + action === "DiskAppeared" || + action === "VolumeMount" || + action === "DiskDescriptionChanged" + ) { return Option.some({ type: "Appeared", bsdName }); } if (action === "DiskDisappeared" || action === "VolumeUnmount") { @@ -193,6 +200,63 @@ export function parseActivityLine( return Option.none(); } +const DEBOUNCE_MS = 1000; + +/** + * Turns one parsed `diskutil activity` event into a DriveEvent, or none. + * + * Events for the same drive within DEBOUNCE_MS of the last *emitted* event are + * dropped. The timestamp is only recorded when an event is actually emitted: a + * DiskAppeared that arrives before the volume is mounted yields nothing, and + * must not swallow the VolumeMount that follows it moments later. + */ +export const processActivityEvent = ( + event: { type: "Appeared" | "Disappeared"; bsdName: string }, + state: { + readonly driveIdMap: Ref.Ref>; + readonly lastEventTimeMap: Ref.Ref>; + }, + getDetails: (bsdName: string) => Effect.Effect>, + logger: Logger["Service"], + now: number, +): Effect.Effect> => + Effect.gen(function* () { + const { driveIdMap, lastEventTimeMap } = state; + const map = yield* Ref.get(driveIdMap); + + const lastTime = (yield* Ref.get(lastEventTimeMap)).get(event.bsdName) ?? 0; + if (now - lastTime < DEBOUNCE_MS) { + return Option.none(); + } + const recordEmitted = Ref.update(lastEventTimeMap, (m) => new Map(m).set(event.bsdName, now)); + + if (event.type === "Appeared") { + const details = yield* getDetails(event.bsdName); + if (Option.isSome(details)) { + const drive = details.value; + yield* Ref.update(driveIdMap, (m) => new Map(m).set(event.bsdName, drive.id)); + yield* recordEmitted; + yield* logger.info(`Drive appeared/changed: ${drive.name} (${drive.id})`); + return Option.some({ _tag: "Appeared", drive }); + } + return Option.none(); + } + + // Disappeared + const driveId = map.get(event.bsdName); + if (driveId) { + yield* Ref.update(driveIdMap, (m) => { + const newMap = new Map(m); + newMap.delete(event.bsdName); + return newMap; + }); + yield* recordEmitted; + yield* logger.info(`Drive disappeared: ${driveId}`); + return Option.some({ _tag: "Disappeared", driveId }); + } + return Option.none(); + }); + /** * Live implementation of DriveDetection using macOS diskutil activity and info. */ @@ -209,7 +273,10 @@ export const DriveDetectionLive = Layer.effect( try: async () => { const glob = new Bun.Glob("*"); const names: string[] = []; - for await (const name of glob.scan({ cwd: "/Volumes", onlyFiles: false })) { + for await (const name of glob.scan({ + cwd: "/Volumes", + onlyFiles: false, + })) { if (!EXCLUDED_NAMES.has(name)) { names.push(name); } @@ -219,12 +286,8 @@ export const DriveDetectionLive = Layer.effect( catch: (cause) => new DriveDetectionError({ cause }), }); - const drives = yield* Effect.forEach( - volumeNames, - (name) => getDriveDetails(`/Volumes/${name}`, logger), - { - concurrency: "inherit", - }, + const drives = yield* Effect.forEach(volumeNames, (name) => + getDriveDetails(`/Volumes/${name}`, logger), ); return drives.filter(Option.isSome).map((opt) => opt.value); @@ -233,6 +296,17 @@ export const DriveDetectionLive = Layer.effect( const getDriveInfo = (mountPoint: string) => getDriveDetails(mountPoint, logger).pipe(Effect.map((opt) => Option.getOrNull(opt))); + const getFreeSpace = (mountPoint: string) => + Effect.tryPromise({ + try: async () => { + const stats = await statfs(mountPoint); + const free = Number(stats.bavail) * Number(stats.bsize); + if (!Number.isFinite(free) || free < 0) throw new Error("statfs reported no free space"); + return free; + }, + catch: (cause) => new DriveDetectionError({ cause }), + }); + const driveEvents = Stream.unwrap( Effect.gen(function* () { const initialDrives = yield* scanDrives(); @@ -243,90 +317,70 @@ export const DriveDetectionLive = Layer.effect( const lastCrashRef = yield* Ref.make(0); const createProcessStream = () => - Stream.acquireRelease( - Effect.gen(function* () { - // Spawn first so if it fails, scanningRef is never set - const process = Bun.spawn(["diskutil", "activity"], { - stdout: "pipe", - stderr: "ignore", - }); - yield* Ref.set(scanningRef, true); - return process; - }), - (process) => - Effect.gen(function* () { - process.kill(); - yield* Ref.set(scanningRef, false); - }), - ).pipe( - Stream.flatMap((process) => - Stream.fromAsyncIterable( - (async function* () { - const reader = process.stdout.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - throw new Error("diskutil activity process exited unexpectedly"); - } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (line.trim()) yield line; + Stream.scoped( + Stream.fromEffect( + Effect.acquireRelease( + Effect.gen(function* () { + // Spawn first so if it fails, scanningRef is never set + const process = Bun.spawn(["diskutil", "activity"], { + stdout: "pipe", + stderr: "ignore", + }); + yield* Ref.set(scanningRef, true); + return process; + }), + (process) => + Effect.gen(function* () { + process.kill(); + yield* Ref.set(scanningRef, false); + }), + ), + ).pipe( + Stream.flatMap((process) => + Stream.fromAsyncIterable( + (async function* () { + const reader = process.stdout.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + throw new Error("diskutil activity process exited unexpectedly"); + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (line.trim()) yield line; + } } + } finally { + reader.releaseLock(); } - } finally { - reader.releaseLock(); - } - })(), - (e) => new DriveDetectionError({ cause: e }), + })(), + (e) => new DriveDetectionError({ cause: e }), + ), + ), + Stream.map(parseActivityLine), + Stream.filterMap((o) => + Option.isSome(o) ? Result.succeed(o.value) : Result.fail(undefined), + ), + Stream.tap((event) => logger.debug(`diskutil event: ${event.type} ${event.bsdName}`)), + Stream.mapEffect((event) => + processActivityEvent( + event, + { driveIdMap, lastEventTimeMap }, + (bsdName) => getDriveDetails(bsdName, logger), + logger, + Date.now(), + ), + ), + Stream.filterMap((o) => + Option.isSome(o) ? Result.succeed(o.value) : Result.fail(undefined), ), ), - Stream.map(parseActivityLine), - Stream.filterMap((o) => o), - Stream.tap((event) => logger.debug(`diskutil event: ${event.type} ${event.bsdName}`)), - Stream.mapEffect((event) => - Effect.gen(function* () { - const map = yield* Ref.get(driveIdMap); - const now = Date.now(); - - // Debounce events for the same drive to prevent rapid successive reads - const lastTime = (yield* Ref.get(lastEventTimeMap)).get(event.bsdName) ?? 0; - if (now - lastTime < 1000) { - return Option.none(); - } - yield* Ref.update(lastEventTimeMap, (m) => new Map(m).set(event.bsdName, now)); - - if (event.type === "Appeared") { - const details = yield* getDriveDetails(event.bsdName, logger); - if (Option.isSome(details)) { - const drive = details.value; - yield* Ref.update(driveIdMap, (m) => new Map(m).set(event.bsdName, drive.id)); - yield* logger.info(`Drive appeared/changed: ${drive.name} (${drive.id})`); - return Option.some({ _tag: "Appeared", drive } as DriveEvent); - } - return Option.none(); - } - - // Disappeared - const driveId = map.get(event.bsdName); - if (driveId) { - yield* Ref.update(driveIdMap, (m) => { - const newMap = new Map(m); - newMap.delete(event.bsdName); - return newMap; - }); - yield* logger.info(`Drive disappeared: ${driveId}`); - return Option.some({ _tag: "Disappeared", driveId } as DriveEvent); - } - return Option.none(); - }), - ), - Stream.filterMap((o) => o), ); // Retry with exponential backoff up to 5 times (resets if stable for 60s) @@ -335,7 +389,7 @@ export const DriveDetectionLive = Layer.effect( attempts: number, ): Stream.Stream => stream.pipe( - Stream.catchAll((err) => { + Stream.catch((err) => { const now = Date.now(); return Stream.fromEffect( Effect.gen(function* () { @@ -355,7 +409,7 @@ export const DriveDetectionLive = Layer.effect( const oldMap = yield* Ref.get(driveIdMap); // Re-scan to get fresh state const freshDrives = yield* scanDrives().pipe( - Effect.catchAll(() => Effect.succeed([] as Drive[])), + Effect.catch(() => Effect.succeed([] as Drive[])), ); const newMap = new Map(freshDrives.map((d) => [d.bsdName, d.id])); yield* Ref.set(driveIdMap, newMap); @@ -396,6 +450,7 @@ export const DriveDetectionLive = Layer.effect( isScanning: () => Ref.get(scanningRef), scanDrives, getDriveInfo, + getFreeSpace, driveEvents, }); }), @@ -413,5 +468,13 @@ export const createDriveDetectionTest = ( scanDrives: () => Effect.succeed(drives), getDriveInfo: (mountPoint) => Effect.succeed(drives.find((d) => d.mountPoint === mountPoint) ?? null), + getFreeSpace: (mountPoint) => + Effect.succeed(drives.find((d) => d.mountPoint === mountPoint)?.freeSpace ?? -1).pipe( + Effect.flatMap((free) => + free >= 0 + ? Effect.succeed(free) + : Effect.fail(new DriveDetectionError({ cause: "unknown" })), + ), + ), driveEvents: events, }); diff --git a/src/services/effects/DriveScan.ts b/src/services/effects/DriveScan.ts index 0d7119e..ffa8bf0 100644 --- a/src/services/effects/DriveScan.ts +++ b/src/services/effects/DriveScan.ts @@ -4,6 +4,7 @@ import { EpisodeMatcher } from "@/services/effects/EpisodeMatcher"; import { FileSystem } from "@/services/effects/FileSystem"; import { Logger } from "@/services/effects/Logger"; import type { Podcast } from "@/types/podcast"; +import { driveEpisodeKey, UNKNOWN_AUTHOR, UNKNOWN_DATE } from "@/utils/formatting"; export class DriveScanError extends Data.TaggedError("DriveScanError")<{ readonly cause: unknown; @@ -12,7 +13,7 @@ export class DriveScanError extends Data.TaggedError("DriveScanError")<{ /** * DriveScan Service Tag */ -export class DriveScan extends Context.Tag("DriveScan")< +export class DriveScan extends Context.Service< DriveScan, { /** Scans a drive for existing podcast files in the 'Podcasts' folder */ @@ -32,16 +33,25 @@ export class DriveScan extends Context.Tag("DriveScan")< drivePath: string, ) => Effect.Effect; } ->() {} +>()("DriveScan") {} /** * Helper to parse podcast file info from path. * extracts show name, title, and date if available. + * Names are normalized to NFC: macOS can hand back NFD names, and the same show + * must not split into two groups depending on the form. + * + * `dateStr` is the literal date from the filename ("YYYY-MM-DD" or "Unknown"), or "" + * when the file has no date prefix. It is what drive matching keys on. */ -function parsePodcastFile( - path: string, -): { showName: string; title: string; date?: Date; rawTitle: string } | null { - const normalizedPath = path.replace(/\\/g, "/"); +function parsePodcastFile(path: string): { + showName: string; + title: string; + date?: Date; + dateStr: string; + rawTitle: string; +} | null { + const normalizedPath = path.replace(/\\/g, "/").normalize("NFC"); const parts = normalizedPath.split("/"); const filename = parts[parts.length - 1]; if (!filename) return null; @@ -52,21 +62,35 @@ function parsePodcastFile( const rawTitle = filename.slice(0, -ext.length); let title = rawTitle; let date: Date | undefined; + let dateStr = ""; - const dateMatch = rawTitle.match(/^(\d{4}-\d{2}-\d{2}) - (.+)$/); + const dateMatch = rawTitle.match(/^(\d{4}-\d{2}-\d{2}|Unknown) - (.+)$/); if (dateMatch) { const titlePart = dateMatch[2]; - if (titlePart) { - const dateStr = dateMatch[1]; - const parsedDate = new Date(dateStr ?? ""); - if (!isNaN(parsedDate.getTime())) { + const literal = dateMatch[1]; + if (titlePart && literal) { + if (literal === UNKNOWN_DATE) { title = titlePart; - date = parsedDate; + dateStr = literal; + } else { + const [y, m, d] = literal.split("-").map(Number) as [number, number, number]; + // Local noon: formatDate reads local fields, so a UTC-midnight parse would + // show the previous day west of UTC. Noon keeps the UTC date equal too. + const parsedDate = new Date(y, m - 1, d, 12); + if ( + parsedDate.getFullYear() === y && + parsedDate.getMonth() === m - 1 && + parsedDate.getDate() === d + ) { + title = titlePart; + date = parsedDate; + dateStr = literal; + } } } } - return { showName, title, date, rawTitle }; + return { showName, title, date, dateStr, rawTitle }; } /** @@ -80,7 +104,7 @@ const getFilesRecursive = ( const fs = yield* FileSystem; const logger = yield* Logger; const entries = yield* fs.list(dir).pipe( - Effect.catchAll((err) => + Effect.catch((err) => Effect.gen(function* () { yield* logger.error(`Failed to list directory ${dir}`, err); return [] as string[]; @@ -96,7 +120,7 @@ const getFilesRecursive = ( const relPath = baseRel ? join(baseRel, entry) : entry; const isDir = yield* fs .isDirectory(fullPath) - .pipe(Effect.catchAll(() => Effect.succeed(false))); + .pipe(Effect.catch(() => Effect.succeed(false))); if (isDir) { return yield* getFilesRecursive(fullPath, relPath); @@ -151,7 +175,7 @@ export const DriveScanLive = Layer.succeed( candidates, ({ file, info }) => Effect.gen(function* () { - const { showName, title, date } = info; + const { showName, title, date, dateStr } = info; const fullPath = join(podcastsDir, file); let published = date; @@ -172,13 +196,16 @@ export const DriveScanLive = Layer.succeed( return { showName, episode: { - id: `${showName}-${title}`, + id: `${showName}-${dateStr ? `${dateStr} - ` : ""}${title}`, title: title.replace(/_/g, " "), + // Drive-derived episodes have no ZAUTHOR to read. + author: UNKNOWN_AUTHOR, duration: 0, published, onDrive: true, filePath: fullPath, fileSize: size, + driveDate: dateStr, }, }; }), @@ -190,7 +217,7 @@ export const DriveScanLive = Layer.succeed( podcastsMap.set(showName, { id: showName, title: showName.replace(/_/g, " "), - author: "Unknown", + author: UNKNOWN_AUTHOR, episodeCount: 0, episodes: [], }); @@ -206,7 +233,7 @@ export const DriveScanLive = Layer.succeed( ); return Array.from(podcastsMap.values()); - }).pipe(Effect.catchAll((err) => Effect.fail(new DriveScanError({ cause: err })))), + }).pipe(Effect.catch((err) => Effect.fail(new DriveScanError({ cause: err })))), buildDriveIndex: (drivePath) => Effect.gen(function* () { @@ -240,8 +267,8 @@ export const DriveScanLive = Layer.succeed( candidates, ({ file, info }) => Effect.gen(function* () { - const { showName, title } = info; - const key = matcher.buildExpectedDrivePath(showName, title); + const { showName, title, dateStr } = info; + const key = matcher.buildExpectedDrivePath(showName, title, dateStr); const fullPath = join(podcastsDir, file); let size = 0; @@ -269,7 +296,7 @@ export const DriveScanLive = Layer.succeed( yield* logger.info(`Drive index built with ${index.size} episodes`); return index; - }).pipe(Effect.catchAll((err) => Effect.fail(new DriveScanError({ cause: err })))), + }).pipe(Effect.catch((err) => Effect.fail(new DriveScanError({ cause: err })))), hasPodcastsFolder: (drivePath) => Effect.gen(function* () { @@ -279,7 +306,7 @@ export const DriveScanLive = Layer.succeed( const exists = yield* fs.exists(podcastsDir); yield* logger.debug(`Checked folder existence at ${podcastsDir}: ${exists}`); return exists; - }).pipe(Effect.catchAll((err) => Effect.fail(new DriveScanError({ cause: err })))), + }).pipe(Effect.catch((err) => Effect.fail(new DriveScanError({ cause: err })))), }), ); @@ -294,6 +321,8 @@ interface MockDrivePodcast { title: string; path: string; size: number; + /** Date string as in the filename ("YYYY-MM-DD" or "Unknown"); omit for an undated file. */ + date?: string; }>; } @@ -306,16 +335,18 @@ export const createDriveScanTest = (mockPodcasts: MockDrivePodcast[] = []) => const podcasts: Podcast[] = mockPodcasts.map((mock) => ({ id: mock.name, title: mock.name, - author: "Unknown", + author: UNKNOWN_AUTHOR, episodeCount: mock.episodes.length, episodes: mock.episodes.map((ep) => ({ id: ep.id, title: ep.title, + author: UNKNOWN_AUTHOR, duration: 0, published: new Date(), onDrive: true, filePath: ep.path, fileSize: ep.size, + driveDate: ep.date ?? "", })), })); return Effect.succeed(podcasts); @@ -325,7 +356,7 @@ export const createDriveScanTest = (mockPodcasts: MockDrivePodcast[] = []) => const index = new Map(); for (const podcast of mockPodcasts) { for (const ep of podcast.episodes) { - const key = `${podcast.name}/${ep.title}`; + const key = driveEpisodeKey(podcast.name, ep.title, ep.date ?? ""); index.set(key, { id: ep.id, title: ep.title, diff --git a/src/services/effects/EpisodeMatcher.ts b/src/services/effects/EpisodeMatcher.ts index b33f309..7bfc4b3 100644 --- a/src/services/effects/EpisodeMatcher.ts +++ b/src/services/effects/EpisodeMatcher.ts @@ -1,38 +1,60 @@ import { Context, Layer } from "effect"; -import { sanitizeFilename } from "@/utils/formatting"; +import { driveEpisodeKey } from "@/utils/formatting"; /** * EpisodeMatcher Service Tag */ -export class EpisodeMatcher extends Context.Tag("EpisodeMatcher")< +export class EpisodeMatcher extends Context.Service< EpisodeMatcher, { - /** Builds the expected relative path for an episode on the drive */ - readonly buildExpectedDrivePath: (showName: string, title: string) => string; + /** + * Builds the expected relative path for an episode on the drive: + * "Show/YYYY-MM-DD - Title.mp3". `date` is a Date (formatted like formatDestPath) + * or the literal date string from a filename; "" gives the undated legacy path. + */ + readonly buildExpectedDrivePath: ( + showName: string, + title: string, + date: Date | string, + ) => string; /** Attempts to match a Mac episode with a file on the drive using path, size, or duration */ readonly matchEpisode: ( showName: string, - episode: { title: string; fileSize?: number; duration?: number }, + episode: { title: string; published: Date; fileSize?: number; duration?: number }, driveIndex: Map, ) => boolean; } ->() {} +>()("EpisodeMatcher") {} -const buildExpectedDrivePathImpl = (showName: string, title: string): string => { - return `${sanitizeFilename(showName)}/${sanitizeFilename(title)}.mp3`; -}; +const buildExpectedDrivePathImpl = (showName: string, title: string, date: Date | string): string => + driveEpisodeKey(showName, title, date); + +/** " (2)", " (3)", ... appended by SyncEngine.dedupeDestPath (sanitized to "_(2)" in keys). */ +const DEDUPE_KEY_SUFFIX = /^[ _]\(\d+\)\.mp3$/; const matchEpisodeImpl = ( showName: string, - episode: { title: string; fileSize?: number; duration?: number }, + episode: { title: string; published: Date; fileSize?: number; duration?: number }, driveIndex: Map, ): boolean => { - // 1. Path-based matching (Strongest, handles tagged files where size changed) - const expectedPath = buildExpectedDrivePathImpl(showName, episode.title); + // 1. Path-based matching (Strongest, handles tagged files where size changed). + // The key includes the publish date so a new episode never matches an older one + // with the same title ("Trailer", "Bonus"). + const expectedPath = buildExpectedDrivePathImpl(showName, episode.title, episode.published); if (driveIndex.has(expectedPath)) return true; - const sanitizedShow = sanitizeFilename(showName); - const showPrefix = `${sanitizedShow}/`; + // Legacy drive files carry no date prefix, so show + title is all there is. + if (driveIndex.has(buildExpectedDrivePathImpl(showName, episode.title, ""))) return true; + + const showPrefix = `${expectedPath.slice(0, expectedPath.indexOf("/"))}/`; + + // A file that was deduped on copy (" - Title (2).mp3") still counts as synced. + const expectedBase = expectedPath.slice(0, -".mp3".length); + for (const key of driveIndex.keys()) { + if (key.startsWith(expectedBase) && DEDUPE_KEY_SUFFIX.test(key.slice(expectedBase.length))) { + return true; + } + } // 2. Cascade through other matches for (const [key, info] of driveIndex) { diff --git a/src/services/effects/FileSystem.ts b/src/services/effects/FileSystem.ts index eb9e644..8f18287 100644 --- a/src/services/effects/FileSystem.ts +++ b/src/services/effects/FileSystem.ts @@ -55,7 +55,7 @@ export class RemoveError extends Data.TaggedError("RemoveError")<{ /** * FileSystem Service Tag */ -export class FileSystem extends Context.Tag("FileSystem")< +export class FileSystem extends Context.Service< FileSystem, { /** Checks if a path exists on the filesystem */ @@ -66,11 +66,13 @@ export class FileSystem extends Context.Tag("FileSystem")< readonly writeFile: (path: string, data: Uint8Array) => Effect.Effect; /** Copies a file from src to dest */ readonly copyFile: (src: string, dest: string) => Effect.Effect; + /** Atomically renames (moves) a file, replacing the destination if present */ + readonly rename: (from: string, to: string) => Effect.Effect; /** Creates a directory recursively */ readonly mkdir: (path: string) => Effect.Effect; /** Removes a file or directory recursively */ readonly remove: (path: string) => Effect.Effect; - /** Reads directory entries using Bun.Glob */ + /** Reads visible directory entries (files and directories) using Bun.Glob */ readonly readDir: (path: string) => Effect.Effect; /** Reads directory entries using node:fs/promises */ readonly list: (path: string) => Effect.Effect; @@ -99,7 +101,17 @@ export class FileSystem extends Context.Tag("FileSystem")< /** Checks if a directory contains no visible files */ readonly isDirEmpty: (path: string) => Effect.Effect; } ->() {} +>()("FileSystem") {} + +/** + * Extension of the final path segment only, so "/Volumes/My.Drive/episode" has no + * extension. Falls back to .mp3 when the basename has none. + */ +const getExtensionImpl = (path: string): string => { + const base = path.slice(path.lastIndexOf("/") + 1); + const lastDot = base.lastIndexOf("."); + return lastDot > 0 && lastDot < base.length - 1 ? base.slice(lastDot) : ".mp3"; +}; const isSystemHiddenFileImpl = (name: string): boolean => { if (SYSTEM_HIDDEN_FILES.includes(name)) return true; @@ -118,7 +130,7 @@ export const FileSystemLive = Layer.effect( await stat(path); }).pipe( Effect.as(true), - Effect.catchAll(() => Effect.succeed(false)), + Effect.catch(() => Effect.succeed(false)), ), readFile: (path) => @@ -178,12 +190,25 @@ export const FileSystemLive = Layer.effect( }).pipe(Effect.tapError((err) => logger.error(`Failed to remove path: ${path}`, err))); }), + rename: (from, to) => + Effect.tryPromise({ + try: async () => { + const fs = await import("node:fs/promises"); + await fs.rename(from, to); + }, + catch: (cause) => new WriteError({ path: to, cause }), + }), + readDir: (path) => Effect.tryPromise({ try: async () => { + // Directories included (the default is files only), otherwise callers + // such as SyncEngine.cleanup never see show directories. Dot entries + // stay excluded: system hidden files are handled by + // cleanupSystemHiddenFiles, and user dot-folders are left alone. const glob = new Bun.Glob("*"); const files: string[] = []; - for await (const file of glob.scan({ cwd: path })) { + for await (const file of glob.scan({ cwd: path, onlyFiles: false })) { files.push(file); } return files; @@ -205,7 +230,7 @@ export const FileSystemLive = Layer.effect( const { stat } = await import("node:fs/promises"); const s = await stat(path); return s.isDirectory(); - }).pipe(Effect.catchAll(() => Effect.succeed(false))), + }).pipe(Effect.catch(() => Effect.succeed(false))), glob: (pattern, cwd) => Effect.tryPromise({ @@ -235,7 +260,7 @@ export const FileSystemLive = Layer.effect( const { stat } = await import("node:fs/promises"); const s = await stat(path); return s.size; - }).pipe(Effect.catchAll(() => Effect.succeed(0))), + }).pipe(Effect.catch(() => Effect.succeed(0))), ensureDir: (dirPath) => Effect.gen(function* () { @@ -258,10 +283,7 @@ export const FileSystemLive = Layer.effect( return AUDIO_EXTENSIONS.has(ext); }, - getExtension: (path) => { - const lastDot = path.lastIndexOf("."); - return lastDot >= 0 ? path.slice(lastDot) : ".mp3"; - }, + getExtension: getExtensionImpl, cleanupSystemHiddenFiles: (dirPath) => Effect.gen(function* () { @@ -281,7 +303,7 @@ export const FileSystemLive = Layer.effect( Effect.tapError((err) => logger.error(`Failed to remove hidden file: ${entry}`, err), ), - Effect.catchAll(() => Effect.void), + Effect.catch(() => Effect.void), ); } } @@ -293,7 +315,7 @@ export const FileSystemLive = Layer.effect( const entries = yield* Effect.tryPromise({ try: () => readdir(path), catch: (cause) => new ReadDirError({ path, cause }), - }).pipe(Effect.catchAll(() => Effect.succeed([] as string[]))); + }).pipe(Effect.catch(() => Effect.succeed([] as string[]))); const visibleFiles = entries.filter((e) => !isSystemHiddenFileImpl(e)); return visibleFiles.length === 0; }), @@ -340,6 +362,16 @@ export const createFileSystemTest = (files: Map = new Map()) return Effect.void; }, + rename: (from, to) => { + const data = files.get(from); + if (!data) { + return Effect.fail(new WriteError({ path: to, cause: "Source not found" })); + } + files.set(to, data); + files.delete(from); + return Effect.void; + }, + readDir: (path) => { const entries = Array.from(files.keys()) .filter((f) => f.startsWith(path) && f !== path) @@ -403,10 +435,7 @@ export const createFileSystemTest = (files: Map = new Map()) return AUDIO_EXTENSIONS.has(ext); }, - getExtension: (path) => { - const lastDot = path.lastIndexOf("."); - return lastDot >= 0 ? path.slice(lastDot) : ".mp3"; - }, + getExtension: getExtensionImpl, cleanupSystemHiddenFiles: (_dirPath) => Effect.void, diff --git a/src/services/effects/Logger.ts b/src/services/effects/Logger.ts index 043aad5..762a189 100644 --- a/src/services/effects/Logger.ts +++ b/src/services/effects/Logger.ts @@ -4,7 +4,7 @@ import { actions } from "@/store"; /** * Logger Service Tag */ -export class Logger extends Context.Tag("Logger")< +export class Logger extends Context.Service< Logger, { /** Logs a debug message to the application store */ @@ -14,7 +14,7 @@ export class Logger extends Context.Tag("Logger")< /** Logs an error message and optional cause to the application store */ readonly error: (message: string, cause?: unknown) => Effect.Effect; } ->() {} +>()("Logger") {} /** * Live implementation of Logger that updates the store actions. diff --git a/src/services/effects/MetadataEditor.ts b/src/services/effects/MetadataEditor.ts index 6f2aeb3..4f0a818 100644 --- a/src/services/effects/MetadataEditor.ts +++ b/src/services/effects/MetadataEditor.ts @@ -18,13 +18,13 @@ export interface PodcastMetadata { /** * MetadataEditor Service Tag */ -export class MetadataEditor extends Context.Tag("MetadataEditor")< +export class MetadataEditor extends Context.Service< MetadataEditor, { /** Writes ID3 tags to an MP3 file */ readonly write: (path: string, metadata: PodcastMetadata) => Effect.Effect; } ->() {} +>()("MetadataEditor") {} /** * Live implementation of MetadataEditor using node-id3. @@ -44,8 +44,10 @@ export const MetadataEditorLive = Layer.succeed(MetadataEditor, { }, year: metadata.year, }; - // Use the Promise API to avoid blocking the main thread (and the TUI) - return nodeID3.Promise.write(tags, path); + // Use the Promise API to avoid blocking the main thread (and the TUI). + // update() merges into existing tags; write() would replace them all + // and drop cover art, chapters and other frames already in the file. + return nodeID3.Promise.update(tags, path); }, catch: (cause) => new MetadataError({ path, cause }), }), diff --git a/src/services/effects/PodcastService.ts b/src/services/effects/PodcastService.ts index 0bf6c7d..4390556 100644 --- a/src/services/effects/PodcastService.ts +++ b/src/services/effects/PodcastService.ts @@ -22,7 +22,7 @@ export class DatabaseNotFoundError extends Data.TaggedError("DatabaseNotFoundErr /** * Podcast Service Tag */ -export class PodcastService extends Context.Tag("PodcastService")< +export class PodcastService extends Context.Service< PodcastService, { /** Checks if the Apple Podcasts database is available */ @@ -32,7 +32,7 @@ export class PodcastService extends Context.Tag("PodcastService")< /** Returns the absolute path to the MTLibrary.sqlite database */ readonly getDatabasePath: () => string; } ->() {} +>()("PodcastService") {} /** * Live implementation of PodcastService using a background worker for SQLite access. @@ -70,11 +70,19 @@ export const PodcastServiceLive = Layer.effect( const workerUrl = new URL(workerPath, import.meta.url); yield* logger.debug(`Spawning worker for database access: ${workerUrl}`); - return new Worker(workerUrl); - }).pipe(Effect.catchAll((err) => Effect.fail(new PodcastError({ cause: err })))), + // `new Worker` can throw synchronously (bad URL, resource limits); + // capture that as a typed PodcastError rather than a defect. + return yield* Effect.try({ + try: () => new Worker(workerUrl), + catch: (cause) => + new PodcastError({ cause, message: "Failed to start database worker" }), + }).pipe( + Effect.tapError((err) => logger.error("Failed to start database worker", err.cause)), + ); + }), // Use: Perform the async work (worker) => - Effect.async((resume) => { + Effect.callback((resume) => { worker.onmessage = (e) => { if (e.data.type === "SUCCESS") { const episodes = e.data.data as PodcastEpisode[]; @@ -116,13 +124,3 @@ export const PodcastServiceLive = Layer.effect( }); }), ); - -/** - * Creates a mock PodcastService for testing. - */ -export const createPodcastServiceTest = (episodes: PodcastEpisode[] = []) => - Layer.succeed(PodcastService, { - getDatabasePath: () => "/mock/path/to/db.sqlite", - checkAvailability: Effect.succeed(true), - loadMacPodcasts: Effect.succeed(episodes), - }); diff --git a/src/services/effects/SettingsService.ts b/src/services/effects/SettingsService.ts index bbcb175..1b27744 100644 --- a/src/services/effects/SettingsService.ts +++ b/src/services/effects/SettingsService.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os"; import { join } from "node:path"; -import { Context, Effect, Layer } from "effect"; +import { Context, Effect, Layer, Option, Semaphore } from "effect"; import { FileSystem } from "@/services/effects/FileSystem"; import { Logger } from "@/services/effects/Logger"; @@ -17,7 +17,7 @@ const SCHEMA_URL = /** * SettingsService Service Tag */ -export class SettingsService extends Context.Tag("SettingsService")< +export class SettingsService extends Context.Service< SettingsService, { /** Loads application settings from ~/.config/podapple/podapple.jsonc */ @@ -25,7 +25,7 @@ export class SettingsService extends Context.Tag("SettingsService")< /** Saves application settings to ~/.config/podapple/podapple.jsonc */ readonly saveSettings: (settings: Partial) => Effect.Effect; } ->() {} +>()("SettingsService") {} /** * Live implementation of SettingsService. @@ -35,57 +35,92 @@ export const SettingsServiceLive = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem; const logger = yield* Logger; + // saveSettings is a read-modify-write of one file. Serialize it so + // overlapping saves (a theme change racing a favorite toggle) cannot + // each merge into the same stale snapshot and lose one update. + const saveLock = yield* Semaphore.make(1); + + const DEFAULTS: Settings = { theme: "Catppuccin", favoriteDrives: [] }; + + /** + * Reads the settings file. A missing file is `None` (use defaults); a file + * that exists but cannot be read or parsed is an error, so callers that + * would overwrite it can refuse. + */ + const readSettings = Effect.gen(function* () { + if (!(yield* fs.exists(SETTINGS_FILE))) return Option.none(); + const data = yield* fs + .readFile(SETTINGS_FILE) + .pipe(Effect.mapError((cause) => new Error(`Failed to read settings: ${cause.path}`))); + const parsed = yield* Effect.try({ + try: () => { + const text = new TextDecoder().decode(data); + // Strip single-line and multi-line comments from JSONC + const cleanJson = text.replace( + /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, + (m, g) => (g ? "" : m), + ); + const value: unknown = JSON.parse(cleanJson); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("expected a JSON object"); + } + return value as Settings; + }, + catch: (cause) => new Error(`Failed to parse settings: ${cause}`), + }); + return Option.some(parsed); + }); const loadSettings = Effect.gen(function* () { yield* logger.debug(`Loading settings from: ${SETTINGS_FILE}`); - const result = yield* fs.readFile(SETTINGS_FILE).pipe( - Effect.flatMap((data) => - Effect.try({ - try: () => { - const text = new TextDecoder().decode(data); - // Strip single-line and multi-line comments from JSONC - const cleanJson = text.replace( - /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, - (m, g) => (g ? "" : m), - ); - return JSON.parse(cleanJson) as Settings; - }, - catch: (cause) => new Error(`Failed to parse settings: ${cause}`), - }), - ), - Effect.catchAll(() => Effect.succeed(null)), + const result = yield* readSettings.pipe( + Effect.tapError((err) => logger.error("Could not load settings, using defaults", err)), + Effect.catch(() => Effect.succeed(Option.none())), ); - if (result) { + if (Option.isSome(result)) { yield* logger.info("Settings loaded successfully"); - return result; + return result.value; } yield* logger.info("Using default settings (could not load or parse settings file)"); - return { theme: "Catppuccin", favoriteDrives: [] } as Settings; + return { ...DEFAULTS } as Settings; }); const saveSettings = (newSettings: Partial) => - Effect.gen(function* () { - yield* logger.debug(`Saving settings to: ${SETTINGS_FILE}`); - const currentSettings = yield* loadSettings; - const mergedSettings = { ...currentSettings, ...newSettings }; + saveLock.withPermits(1)( + Effect.gen(function* () { + yield* logger.debug(`Saving settings to: ${SETTINGS_FILE}`); + // Strict read: if the file exists but is unreadable/corrupt, fail rather + // than overwrite the user's (possibly hand-edited) settings with defaults. + const current = yield* readSettings; + const mergedSettings = { + ...(Option.getOrUndefined(current) ?? DEFAULTS), + ...newSettings, + }; - const dirExists = yield* fs.exists(SETTINGS_DIR); - if (!dirExists) { - yield* logger.debug(`Creating settings directory: ${SETTINGS_DIR}`); - yield* fs.mkdir(SETTINGS_DIR); - } - const settingsWithSchema = { - $schema: SCHEMA_URL, - ...mergedSettings, - }; - const data = new TextEncoder().encode(JSON.stringify(settingsWithSchema, null, 2)); - yield* fs.writeFile(SETTINGS_FILE, data); - yield* logger.info("Settings saved successfully"); - }).pipe( - Effect.tapError((err) => logger.error("Failed to save settings", err)), - Effect.mapError((err) => new Error(`Failed to save settings: ${err}`)), + const dirExists = yield* fs.exists(SETTINGS_DIR); + if (!dirExists) { + yield* logger.debug(`Creating settings directory: ${SETTINGS_DIR}`); + yield* fs.mkdir(SETTINGS_DIR); + } + const settingsWithSchema = { + $schema: SCHEMA_URL, + ...mergedSettings, + }; + const data = new TextEncoder().encode(JSON.stringify(settingsWithSchema, null, 2)); + // Write to a temp file and rename so a crash mid-write never leaves a + // truncated settings file behind. + const tmpFile = `${SETTINGS_FILE}.${process.pid}.tmp`; + yield* fs.writeFile(tmpFile, data); + yield* fs + .rename(tmpFile, SETTINGS_FILE) + .pipe(Effect.tapError(() => fs.remove(tmpFile).pipe(Effect.ignore))); + yield* logger.info("Settings saved successfully"); + }).pipe( + Effect.tapError((err) => logger.error("Failed to save settings", err)), + Effect.mapError((err) => new Error(`Failed to save settings: ${err}`)), + ), ); return SettingsService.of({ diff --git a/src/services/effects/SyncEngine.ts b/src/services/effects/SyncEngine.ts index 39e074f..6fcb6de 100644 --- a/src/services/effects/SyncEngine.ts +++ b/src/services/effects/SyncEngine.ts @@ -6,7 +6,7 @@ import { Buffer } from "node:buffer"; import * as fs from "node:fs/promises"; -import { Context, Data, Effect, Layer, Option, Stream } from "effect"; +import { Cause, Context, Data, Effect, Layer, Option, Result, Stream } from "effect"; import { EpisodeMatcher } from "@/services/effects/EpisodeMatcher"; import { FileSystem, type WriteError } from "@/services/effects/FileSystem"; import { Logger } from "@/services/effects/Logger"; @@ -40,7 +40,7 @@ export class CleanupError extends Data.TaggedError("CleanupError")<{ /** * SyncEngine Service Tag */ -export class SyncEngine extends Context.Tag("SyncEngine")< +export class SyncEngine extends Context.Service< SyncEngine, { /** Creates a plan for what needs to be copied or deleted */ @@ -63,7 +63,7 @@ export class SyncEngine extends Context.Tag("SyncEngine")< /** Cleans up empty show directories and system hidden files on the drive */ readonly cleanup: (drivePath: string) => Effect.Effect; } ->() {} +>()("SyncEngine") {} /** * Formats the destination path for an episode on the drive. @@ -83,68 +83,105 @@ export function formatDestPath( } /** - * Copies a file using node:fs handles and emits the number of bytes written in chunks. - * Cleans up partial files on failure or interruption. + * Makes destination paths unique within one plan. Two episodes can sanitize to the + * same path (same show/title/date, or truncated names), and the second copy would + * overwrite the first. The later one gets " (2)", " (3)", ... before the extension. + * Comparison is case-insensitive because the drive filesystems (FAT/exFAT/APFS) + * usually are. Deterministic: depends only on input order. + */ +export function dedupeDestPath(destPath: string, extension: string, used: Set): string { + const base = + extension && destPath.endsWith(extension) ? destPath.slice(0, -extension.length) : destPath; + const ext = base === destPath ? "" : extension; + let candidate = destPath; + let n = 2; + while (used.has(candidate.toLowerCase())) { + candidate = `${base} (${n})${ext}`; + n += 1; + } + used.add(candidate.toLowerCase()); + return candidate; +} + +/** Only MP3 carries ID3; tagging anything else (e.g. .m4a) would corrupt it. */ +const isMp3 = (path: string): boolean => path.toLowerCase().endsWith(".mp3"); + +/** Best-effort removal of a leftover file; never fails. */ +const removeQuietly = (path: string): Effect.Effect => + Effect.tryPromise(() => fs.rm(path, { force: true })).pipe(Effect.catch(() => Effect.void)); + +/** In-progress copies live at `${dest}.part` and are renamed into place once complete. */ +const partPathOf = (dest: string): string => `${dest}.part`; + +/** + * Copies `src` to `${dest}.part` using node:fs handles and emits the number of bytes + * written in chunks. The caller owns the finished .part file (tag it, then rename it + * to `dest`), which is why a final file on the drive is complete by construction. + * The .part is removed on failure or interruption of the copy itself, and any stale + * one from an earlier crashed run is removed before copying. */ const copyFileStream = (src: string, dest: string): Stream.Stream => - Stream.unwrapScoped( - Effect.gen(function* () { - const srcHandle = yield* Effect.acquireRelease( - Effect.tryPromise({ - try: () => fs.open(src, "r"), - catch: (cause) => new SyncCopyError({ src, dest, cause }), - }), - (handle) => Effect.promise(() => handle.close()), - ); + Stream.scoped( + Stream.unwrap( + Effect.gen(function* () { + const partPath = partPathOf(dest); + let finished = false; - const destHandle = yield* Effect.acquireRelease( - Effect.tryPromise({ - try: () => fs.open(dest, "w"), - catch: (cause) => new SyncCopyError({ src, dest, cause }), - }), - (handle) => - Effect.gen(function* () { - yield* Effect.promise(() => handle.close()); - // Check if we were interrupted or failed before finishing - const stat = yield* Effect.tryPromise(() => fs.stat(dest)).pipe( - Effect.catchAll(() => Effect.succeed(null)), - ); - const srcStat = yield* Effect.tryPromise(() => fs.stat(src)).pipe( - Effect.catchAll(() => Effect.succeed(null)), - ); - if (stat && srcStat && stat.size < srcStat.size) { - yield* Effect.tryPromise(() => fs.unlink(dest)).pipe( - Effect.catchAll(() => Effect.void), - ); - } + const srcHandle = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => fs.open(src, "r"), + catch: (cause) => new SyncCopyError({ src, dest, cause }), }), - ); + (handle) => Effect.promise(() => handle.close()), + ); - const buffer = Buffer.alloc(BUFFER_SIZE); - - return Stream.repeatEffectOption( - Effect.tryPromise({ - try: () => srcHandle.read(buffer, 0, BUFFER_SIZE, null), - catch: (cause) => Option.some(new SyncCopyError({ src, dest, cause })), - }).pipe( - Effect.flatMap(({ bytesRead }) => { - if (bytesRead === 0) return Effect.fail(Option.none()); - return Effect.tryPromise({ - try: () => destHandle.write(buffer.subarray(0, bytesRead)), - catch: (cause) => Option.some(new SyncCopyError({ src, dest, cause })), - }).pipe( - Effect.flatMap(() => - Effect.tryPromise({ - try: () => destHandle.datasync(), - catch: (cause) => Option.some(new SyncCopyError({ src, dest, cause })), - }), - ), - Effect.as(bytesRead), - ); + yield* removeQuietly(partPath); + + const destHandle = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => fs.open(partPath, "w"), + catch: (cause) => new SyncCopyError({ src, dest, cause }), }), - ), - ); - }), + (handle) => + Effect.gen(function* () { + yield* Effect.promise(() => handle.close()); + // Failed or interrupted before the source was fully read: drop the + // partial. Nothing ever appears at `dest` until the caller renames. + if (!finished) { + yield* removeQuietly(partPath); + } + }), + ); + + const buffer = Buffer.alloc(BUFFER_SIZE); + + return Stream.fromEffectRepeat( + Effect.tryPromise({ + try: () => srcHandle.read(buffer, 0, BUFFER_SIZE, null), + catch: (cause) => new SyncCopyError({ src, dest, cause }), + }).pipe( + Effect.flatMap(({ bytesRead }): Effect.Effect => { + if (bytesRead === 0) { + finished = true; + return Cause.done(); + } + return Effect.tryPromise({ + try: () => destHandle.write(buffer.subarray(0, bytesRead)), + catch: (cause) => new SyncCopyError({ src, dest, cause }), + }).pipe( + Effect.flatMap(() => + Effect.tryPromise({ + try: () => destHandle.datasync(), + catch: (cause) => new SyncCopyError({ src, dest, cause }), + }), + ), + Effect.as(bytesRead), + ); + }), + ), + ); + }), + ), ); /** @@ -169,6 +206,7 @@ export const SyncEngineLive = Layer.effect( podcast: (typeof mac)[number]; fullDestPath: string; }> = []; + const usedDestPaths = new Set(); for (const podcast of mac) { for (const episode of podcast.episodes) { @@ -176,12 +214,12 @@ export const SyncEngineLive = Layer.effect( if (matcher.matchEpisode(podcast.title, episode, driveIndex)) continue; const ext = fs.getExtension(episode.filePath); - const fullDestPath = formatDestPath( - drivePath, - podcast.title, - episode.title, - episode.published, + // Deduped before the existence check so the " (2)" assignment is + // stable across runs, whatever is already on the drive. + const fullDestPath = dedupeDestPath( + formatDestPath(drivePath, podcast.title, episode.title, episode.published, ext), ext, + usedDestPaths, ); candidates.push({ episode, podcast, fullDestPath }); } @@ -191,10 +229,22 @@ export const SyncEngineLive = Layer.effect( candidates, ({ episode, podcast, fullDestPath }) => Effect.gen(function* () { + // getFileSize yields 0 for a missing/unreadable file. Planning such + // an item would fail the whole execute stream at copy time. + const sSize = yield* fs.getFileSize(episode.filePath); + if (sSize <= 0) { + yield* logger.info( + `Warning: skipping "${episode.title}", source is missing or empty: ${episode.filePath}`, + ); + return Option.none(); + } + + // Copies land at `${dest}.part` and are renamed into place only once + // complete (and tagged), so any non-empty file at the final path is + // whole. Do not compare sizes: ID3 tagging legitimately changes them. const dSize = yield* fs.getFileSize(fullDestPath); if (dSize > 0) return Option.none(); - const sSize = yield* fs.getFileSize(episode.filePath); return Option.some({ episode, podcast, @@ -212,7 +262,12 @@ export const SyncEngineLive = Layer.effect( totalBytes += opt.value.size; } } - const plan: SyncPlan = { toCopy, toDelete: [], totalFiles: toCopy.length, totalBytes }; + const plan: SyncPlan = { + toCopy, + toDelete: [], + totalFiles: toCopy.length, + totalBytes, + }; yield* logger.info( `Sync plan created: ${plan.totalFiles} files to copy, ${plan.totalBytes} bytes total`, ); @@ -225,6 +280,10 @@ export const SyncEngineLive = Layer.effect( yield* logger.info(`Executing sync plan: ${plan.totalFiles} files to copy`); const startTime = Date.now(); let bytesWritten = 0; + // Files copied but discarded because tagging failed. The sync still + // succeeds, but the count is surfaced so the user is not told + // "Sync complete" while files are quietly missing. + let discarded = 0; return Stream.fromIterable(plan.toCopy).pipe( Stream.zipWithIndex, @@ -233,15 +292,16 @@ export const SyncEngineLive = Layer.effect( const initialProgress = Stream.succeed({ currentFile: item.episode.title, - currentIndex: i, + currentIndex: i + 1, totalFiles: plan.totalFiles, bytesTransferred: bytesWritten, totalBytes: plan.totalBytes, + discarded, startTime, status: "syncing", }); - const copyFlow = Stream.fromEffect( + const copyOnly = Stream.fromEffect( Effect.gen(function* () { yield* logger.debug( `Starting copy of [${i + 1}/${plan.totalFiles}]: ${item.episode.title}`, @@ -252,76 +312,132 @@ export const SyncEngineLive = Layer.effect( ).pipe( Stream.flatMap(() => copyFileStream(item.sourcePath, item.destPath).pipe( - Stream.map((bytes) => { + Stream.map((bytes): SyncProgress => { bytesWritten += bytes; return { currentFile: item.episode.title, - currentIndex: i, + currentIndex: i + 1, totalFiles: plan.totalFiles, bytesTransferred: bytesWritten, totalBytes: plan.totalBytes, + discarded, startTime, status: "syncing", - } as SyncProgress; + }; }), ), ), - Stream.concat( - Stream.fromEffect( - Effect.sync(() => { - // Emit a progress update to show we are tagging - return { - currentFile: `Tagging: ${item.episode.title}`, - currentIndex: i, - totalFiles: plan.totalFiles, - bytesTransferred: bytesWritten, - totalBytes: plan.totalBytes, - startTime, - status: "syncing", - } as SyncProgress; - }), - ), - ), - Stream.concat( - Stream.fromEffect( - Effect.gen(function* () { - const metadataEditor = yield* MetadataEditor; - yield* logger.debug(`Tagging episode: ${item.episode.title}`); - yield* metadataEditor - .write(item.destPath, { - title: item.episode.title, - artist: item.podcast.author, - album: item.podcast.title, - genre: "Podcast", - year: item.episode.published.getFullYear().toString(), - comment: `Published: ${item.episode.published.toISOString().split("T")[0]}`, - }) - .pipe( - Effect.tap(() => - logger.debug(`Successfully tagged: ${item.episode.title}`), - ), - Effect.tapError((err) => - logger.error(`Failed to tag: ${item.episode.title}`, err), - ), - Effect.catchAll(() => Effect.void), - ); - }), - ).pipe(Stream.filterMap(() => Option.none())), - ), ); + const partPath = partPathOf(item.destPath); + + // Moves the finished .part into place. Any failure or interruption + // here drops the .part so nothing half-done is left on the drive. + const finalize = Effect.gen(function* () { + const fs = yield* FileSystem; + yield* fs.rename(partPath, item.destPath); + }).pipe(Effect.onError(() => removeQuietly(partPath))); + + // Only MP3s carry ID3. Tagging .m4a etc. prepends an ID3 block and + // corrupts the file, so those are copied untouched. + const copyFlow = !isMp3(item.destPath) + ? copyOnly.pipe(Stream.concat(Stream.fromEffect(finalize).pipe(Stream.drain))) + : copyOnly.pipe( + Stream.concat( + Stream.fromEffect( + Effect.sync((): SyncProgress => ({ + // Emit a progress update to show we are tagging + currentFile: `Tagging: ${item.episode.title}`, + currentIndex: i + 1, + totalFiles: plan.totalFiles, + bytesTransferred: bytesWritten, + totalBytes: plan.totalBytes, + discarded, + startTime, + status: "syncing", + })), + ), + ), + Stream.concat( + Stream.fromEffect( + Effect.gen(function* () { + const metadataEditor = yield* MetadataEditor; + const fs = yield* FileSystem; + yield* logger.debug(`Tagging episode: ${item.episode.title}`); + // Tag the .part so the final path only ever holds a finished file. + const tagged = yield* metadataEditor + .write(partPath, { + title: item.episode.title, + artist: item.podcast.author, + album: item.podcast.title, + genre: "Podcast", + // An invalid Date makes toISOString throw (a defect that + // would kill the stream), so omit date-derived tags. + ...(Number.isNaN(item.episode.published.getTime()) + ? {} + : { + year: item.episode.published.getFullYear().toString(), + comment: `Published: ${item.episode.published.toISOString().split("T")[0]}`, + }), + }) + .pipe( + Effect.tap(() => + logger.debug(`Successfully tagged: ${item.episode.title}`), + ), + Effect.as(true), + // A failed tag never fails the sync, but a half-tagged file must + // not reach the drive, so discard the .part and let the next + // run re-copy. + Effect.tapError((err) => + logger.error( + `Failed to tag: ${item.episode.title}, discarding copied file: ${item.destPath}`, + err, + ), + ), + Effect.catch(() => + Effect.suspend(() => { + // Count it even if the removal itself fails: either way + // the file did not make it onto the drive correctly. + discarded += 1; + return fs.remove(partPath).pipe( + Effect.tapError((err) => + logger.error( + `Failed to discard untagged file: ${partPath}`, + err, + ), + ), + Effect.catch(() => Effect.void), + Effect.as(false), + ); + }), + ), + // Interrupted mid-tag: do not leave the .part behind. + Effect.onInterrupt(() => removeQuietly(partPath)), + ); + if (tagged) yield* finalize; + }), + ).pipe(Stream.filterMap(() => Result.fail(undefined))), + ), + ); + return Stream.concat(initialProgress, copyFlow); }), Stream.concat( - Stream.succeed({ - currentFile: "", - currentIndex: plan.totalFiles, - totalFiles: plan.totalFiles, - bytesTransferred: plan.totalBytes, - totalBytes: plan.totalBytes, - startTime, - status: "complete", - }), + // Deferred, not Stream.succeed: `discarded` is only final once every + // file has been tagged, and Stream.succeed builds its value when + // the stream is assembled rather than when it is consumed. + Stream.suspend((): Stream.Stream => + Stream.succeed({ + currentFile: "", + currentIndex: plan.totalFiles, + totalFiles: plan.totalFiles, + bytesTransferred: plan.totalBytes, + totalBytes: plan.totalBytes, + discarded, + startTime, + status: "complete", + }), + ), ), Stream.tap(() => logger.info("Sync plan execution complete")), ); @@ -342,6 +458,9 @@ export const SyncEngineLive = Layer.effect( ), Stream.runDrain, ); + yield* fs + .rename(partPathOf(dest), dest) + .pipe(Effect.onError(() => removeQuietly(partPathOf(dest)))); }), cleanup: (drivePath) => @@ -351,7 +470,7 @@ export const SyncEngineLive = Layer.effect( const podcastsPath = `${drivePath}/Podcasts`; const showDirs = yield* fs.readDir(podcastsPath).pipe( Effect.mapError((cause) => new CleanupError({ path: podcastsPath, cause })), - Effect.catchAll(() => Effect.succeed([] as string[])), + Effect.catch(() => Effect.succeed([] as string[])), ); for (const showDir of showDirs) { if (fs.isSystemHiddenFile(showDir)) continue; @@ -359,13 +478,13 @@ export const SyncEngineLive = Layer.effect( const isDir = yield* fs.isDirectory(showPath); if (isDir) { yield* logger.debug(`Cleaning show directory: ${showDir}`); - yield* fs.cleanupSystemHiddenFiles(showPath).pipe(Effect.catchAll(() => Effect.void)); + yield* fs.cleanupSystemHiddenFiles(showPath).pipe(Effect.catch(() => Effect.void)); const empty = yield* fs.isDirEmpty(showPath); if (empty) { yield* logger.info(`Removing empty show directory: ${showDir}`); yield* fs.remove(showPath).pipe( Effect.mapError((cause) => new CleanupError({ path: showPath, cause })), - Effect.catchAll(() => Effect.void), + Effect.catch(() => Effect.void), ); } } @@ -411,33 +530,38 @@ export const createSyncEngineTest = (mockFiles: Map = new Ma totalBytes += sSize; } } - return { toCopy, toDelete: [], totalFiles: toCopy.length, totalBytes } as SyncPlan; + return { + toCopy, + toDelete: [], + totalFiles: toCopy.length, + totalBytes, + } satisfies SyncPlan; }), execute: (plan) => { const startTime = Date.now(); return Stream.fromIterable(plan.toCopy).pipe( Stream.zipWithIndex, - Stream.map( - ([item, i]): SyncProgress => ({ - currentFile: item.episode.title, - currentIndex: i + 1, - totalFiles: plan.totalFiles, - bytesTransferred: plan.toCopy.slice(0, i + 1).reduce((acc, i) => acc + i.size, 0), - totalBytes: plan.totalBytes, - startTime, - status: "syncing", - }), - ), + Stream.map(([item, i]): SyncProgress => ({ + currentFile: item.episode.title, + currentIndex: i + 1, + totalFiles: plan.totalFiles, + bytesTransferred: plan.toCopy.slice(0, i + 1).reduce((acc, i) => acc + i.size, 0), + totalBytes: plan.totalBytes, + discarded: 0, + startTime, + status: "syncing", + })), Stream.concat( - Stream.succeed({ + Stream.succeed({ currentFile: "", currentIndex: plan.totalFiles, totalFiles: plan.totalFiles, bytesTransferred: plan.totalBytes, totalBytes: plan.totalBytes, + discarded: 0, startTime, status: "complete", - } as SyncProgress), + }), ), ); }, diff --git a/src/services/workers/db.worker.ts b/src/services/workers/db.worker.ts index d992a8b..6c5bbad 100644 --- a/src/services/workers/db.worker.ts +++ b/src/services/workers/db.worker.ts @@ -6,11 +6,14 @@ declare var self: Worker; const APPLE_EPOCH_OFFSET = 978307200; +/** Fallback used when a podcast has no author recorded, so ID3 artist is never blank. */ +const UNKNOWN_AUTHOR = "Unknown"; + const parseFileUrl = (url: string) => Effect.try({ try: () => decodeURIComponent(new URL(url).pathname), catch: () => new Error("Invalid URL"), - }).pipe(Effect.orElse(() => Effect.succeed(url.replace("file://", "")))); + }).pipe(Effect.orElseSucceed(() => url.replace("file://", ""))); self.onmessage = (event: MessageEvent) => { const { type, dbPath } = event.data; @@ -21,11 +24,13 @@ self.onmessage = (event: MessageEvent) => { const rows = yield* Effect.try({ try: () => { const db = new Database(dbPath, { readonly: true }); - const query = ` + try { + const query = ` SELECT e.Z_PK as id, e.ZTITLE as title, p.ZTITLE as showName, + p.ZAUTHOR as author, e.ZASSETURL as assetUrl, e.ZPUBDATE as pubDate, e.ZDURATION as duration @@ -34,9 +39,10 @@ self.onmessage = (event: MessageEvent) => { WHERE e.ZASSETURL IS NOT NULL ORDER BY e.ZPUBDATE DESC `; - const results = db.query(query).all() as EpisodeRow[]; - db.close(); - return results; + return db.query(query).all() as EpisodeRow[]; + } finally { + db.close(); + } }, catch: (error) => (error instanceof Error ? error : new Error(String(error))), }); @@ -53,6 +59,7 @@ self.onmessage = (event: MessageEvent) => { id: String(row.id), title: row.title ?? "Untitled", showName: row.showName ?? "Unknown", + author: row.author?.trim() || UNKNOWN_AUTHOR, filePath, published: row.pubDate ? new Date((row.pubDate + APPLE_EPOCH_OFFSET) * 1000) diff --git a/src/store/index.ts b/src/store/index.ts index fd9587f..362b81a 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,4 +1,4 @@ -import { createStore } from "solid-js/store"; +import { createStore } from "solid-js"; import type { DebugMessage } from "@/components/DebugPopup"; import type { Drive } from "@/types/drive"; import type { AppView, FocusedPane } from "@/types/keyboard"; @@ -6,6 +6,7 @@ import type { PodcastEpisode } from "@/types/podcast"; export interface TransferProgress { currentFile: string; + lastFile: string; filesDone: number; totalFiles: number; bytesTransferred: number; @@ -28,6 +29,7 @@ export interface AppState { isScanning: boolean; transferProgress: TransferProgress; errorMsg: string; + successMsg: string; debugMessages: DebugMessage[]; lastKey: string | null; lastSavedTheme: string; @@ -36,7 +38,7 @@ export interface AppState { favoriteDrives: string[]; } -const initialState: AppState = { +const createInitialState = (): AppState => ({ appView: "normal", focusedPane: "mac", macIndex: 0, @@ -53,6 +55,7 @@ const initialState: AppState = { isScanning: false, transferProgress: { currentFile: "", + lastFile: "", filesDone: 0, totalFiles: 0, bytesTransferred: 0, @@ -60,75 +63,183 @@ const initialState: AppState = { speed: 0, }, errorMsg: "", + successMsg: "", debugMessages: [], lastKey: null, lastSavedTheme: "Catppuccin", favoriteDrives: [], +}); + +export const [state, setState] = createStore(createInitialState()); + +const updateState = (update: (draft: AppState) => void): void => { + setState(update); }; -export const [state, setState] = createStore(initialState); +const clampIndex = (index: number, length: number): number => + Math.min(Math.max(0, index), Math.max(0, length - 1)); + +const resolveUpdate = (value: T | ((previous: T) => T), previous: T): T => + typeof value === "function" ? (value as (previous: T) => T)(previous) : value; export const actions = { - setAppView: (view: AppView) => setState("appView", view), - setFocusedPane: (pane: FocusedPane) => setState("focusedPane", pane), - setMacIndex: (index: number | ((prev: number) => number)) => setState("macIndex", index), - setDriveIndex: (index: number | ((prev: number) => number)) => setState("driveIndex", index), - setDriveMenuIndex: (index: number | ((prev: number) => number)) => - setState("driveMenuIndex", index), - setThemeMenuIndex: (index: number | ((prev: number) => number)) => - setState("themeMenuIndex", index), - setDebugMenuIndex: (index: number | ((prev: number) => number)) => - setState("debugMenuIndex", index), - setMacPodcasts: (episodes: PodcastEpisode[] | ((prev: PodcastEpisode[]) => PodcastEpisode[])) => - setState("macPodcasts", episodes), - setDrivePodcasts: (episodes: PodcastEpisode[] | ((prev: PodcastEpisode[]) => PodcastEpisode[])) => - setState("drivePodcasts", episodes), - setDrives: (drives: Drive[] | ((prev: Drive[]) => Drive[])) => setState("drives", drives), - setCurrentDrive: (drive: Drive | null | ((prev: Drive | null) => Drive | null)) => - setState("currentDrive", drive), - setLoadingMac: (loading: boolean) => setState("loadingMac", loading), - setLoadingDrive: (loading: boolean) => setState("loadingDrive", loading), - setIsScanning: (scanning: boolean) => setState("isScanning", scanning), + setAppView: (view: AppView) => + updateState((draft) => { + draft.appView = view; + }), + setFocusedPane: (pane: FocusedPane) => + updateState((draft) => { + draft.focusedPane = pane; + }), + setMacIndex: (index: number | ((previous: number) => number)) => + updateState((draft) => { + draft.macIndex = resolveUpdate(index, draft.macIndex); + }), + setDriveIndex: (index: number | ((previous: number) => number)) => + updateState((draft) => { + draft.driveIndex = resolveUpdate(index, draft.driveIndex); + }), + setDriveMenuIndex: (index: number | ((previous: number) => number)) => + updateState((draft) => { + draft.driveMenuIndex = resolveUpdate(index, draft.driveMenuIndex); + }), + setThemeMenuIndex: (index: number | ((previous: number) => number)) => + updateState((draft) => { + draft.themeMenuIndex = resolveUpdate(index, draft.themeMenuIndex); + }), + setDebugMenuIndex: (index: number | ((previous: number) => number)) => + updateState((draft) => { + draft.debugMenuIndex = resolveUpdate(index, draft.debugMenuIndex); + }), + setMacPodcasts: ( + episodes: PodcastEpisode[] | ((previous: PodcastEpisode[]) => PodcastEpisode[]), + ) => + updateState((draft) => { + const next = resolveUpdate(episodes, draft.macPodcasts); + draft.macPodcasts = next; + // The list may have shrunk under the cursor; keep it on a real row. + draft.macIndex = clampIndex(draft.macIndex, next.length); + }), + setDrivePodcasts: ( + episodes: PodcastEpisode[] | ((previous: PodcastEpisode[]) => PodcastEpisode[]), + ) => + updateState((draft) => { + const next = resolveUpdate(episodes, draft.drivePodcasts); + draft.drivePodcasts = next; + draft.driveIndex = clampIndex(draft.driveIndex, next.length); + }), + setDrives: (drives: Drive[] | ((previous: Drive[]) => Drive[])) => + updateState((draft) => { + draft.drives = resolveUpdate(drives, draft.drives); + }), + setCurrentDrive: (drive: Drive | null | ((previous: Drive | null) => Drive | null)) => + updateState((draft) => { + draft.currentDrive = resolveUpdate(drive, draft.currentDrive); + }), + setLoadingMac: (loading: boolean) => + updateState((draft) => { + draft.loadingMac = loading; + }), + setLoadingDrive: (loading: boolean) => + updateState((draft) => { + draft.loadingDrive = loading; + }), + setIsScanning: (scanning: boolean) => + updateState((draft) => { + draft.isScanning = scanning; + }), updateTransferProgress: (progress: Partial) => - setState("transferProgress", (prev) => ({ ...prev, ...progress })), - setErrorMsg: (msg: string) => setState("errorMsg", msg), + updateState((draft) => { + if (progress.currentFile === "Preparing...") { + draft.transferProgress.lastFile = ""; + } else if (progress.currentFile) { + draft.transferProgress.lastFile = progress.currentFile; + } + Object.assign(draft.transferProgress, progress); + }), + // The two message fields are mutually exclusive: one status line at a time. + // Enforced here rather than at each call site so a stale success cannot sit + // next to a fresh error (and vice versa) whichever setter runs. + setErrorMsg: (msg: string) => + updateState((draft) => { + draft.errorMsg = msg; + draft.successMsg = ""; + }), + setSuccessMsg: (msg: string) => + updateState((draft) => { + draft.successMsg = msg; + draft.errorMsg = ""; + }), addDebugMessage: (message: string, type: DebugMessage["type"] = "info") => { - setState("debugMessages", (prev) => { - const next = [...prev, { timestamp: Date.now(), message, type }]; - return next.length > 500 ? next.slice(-500) : next; + updateState((draft) => { + draft.debugMessages.push({ timestamp: Date.now(), message, type }); + if (draft.debugMessages.length > 500) { + draft.debugMessages.splice(0, draft.debugMessages.length - 500); + } }); }, - clearDebugMessages: () => setState("debugMessages", []), - setLastKey: (key: string | null) => setState("lastKey", key), - setLastSavedTheme: (theme: string) => setState("lastSavedTheme", theme), - setFavoriteDrives: (drives: string[]) => setState("favoriteDrives", drives), + clearDebugMessages: () => + updateState((draft) => { + draft.debugMessages = []; + }), + setLastKey: (key: string | null) => + updateState((draft) => { + draft.lastKey = key; + }), + setLastSavedTheme: (theme: string) => + updateState((draft) => { + draft.lastSavedTheme = theme; + }), + setFavoriteDrives: (drives: string[]) => + updateState((draft) => { + draft.favoriteDrives = drives; + }), toggleFavoriteDrive: (driveId: string) => { - setState("favoriteDrives", (prev) => { - if (prev.includes(driveId)) { - return prev.filter((id) => id !== driveId); + updateState((draft) => { + const index = draft.favoriteDrives.indexOf(driveId); + if (index >= 0) { + draft.favoriteDrives.splice(index, 1); + } else { + draft.favoriteDrives.push(driveId); } - return [...prev, driveId]; }); }, toggleMacSelection: (index: number) => { - setState("macPodcasts", index, "selected", (s) => !s); + updateState((draft) => { + const episode = draft.macPodcasts[index]; + if (episode) episode.selected = !episode.selected; + }); }, toggleAllMacSelection: () => { const allSelected = state.macPodcasts.every((p) => p.selected); - setState("macPodcasts", (prev) => prev.map((p) => ({ ...p, selected: !allSelected }))); + updateState((draft) => { + for (const episode of draft.macPodcasts) episode.selected = !allSelected; + }); }, clearMacSelection: () => { - setState("macPodcasts", (prev) => prev.map((p) => ({ ...p, selected: false }))); + updateState((draft) => { + for (const episode of draft.macPodcasts) episode.selected = false; + }); }, toggleDriveSelection: (index: number) => { - setState("drivePodcasts", index, "selected", (s) => !s); + updateState((draft) => { + const episode = draft.drivePodcasts[index]; + if (episode) episode.selected = !episode.selected; + }); }, toggleAllDriveSelection: () => { const allSelected = state.drivePodcasts.every((p) => p.selected); - setState("drivePodcasts", (prev) => prev.map((p) => ({ ...p, selected: !allSelected }))); + updateState((draft) => { + for (const episode of draft.drivePodcasts) episode.selected = !allSelected; + }); }, clearDriveSelection: () => { - setState("drivePodcasts", (prev) => prev.map((p) => ({ ...p, selected: false }))); + updateState((draft) => { + for (const episode of draft.drivePodcasts) episode.selected = false; + }); }, - resetState: () => setState(initialState), + resetState: () => + updateState((draft) => { + Object.assign(draft, createInitialState()); + }), }; diff --git a/src/theme/colors.ts b/src/theme/colors.ts index 5eacd4c..79efd83 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -1,5 +1,4 @@ -import { createSignal } from "solid-js"; -import { createStore, reconcile } from "solid-js/store"; +import { createSignal, createStore, reconcile } from "solid-js"; import { createTheme, Palettes, type ThemePalette } from "./themes"; export type Theme = ReturnType; @@ -20,3 +19,8 @@ export function setTheme(themeName: string) { setPalette(palette); } } + +export function previewThemeAt(index: number): void { + const themeName = Themes[index]; + if (themeName) setTheme(themeName); +} diff --git a/src/theme/index.ts b/src/theme/index.ts deleted file mode 100644 index bbdc171..0000000 --- a/src/theme/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./colors"; diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 0600a51..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./drive"; -export * from "./keyboard"; -export * from "./podcast"; -export * from "./sync"; diff --git a/src/types/keyboard.ts b/src/types/keyboard.ts index 3bb5674..3860ebc 100644 --- a/src/types/keyboard.ts +++ b/src/types/keyboard.ts @@ -5,12 +5,7 @@ export type AppView = | "transferring" | "confirm" | "themeSelection" - | "debug" - | "main" - | "podcasts" - | "episodes" - | "drives" - | "sync"; + | "debug"; /** * Focused pane in dual-pane layout. diff --git a/src/types/podcast.ts b/src/types/podcast.ts index 11bf3fd..4110a62 100644 --- a/src/types/podcast.ts +++ b/src/types/podcast.ts @@ -1,11 +1,19 @@ export interface Episode { id: string; title: string; + /** Podcast author, used as the ID3 artist tag. Never the empty string in production. */ + author: string; duration: number; published: Date; onDrive: boolean; filePath: string; fileSize: number; + /** + * Drive-scan only: the date string exactly as it appears in the filename + * ("YYYY-MM-DD" or "Unknown"), or "" when the file has no date prefix. + * Used for drive matching so no timezone round-trip is involved. + */ + driveDate?: string; } export interface PodcastEpisode extends Episode { @@ -22,12 +30,11 @@ export interface Podcast { uuid?: string; // UUID from macOS Podcasts DB (for linking episodes) } -export type PaneId = "mac" | "drive"; - export interface EpisodeRow { id: number; title: string | null; showName: string | null; + author: string | null; assetUrl: string | null; pubDate: number | null; duration: number | null; diff --git a/src/types/sync.ts b/src/types/sync.ts index 41f3bb3..b63cd0d 100644 --- a/src/types/sync.ts +++ b/src/types/sync.ts @@ -6,22 +6,13 @@ export interface SyncProgress { totalFiles: number; bytesTransferred: number; totalBytes: number; + /** Files copied but discarded because tagging failed. Monotonic across a run. */ + discarded: number; startTime: number; status: "idle" | "syncing" | "complete" | "error"; error?: string; } -export interface SyncOptions { - sourcePath: string; // Path to Mac podcasts - destinationPath: string; // Path to USB drive - deleteOrphans: boolean; // Remove episodes not on Mac - dryRun: boolean; // Preview changes without syncing -} - -export interface SyncEpisode extends Episode { - podcastTitle: string; -} - export interface CopyItem { episode: Episode; podcast: Podcast; @@ -42,23 +33,3 @@ export interface SyncPlan { totalBytes: number; totalFiles: number; } - -export interface SyncResult { - copied: number; - deleted: number; - failed: Array<{ file: string; error: string }>; - duration: number; -} - -export interface DriveEpisode { - id: string; - title: string; - path: string; - size: number; -} - -export interface DrivePodcast { - name: string; - path: string; - episodes: DriveEpisode[]; -} diff --git a/src/utils/drive.ts b/src/utils/drive.ts index 1fd9aa1..5c981ee 100644 --- a/src/utils/drive.ts +++ b/src/utils/drive.ts @@ -6,6 +6,7 @@ import { formatBytes } from "@/utils/formatting"; * e.g., "WALKMAN (15.2 GB free)" */ export function formatDriveInfo(drive: Drive): string { - const freeText = drive.freeSpace >= 0 ? `${formatBytes(drive.freeSpace)} free` : "unknown free space"; + const freeText = + drive.freeSpace >= 0 ? `${formatBytes(drive.freeSpace)} free` : "unknown free space"; return `${drive.name} (${freeText})`; } diff --git a/src/utils/driveSelection.ts b/src/utils/driveSelection.ts new file mode 100644 index 0000000..b94dce1 --- /dev/null +++ b/src/utils/driveSelection.ts @@ -0,0 +1,11 @@ +import { actions } from "@/store"; +import type { Drive } from "@/types/drive"; + +export const selectDrive = (drive: Drive, loadDrivePodcasts: (drive: Drive) => void): void => { + actions.setCurrentDrive(drive); + // The previous drive's listing must not show under the new drive while it loads. + actions.setDrivePodcasts([]); + actions.setErrorMsg(""); + loadDrivePodcasts(drive); + actions.setAppView("normal"); +}; diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index a138b67..fc94886 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -6,6 +6,11 @@ export const APPLE_EPOCH_OFFSET = 978307200; const BYTES_UNITS = ["B", "KB", "MB", "GB", "TB"] as const; +/** Fallback podcast author. Matches the DriveScan convention so ID3 artist is never blank. */ +export const UNKNOWN_AUTHOR = "Unknown"; +/** Date label used in drive filenames when an episode has no usable publish date. */ +export const UNKNOWN_DATE = "Unknown"; + /** * Formats bytes into human-readable string * e.g., 1073741824 → "1.0 GB" @@ -30,9 +35,16 @@ export function formatBytes(rawBytes: number): string { return `${value.toFixed(1)} ${BYTES_UNITS[unitIndex]}`; } +/** Maximum length of a sanitized name, in code points. */ +const MAX_FILENAME_LENGTH = 100; + /** * Helper to sanitize filenames for FAT32 compatibility. * Replaces invalid characters with dashes and normalizes spaces. + * + * The result is NFC-normalized. HFS+ hands back NFD names from readdir while + * Podcasts.app metadata is typically NFC, so keys built from the two sides only + * match if both are normalized to the same form. */ export function sanitizeFilename(name: string): string { if (!name || name.trim().length === 0) { @@ -41,7 +53,7 @@ export function sanitizeFilename(name: string): string { // Replace invalid chars with - // < > : " / \ | ? * - let sanitized = name.replace(/[<>:"/\\|?*]/g, "-"); + let sanitized = name.normalize("NFC").replace(/[<>:"/\\|?*]/g, "-"); sanitized = sanitized.replace(/&/g, "and"); sanitized = sanitized.replace(/'/g, ""); @@ -51,12 +63,19 @@ export function sanitizeFilename(name: string): string { return "untitled"; } - // Truncate to 100 chars (FAT32 limit is higher but this is safe) - if (sanitized.length > 100) { - sanitized = sanitized.slice(0, 100); + // Truncate by code point: slicing UTF-16 units can cut a surrogate pair in + // half and leave a lone surrogate, which is not a valid filename. + const codePoints = Array.from(sanitized); + if (codePoints.length > MAX_FILENAME_LENGTH) { + sanitized = codePoints.slice(0, MAX_FILENAME_LENGTH).join(""); + } + + // "." and ".." (or any run of dots) are directory references, not names. + if (/^\.+$/.test(sanitized)) { + return "_"; } - return sanitized; + return sanitized.normalize("NFC"); } /** @@ -89,11 +108,12 @@ export function groupEpisodesByPodcast(episodes: PodcastEpisode[]): Podcast[] { return Array.from(grouped.entries()).map(([showName, eps]) => ({ id: showName, title: showName, - author: "", + author: eps.find((ep) => ep.author.trim().length > 0)?.author ?? UNKNOWN_AUTHOR, episodeCount: eps.length, episodes: eps.map((ep) => ({ id: ep.id, title: ep.title, + author: ep.author, duration: ep.duration, published: ep.published, onDrive: false, @@ -128,7 +148,7 @@ export function formatDuration(rawSeconds: number): string { */ export function formatDate(date: Date): string { if (Number.isNaN(date.getTime()) || date.getTime() === 0) { - return "Unknown"; + return UNKNOWN_DATE; } const year = date.getFullYear(); @@ -188,6 +208,7 @@ export const mapPodcastsToEpisodes = (podcasts: Podcast[]): PodcastEpisode[] => p.episodes.map((ep) => ({ id: ep.id, title: ep.title, + author: p.author.trim() || UNKNOWN_AUTHOR, showName: p.title, filePath: ep.filePath, published: ep.published, @@ -195,6 +216,7 @@ export const mapPodcastsToEpisodes = (podcasts: Podcast[]): PodcastEpisode[] => fileSize: ep.fileSize, selected: false, onDrive: ep.onDrive, + driveDate: ep.driveDate, })), ); }; @@ -215,6 +237,7 @@ export function mapDriveEpisodes( episodes.push({ id: info.path, title: info.title, + author: UNKNOWN_AUTHOR, showName, filePath: info.path, published: new Date(), // Not available from file scan @@ -228,23 +251,50 @@ export function mapDriveEpisodes( return episodes; } +/** + * Builds the canonical show/file key used to match an episode with a file on the + * drive: "Show/YYYY-MM-DD - Title.mp3", mirroring formatDestPath. `date` is either a + * Date (formatted with formatDate, exactly as formatDestPath does) or the literal + * date string from a filename ("YYYY-MM-DD" or "Unknown"). An empty string yields the + * undated legacy key "Show/Title.mp3". + */ +export function driveEpisodeKey(showName: string, title: string, date: Date | string): string { + const dateStr = typeof date === "string" ? date : formatDate(date); + const prefix = dateStr ? `${dateStr} - ` : ""; + return `${sanitizeFilename(showName)}/${prefix}${sanitizeFilename(title)}.mp3`; +} + +/** + * Removes the " (2)", " (3)", ... suffix that SyncEngine.dedupeDestPath appends to + * a title. Returns the title unchanged when there is no such suffix. + */ +export function stripDedupeSuffix(title: string): string { + const match = title.match(/^(.*\S)\s\((\d+)\)$/); + return match && Number(match[2]) >= 2 ? (match[1] ?? title) : title; +} + /** * Mark episodes as being on the drive based on file path matching. - * Matches on Show Name + Episode Title using sanitized keys for robustness. + * Matches on Show Name + publish date + Episode Title using sanitized keys, the same + * keys as DriveScan.buildDriveIndex. Drive files without a date prefix (legacy) match + * on show + title alone, and a " (2)" dedupe suffix on a drive title is ignored. */ export function markEpisodesOnDrive( macEpisodes: PodcastEpisode[], driveEpisodes: PodcastEpisode[], ): PodcastEpisode[] { - // Create a set of "SanitizedShow/SanitizedTitle" keys - const driveSet = new Set( - driveEpisodes.map((e) => `${sanitizeFilename(e.showName)}/${sanitizeFilename(e.title)}`), - ); + const driveSet = new Set(); + for (const e of driveEpisodes) { + const date = e.driveDate ?? e.published; + driveSet.add(driveEpisodeKey(e.showName, e.title, date)); + const stripped = stripDedupeSuffix(e.title); + if (stripped !== e.title) driveSet.add(driveEpisodeKey(e.showName, stripped, date)); + } return macEpisodes.map((episode) => ({ ...episode, - onDrive: driveSet.has( - `${sanitizeFilename(episode.showName)}/${sanitizeFilename(episode.title)}`, - ), + onDrive: + driveSet.has(driveEpisodeKey(episode.showName, episode.title, episode.published)) || + driveSet.has(driveEpisodeKey(episode.showName, episode.title, "")), })); } diff --git a/src/utils/keyboard.ts b/src/utils/keyboard.ts index a18633e..f4c16c1 100644 --- a/src/utils/keyboard.ts +++ b/src/utils/keyboard.ts @@ -1,11 +1,5 @@ import type { AppView } from "@/types/keyboard"; -const NAVIGATION_KEYS = new Set(["j", "k", "up", "down"]); - -export function isNavigationKey(key: string): boolean { - return NAVIGATION_KEYS.has(key.toLowerCase()); -} - /** * Creates a condensed list of footer shortcuts for a given view. * Matches original podcasts-sync layout. @@ -18,10 +12,7 @@ export function getFooterShortcuts( const line2: Array<{ key: string; label: string }> = []; switch (view) { - case "main": case "normal": - case "podcasts": - case "episodes": line1.push( { key: "↑/↓", label: "navigate" }, { key: "tab", label: "switch list" }, @@ -37,9 +28,7 @@ export function getFooterShortcuts( break; case "confirm": case "debug": - case "drives": case "driveSelection": - case "sync": case "syncing": case "themeSelection": case "transferring": diff --git a/src/utils/plist.ts b/src/utils/plist.ts index 00fa08f..481fe9e 100644 --- a/src/utils/plist.ts +++ b/src/utils/plist.ts @@ -4,7 +4,7 @@ * Parses Apple plist XML format from diskutil output. */ -import { Either } from "effect"; +import { Result } from "effect"; export type PlistValue = | string @@ -18,11 +18,17 @@ export interface PlistDict { [key: string]: PlistValue; } +/** `` content: optional sign, digits with optional fraction, optional exponent. */ +const REAL_PATTERN = /^\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\s*<\/real>/; + +/** `` and ``: an empty string written as an empty element. */ +const SELF_CLOSING_STRING = /^/; + /** * Parses Apple plist XML format from diskutil output. * Supports dict, array, string, integer, real, boolean, date, and data tags. */ -export function parsePlist(xml: string): Either.Either { +export function parsePlist(xml: string): Result.Result { const content = xml .replace(/<\?xml[^?]*\?>/g, "") .replace(/]*>/g, "") @@ -30,14 +36,14 @@ export function parsePlist(xml: string): Either.Either { const plistMatch = content.match(/]*>([\s\S]*)<\/plist>/); if (!plistMatch) { - return Either.left(new Error("Invalid plist: no element found")); + return Result.fail(new Error("Invalid plist: no element found")); } const innerContent = plistMatch[1]?.trim() ?? ""; try { - return Either.right(parseElement(innerContent)); + return Result.succeed(parseElement(innerContent)); } catch (error) { - return Either.left(error instanceof Error ? error : new Error(String(error))); + return Result.fail(error instanceof Error ? error : new Error(String(error))); } } @@ -60,6 +66,10 @@ function parseElement(xml: string): PlistValue { return parseArray(trimmed); } + if (SELF_CLOSING_STRING.test(trimmed)) { + return ""; + } + const stringMatch = trimmed.match(/^([\s\S]*?)<\/string>/); if (stringMatch) { return decodeXmlEntities(stringMatch[1] ?? ""); @@ -70,7 +80,7 @@ function parseElement(xml: string): PlistValue { return Number.parseInt(intMatch[1] ?? "0", 10); } - const realMatch = trimmed.match(/^(-?[\d.]+)<\/real>/); + const realMatch = trimmed.match(REAL_PATTERN); if (realMatch) { return Number.parseFloat(realMatch[1] ?? "0"); } @@ -159,7 +169,14 @@ function parseDict(xml: string): PlistDict { const valueStart = pos; const valueResult = extractNextValue(content, valueStart); - if (!valueResult) break; + if (!valueResult) { + // One value this parser does not understand must not take the rest of + // the dict with it. Drop this key and resume at the next one. + const nextKey = content.indexOf("", valueStart); + if (nextKey === -1) break; + pos = nextKey; + continue; + } result[key] = valueResult.value; pos = valueResult.endPos; @@ -188,6 +205,11 @@ function extractNextValue( return { value: false, endPos: pos + 9 }; } + const emptyString = remaining.match(SELF_CLOSING_STRING); + if (emptyString) { + return { value: "", endPos: pos + emptyString[0].length }; + } + const stringMatch = remaining.match(/^([\s\S]*?)<\/string>/); if (stringMatch) { return { @@ -204,7 +226,7 @@ function extractNextValue( }; } - const realMatch = remaining.match(/^(-?[\d.]+)<\/real>/); + const realMatch = remaining.match(REAL_PATTERN); if (realMatch) { return { value: Number.parseFloat(realMatch[1] ?? "0"), @@ -319,15 +341,28 @@ function findClosingTag(xml: string, tagName: string): number { return xml.length; } +/** + * Decodes the five predefined XML entities and numeric character references. + * + * One pass over the input, so decoded output is never decoded again: the literal + * text "&lt;" becomes "<", not "<". Chaining replaces with & first + * gets this wrong. + */ function decodeXmlEntities(str: string): string { - return str - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'"); + return str.replace( + /&(?:#x([0-9a-fA-F]+)|#(\d+)|(amp|lt|gt|quot|apos));/g, + (match, hex, dec, named) => { + if (named) return NAMED_ENTITIES[named as keyof typeof NAMED_ENTITIES]; + const codePoint = hex ? Number.parseInt(hex, 16) : Number.parseInt(dec, 10); + // Out of range (or a lone surrogate) is not a character; leave it as written. + if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return match; + return String.fromCodePoint(codePoint); + }, + ); } +const NAMED_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" } as const; + /** * Gets a string value from a PlistDict. */