From b59e9053c55153f02a41491026ccb9b1064e3703 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sun, 27 Sep 2026 17:47:25 -0700 Subject: [PATCH 1/2] fix(build): use the system linker on macOS Homebrew lld cannot resolve libSystem primitives (bzero, memset, lchown, fchown) against the macOS 27 SDK, so every binary and test link fails with hundreds of undefined symbols. Anyone on a current macOS could not build at all, and the failure reads as a mess of missing libc symbols rather than as a linker mismatch, so it costs an afternoon to trace. Upgrading does not help: the newest published lld has the same problem. Drop the pin for both macOS targets and let Cargo fall back to Apple's ld, which works and is fast enough that the lost link time is not worth a broken build. Linux keeps mold. --- .cargo/config.toml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index cfe2acd11..0ac6b72a1 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -10,20 +10,17 @@ # Linker Configuration # ============================================================================= # Using faster linkers significantly reduces link time during development. -# - macOS: lld (install with: brew install lld) # - Linux: mold (install with: apt install mold) # # If the linker is not installed, Cargo will fall back to the default linker. +# +# macOS uses the system linker. lld is not an option here: against the macOS 27 +# SDK it fails to resolve libSystem primitives (bzero, memset, lchown, fchown), +# so every binary and test link dies with hundreds of undefined symbols. This is +# not a version lag that an upgrade clears, and Apple's ld is fast enough that +# the lost link time does not justify a broken build. # ============================================================================= -# macOS (Apple Silicon) -[target.aarch64-apple-darwin] -rustflags = ["-C", "link-arg=-fuse-ld=lld"] - -# macOS (Intel) -[target.x86_64-apple-darwin] -rustflags = ["-C", "link-arg=-fuse-ld=lld"] - # Linux (x86_64) - used in CI [target.x86_64-unknown-linux-gnu] linker = "clang" From 5e497fba2bd8a9c86b0498cf5363193fd1481c1b Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sun, 27 Sep 2026 17:47:40 -0700 Subject: [PATCH 2/2] refactor(api): camelCase the reading-progress transfer wire format The export document, the import request and the import report were the only payloads in the API spelling their fields in snake_case. The reasoning was that the document is a portable interchange file rather than something a frontend deserializes, but it is both: the export returns it as a response body, and the import nests it inside its request. That second point is what settles it, because a camelCase request wrapping a snake_case document puts two naming conventions in one JSON body with no visible boundary between them, and the frontend reads both through the same generated types. Only the structs change. The enums keep snake_case variants, which is how enum values are spelled everywhere else in the API. Nothing has consumed the format yet, so this is free now and a breaking change later. --- .../v1/dto/reading_progress_transfer.rs | 1 + .../v1/handlers/reading_progress_transfer.rs | 6 +- .../src/reading_transfer/model.rs | 38 ++-- docs/api/openapi.json | 198 +++++++++--------- .../reading-progress-transfer.md | 24 +-- tests/api/reading_progress_transfer.rs | 4 +- web/openapi.json | 198 +++++++++--------- web/src/api/readingProgressTransfer.ts | 10 +- .../ReadingProgressTransferSettings.test.tsx | 26 +-- .../ReadingProgressTransferSettings.tsx | 56 ++--- web/src/types/api.generated.ts | 126 +++++------ 11 files changed, 351 insertions(+), 336 deletions(-) diff --git a/crates/codex-api/src/routes/v1/dto/reading_progress_transfer.rs b/crates/codex-api/src/routes/v1/dto/reading_progress_transfer.rs index b7506386c..7ca26bb47 100644 --- a/crates/codex-api/src/routes/v1/dto/reading_progress_transfer.rs +++ b/crates/codex-api/src/routes/v1/dto/reading_progress_transfer.rs @@ -17,6 +17,7 @@ fn default_true() -> bool { /// Query parameters for `GET /api/v1/reading-progress/export`. #[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ExportReadingProgressQuery { /// Sessions are opt-out: they are the only source of every reading /// statistic, so leaving them out is easy to do by accident and hard to diff --git a/crates/codex-api/src/routes/v1/handlers/reading_progress_transfer.rs b/crates/codex-api/src/routes/v1/handlers/reading_progress_transfer.rs index 79e6c711e..4da111cf6 100644 --- a/crates/codex-api/src/routes/v1/handlers/reading_progress_transfer.rs +++ b/crates/codex-api/src/routes/v1/handlers/reading_progress_transfer.rs @@ -40,7 +40,7 @@ use codex_services::reading_transfer::import::{ImportError, ImportOptions}; get, path = "/api/v1/reading-progress/export", params( - ("include_sessions" = Option, Query, description = "Include the reading-session log (default: true). Sessions are the only source of every reading statistic, so this is opt-out rather than opt-in.") + ("includeSessions" = Option, Query, description = "Include the reading-session log (default: true). Sessions are the only source of every reading statistic, so this is opt-out rather than opt-in.") ), responses( (status = 200, description = "The export document", body = codex_services::reading_transfer::model::ReadingProgressExportDocument), @@ -89,8 +89,8 @@ pub async fn export_reading_progress( /// ever written against it. /// /// Matching never guesses: any step (external id, path, file name, or, under -/// `hash_mode = "match"`, hash) that finds more than one candidate reports -/// `ambiguous` and writes nothing for that series or book. `dry_run: true` +/// `hashMode = "match"`, hash) that finds more than one candidate reports +/// `ambiguous` and writes nothing for that series or book. `dryRun: true` /// returns the identical response shape without writing anything, which is /// what makes it safe to preview before committing. /// diff --git a/crates/codex-services/src/reading_transfer/model.rs b/crates/codex-services/src/reading_transfer/model.rs index 6fcc4ca52..b6b1f045b 100644 --- a/crates/codex-services/src/reading_transfer/model.rs +++ b/crates/codex-services/src/reading_transfer/model.rs @@ -9,12 +9,13 @@ //! API layer only serializes what the service already built. `codex-api` //! re-exports this module under its `dto` namespace for OpenAPI registration. //! -//! The document itself is a portable file meant to survive a library -//! reorganisation or a move to a different Codex instance, so its field names -//! are deliberately **not** camelCased like the rest of the API's DTOs: it is -//! a versioned interchange format rather than a shape a frontend deserializes, -//! and plain `snake_case` matches what anyone writing a compatible tool would -//! expect from the documented shape. +//! The document is a portable file meant to survive a library reorganisation +//! or a move to a different Codex instance, but it is an ordinary API payload +//! too: the export returns it as a response body and the import nests it in +//! its request. So the structs here are `camelCase` on the wire like every +//! other DTO, and a single import body does not carry two naming conventions +//! with an invisible boundary between them. The enums stay `snake_case`, +//! matching how enum variants are spelled everywhere else in the API. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -29,6 +30,7 @@ pub const READING_PROGRESS_VERSION: i32 = 1; /// One external identifier attached to a series (a plugin match, a ComicInfo /// value, or a manual entry). #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ExportExternalIdDto { /// `plugin:`, `comicinfo`, `epub`, or `manual`. #[schema(example = "plugin:mangabaka")] @@ -40,6 +42,7 @@ pub struct ExportExternalIdDto { /// The live resume position for one book. Retains `r2_progression`: it is the /// only place the EPUB locator survives, since sessions strip it. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ExportProgressDto { pub current_page: i32, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -56,6 +59,7 @@ pub struct ExportProgressDto { /// One finished read-through. Keeps its original id so re-importing the same /// file is a no-op rather than a duplicate. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ExportCompletionDto { pub id: Uuid, pub started_at: DateTime, @@ -66,6 +70,7 @@ pub struct ExportCompletionDto { /// absent: nothing reads a session's historical locator, and it is the only /// non-scalar column on the row. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ExportSessionDto { pub id: Uuid, pub device_id: String, @@ -95,6 +100,7 @@ pub struct ExportSessionDto { /// rather than by id: the whole point of the file is that ids on the far side /// are expected to be different. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ExportBookDto { /// Relative to the series folder, so a series move does not invalidate it. #[schema(example = "Vol 01/v01.cbz")] @@ -112,13 +118,14 @@ pub struct ExportBookDto { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub completions: Vec, /// Omitted entirely (not an empty array) when the export was taken with - /// `include_sessions=false`. + /// `includeSessions=false`. #[serde(default, skip_serializing_if = "Option::is_none")] pub sessions: Option>, } /// One series and everything the exporting user recorded against its books. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ExportSeriesDto { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub external_ids: Vec, @@ -143,6 +150,7 @@ pub struct ExportSeriesDto { /// The whole export: one user's reading state, self-describing enough to be /// matched back against a differently organised library. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ReadingProgressExportDocument { #[schema(example = "codex-reading-progress")] pub format: String, @@ -190,6 +198,7 @@ fn default_reattach_sessions() -> bool { /// `POST /api/v1/reading-progress/import` request body. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ImportReadingProgressRequest { /// Compute and report the outcome without writing anything. #[serde(default)] @@ -236,12 +245,12 @@ pub enum SeriesDisposition { pub enum BookDisposition { Matched, /// Matched only by filename stem (e.g. a `.cbr` repacked to `.cbz`). - /// Applied only when `accept_stem_matches` is set. + /// Applied only when `acceptStemMatches` is set. StemMatch, Ambiguous, Unmatched, /// A single candidate was found by path or name, but its `file_hash` - /// disagreed with the export under `hash_mode = "verify"` or `"match"`. + /// disagreed with the export under `hashMode = "verify"` or `"match"`. HashMismatch, } @@ -259,6 +268,7 @@ pub enum FieldOutcome { /// Insert/reattach/skip counts for an append-only table (completions or /// sessions) within one book. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct WriteCounts { pub inserted: u32, /// Adopted an orphaned row (`book_id IS NULL`) rather than inserting a @@ -270,6 +280,7 @@ pub struct WriteCounts { /// The outcome for one book in the import file. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ImportBookReport { pub path: String, pub file_name: String, @@ -278,7 +289,7 @@ pub struct ImportBookReport { pub matched_book_id: Option, /// Whether any writes were attempted for this book. False for every /// disposition except `matched`, and except `stem_match` when - /// `accept_stem_matches` is off. + /// `acceptStemMatches` is off. pub applied: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub progress: Option, @@ -288,6 +299,7 @@ pub struct ImportBookReport { /// The outcome for one series in the import file. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ImportSeriesReport { pub library_relative_path: String, pub name: String, @@ -309,6 +321,7 @@ pub struct ImportSeriesReport { /// Totals across every series in the file, for a one-line summary. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ImportSummary { pub series_total: u32, pub series_matched: u32, @@ -331,12 +344,13 @@ pub struct ImportSummary { /// The response for both a real import and a dry run: the shape is identical /// either way, so a client cannot tell from the response alone whether -/// anything was written. Only `dry_run` (and the DB) says that. +/// anything was written. Only `dryRun` (and the DB) says that. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ImportReadingProgressResponse { pub dry_run: bool, pub sessions_in_file: bool, - /// Informational notes about the request, e.g. `reattach_sessions` having + /// Informational notes about the request, e.g. `reattachSessions` having /// no effect because the file carries no sessions. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub notices: Vec, diff --git a/docs/api/openapi.json b/docs/api/openapi.json index d23780206..c8ab3d9d8 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -10247,7 +10247,7 @@ "operationId": "export_reading_progress", "parameters": [ { - "name": "include_sessions", + "name": "includeSessions", "in": "query", "description": "Include the reading-session log (default: true). Sessions are the only source of every reading statistic, so this is opt-out rather than opt-in.", "required": false, @@ -10290,7 +10290,7 @@ "Reading Progress Transfer" ], "summary": "Import reading progress from an export document", - "description": "Resolves the file's series and books against the current library through\nthe same access-group / sharing-tag visibility that an ordinary read\nrespects: a book the importing user cannot see resolves as `unmatched`,\nnever as a permission error that would confirm it exists, and nothing is\never written against it.\n\nMatching never guesses: any step (external id, path, file name, or, under\n`hash_mode = \"match\"`, hash) that finds more than one candidate reports\n`ambiguous` and writes nothing for that series or book. `dry_run: true`\nreturns the identical response shape without writing anything, which is\nwhat makes it safe to preview before committing.\n\nEach series is applied in its own transaction; the response's per-series\n`committed` field says which ones actually landed. `read_completions` and\n`reading_sessions` reuse their exported ids on insert, so importing the\nsame file twice leaves row counts unchanged rather than duplicating\nhistory.", + "description": "Resolves the file's series and books against the current library through\nthe same access-group / sharing-tag visibility that an ordinary read\nrespects: a book the importing user cannot see resolves as `unmatched`,\nnever as a permission error that would confirm it exists, and nothing is\never written against it.\n\nMatching never guesses: any step (external id, path, file name, or, under\n`hashMode = \"match\"`, hash) that finds more than one candidate reports\n`ambiguous` and writes nothing for that series or book. `dryRun: true`\nreturns the identical response shape without writing anything, which is\nwhat makes it safe to preview before committing.\n\nEach series is applied in its own transaction; the response's per-series\n`committed` field says which ones actually landed. `read_completions` and\n`reading_sessions` reuse their exported ids on insert, so importing the\nsame file twice leaves row counts unchanged rather than duplicating\nhistory.", "operationId": "import_reading_progress", "requestBody": { "content": { @@ -30663,7 +30663,7 @@ "description": "One book inside a series, keyed for matching by path, name, and hash\nrather than by id: the whole point of the file is that ids on the far side\nare expected to be different.", "required": [ "path", - "file_name" + "fileName" ], "properties": { "completions": { @@ -30672,15 +30672,15 @@ "$ref": "#/components/schemas/ExportCompletionDto" } }, - "file_hash": { + "fileHash": { "type": "string", "description": "Empty when the book was never analyzed; never treated as a value to\nmatch on in that case." }, - "file_name": { + "fileName": { "type": "string", "example": "v01.cbz" }, - "partial_hash": { + "partialHash": { "type": "string" }, "path": { @@ -30699,7 +30699,7 @@ "items": { "$ref": "#/components/schemas/ExportSessionDto" }, - "description": "Omitted entirely (not an empty array) when the export was taken with\n`include_sessions=false`." + "description": "Omitted entirely (not an empty array) when the export was taken with\n`includeSessions=false`." } } }, @@ -30708,11 +30708,11 @@ "description": "One finished read-through. Keeps its original id so re-importing the same\nfile is a no-op rather than a duplicate.", "required": [ "id", - "started_at", - "completed_at" + "startedAt", + "completedAt" ], "properties": { - "completed_at": { + "completedAt": { "type": "string", "format": "date-time" }, @@ -30720,7 +30720,7 @@ "type": "string", "format": "uuid" }, - "started_at": { + "startedAt": { "type": "string", "format": "date-time" } @@ -30830,44 +30830,44 @@ "type": "object", "description": "The live resume position for one book. Retains `r2_progression`: it is the\nonly place the EPUB locator survives, since sessions strip it.", "required": [ - "current_page", + "currentPage", "completed", - "started_at", - "updated_at" + "startedAt", + "updatedAt" ], "properties": { "completed": { "type": "boolean" }, - "completed_at": { + "completedAt": { "type": [ "string", "null" ], "format": "date-time" }, - "current_page": { + "currentPage": { "type": "integer", "format": "int32" }, - "progress_percentage": { + "progressPercentage": { "type": [ "number", "null" ], "format": "double" }, - "r2_progression": { + "r2Progression": { "type": [ "string", "null" ] }, - "started_at": { + "startedAt": { "type": "string", "format": "date-time" }, - "updated_at": { + "updatedAt": { "type": "string", "format": "date-time" } @@ -30877,7 +30877,7 @@ "type": "object", "description": "Query parameters for `GET /api/v1/reading-progress/export`.", "properties": { - "include_sessions": { + "includeSessions": { "type": "boolean", "description": "Sessions are opt-out: they are the only source of every reading\nstatistic, so leaving them out is easy to do by accident and hard to\nnotice until the numbers are gone." } @@ -30887,7 +30887,7 @@ "type": "object", "description": "One series and everything the exporting user recorded against its books.", "required": [ - "library_relative_path", + "libraryRelativePath", "name" ], "properties": { @@ -30897,13 +30897,13 @@ "$ref": "#/components/schemas/ExportBookDto" } }, - "external_ids": { + "externalIds": { "type": "array", "items": { "$ref": "#/components/schemas/ExportExternalIdDto" } }, - "library_relative_path": { + "libraryRelativePath": { "type": "string", "description": "The series path as stored, relative to the library root.", "example": "shonen/Naruto" @@ -30925,7 +30925,7 @@ ], "format": "int32" }, - "rating_updated_at": { + "ratingUpdatedAt": { "type": [ "string", "null" @@ -30940,40 +30940,40 @@ "description": "One row from the reading-session log. `r2_progression` is deliberately\nabsent: nothing reads a session's historical locator, and it is the only\nnon-scalar column on the row.", "required": [ "id", - "device_id", + "deviceId", "pass", "kind", - "duration_source", - "client_started_at", - "client_ended_at", - "server_recorded_at" + "durationSource", + "clientStartedAt", + "clientEndedAt", + "serverRecordedAt" ], "properties": { - "active_duration_ms": { + "activeDurationMs": { "type": [ "integer", "null" ], "format": "int64" }, - "client_ended_at": { + "clientEndedAt": { "type": "string", "format": "date-time" }, - "client_started_at": { + "clientStartedAt": { "type": "string", "format": "date-time" }, - "device_id": { + "deviceId": { "type": "string" }, - "device_name": { + "deviceName": { "type": [ "string", "null" ] }, - "duration_source": { + "durationSource": { "type": "string", "description": "`\"measured\"`, `\"inferred\"`, or `\"unknown\"`.", "example": "measured" @@ -30987,7 +30987,7 @@ "description": "`\"progress\"`, `\"completed\"`, or `\"reset\"`.", "example": "progress" }, - "pages_read": { + "pagesRead": { "type": [ "integer", "null" @@ -30998,18 +30998,18 @@ "type": "integer", "format": "int32" }, - "server_recorded_at": { + "serverRecordedAt": { "type": "string", "format": "date-time" }, - "to_page": { + "toPage": { "type": [ "integer", "null" ], "format": "int32" }, - "to_percentage": { + "toPercentage": { "type": [ "number", "null" @@ -32501,7 +32501,7 @@ "description": "The outcome for one book in the import file.", "required": [ "path", - "file_name", + "fileName", "disposition", "applied", "completions", @@ -32510,7 +32510,7 @@ "properties": { "applied": { "type": "boolean", - "description": "Whether any writes were attempted for this book. False for every\ndisposition except `matched`, and except `stem_match` when\n`accept_stem_matches` is off." + "description": "Whether any writes were attempted for this book. False for every\ndisposition except `matched`, and except `stem_match` when\n`acceptStemMatches` is off." }, "completions": { "$ref": "#/components/schemas/WriteCounts" @@ -32518,10 +32518,10 @@ "disposition": { "$ref": "#/components/schemas/BookDisposition" }, - "file_name": { + "fileName": { "type": "string" }, - "matched_book_id": { + "matchedBookId": { "type": [ "string", "null" @@ -32546,28 +32546,28 @@ "file" ], "properties": { - "accept_stem_matches": { + "acceptStemMatches": { "type": "boolean", "description": "A stem match (`v01.cbr` renamed to `v01.cbz`) is reported either way,\nbut only written when this is set: two files can share a stem, and\napplying it silently risks writing progress onto the wrong one." }, - "conflict_policy": { + "conflictPolicy": { "$ref": "#/components/schemas/ConflictPolicy" }, - "dry_run": { + "dryRun": { "type": "boolean", "description": "Compute and report the outcome without writing anything." }, "file": { "$ref": "#/components/schemas/ReadingProgressExportDocument" }, - "hash_mode": { + "hashMode": { "$ref": "#/components/schemas/HashMode" }, - "reattach_sessions": { + "reattachSessions": { "type": "boolean", "description": "When a session or completion in the file already exists as the\nimporter's own row but is not on a live book (its book was hard-deleted,\nleaving `book_id` null, or the scanner marked it deleted after the file\nmoved), move it onto the matched book instead of skipping it." }, - "source_preference": { + "sourcePreference": { "type": "array", "items": { "type": "string" @@ -32578,15 +32578,15 @@ }, "ImportReadingProgressResponse": { "type": "object", - "description": "The response for both a real import and a dry run: the shape is identical\neither way, so a client cannot tell from the response alone whether\nanything was written. Only `dry_run` (and the DB) says that.", + "description": "The response for both a real import and a dry run: the shape is identical\neither way, so a client cannot tell from the response alone whether\nanything was written. Only `dryRun` (and the DB) says that.", "required": [ - "dry_run", - "sessions_in_file", + "dryRun", + "sessionsInFile", "summary", "series" ], "properties": { - "dry_run": { + "dryRun": { "type": "boolean" }, "notices": { @@ -32594,7 +32594,7 @@ "items": { "type": "string" }, - "description": "Informational notes about the request, e.g. `reattach_sessions` having\nno effect because the file carries no sessions." + "description": "Informational notes about the request, e.g. `reattachSessions` having\nno effect because the file carries no sessions." }, "series": { "type": "array", @@ -32602,7 +32602,7 @@ "$ref": "#/components/schemas/ImportSeriesReport" } }, - "sessions_in_file": { + "sessionsInFile": { "type": "boolean" }, "summary": { @@ -32614,7 +32614,7 @@ "type": "object", "description": "The outcome for one series in the import file.", "required": [ - "library_relative_path", + "libraryRelativePath", "name", "disposition", "attempted", @@ -32645,10 +32645,10 @@ "null" ] }, - "library_relative_path": { + "libraryRelativePath": { "type": "string" }, - "matched_series_id": { + "matchedSeriesId": { "type": [ "string", "null" @@ -32667,106 +32667,106 @@ "type": "object", "description": "Totals across every series in the file, for a one-line summary.", "required": [ - "series_total", - "series_matched", - "series_ambiguous", - "series_unmatched", - "series_committed", - "books_total", - "books_matched", - "books_stem_matched", - "books_ambiguous", - "books_unmatched", - "books_hash_mismatch", - "progress_written", - "ratings_written", - "completions_inserted", - "completions_reattached", - "sessions_inserted", - "sessions_reattached" - ], - "properties": { - "books_ambiguous": { + "seriesTotal", + "seriesMatched", + "seriesAmbiguous", + "seriesUnmatched", + "seriesCommitted", + "booksTotal", + "booksMatched", + "booksStemMatched", + "booksAmbiguous", + "booksUnmatched", + "booksHashMismatch", + "progressWritten", + "ratingsWritten", + "completionsInserted", + "completionsReattached", + "sessionsInserted", + "sessionsReattached" + ], + "properties": { + "booksAmbiguous": { "type": "integer", "format": "int32", "minimum": 0 }, - "books_hash_mismatch": { + "booksHashMismatch": { "type": "integer", "format": "int32", "minimum": 0 }, - "books_matched": { + "booksMatched": { "type": "integer", "format": "int32", "minimum": 0 }, - "books_stem_matched": { + "booksStemMatched": { "type": "integer", "format": "int32", "minimum": 0 }, - "books_total": { + "booksTotal": { "type": "integer", "format": "int32", "minimum": 0 }, - "books_unmatched": { + "booksUnmatched": { "type": "integer", "format": "int32", "minimum": 0 }, - "completions_inserted": { + "completionsInserted": { "type": "integer", "format": "int32", "minimum": 0 }, - "completions_reattached": { + "completionsReattached": { "type": "integer", "format": "int32", "minimum": 0 }, - "progress_written": { + "progressWritten": { "type": "integer", "format": "int32", "minimum": 0 }, - "ratings_written": { + "ratingsWritten": { "type": "integer", "format": "int32", "minimum": 0 }, - "series_ambiguous": { + "seriesAmbiguous": { "type": "integer", "format": "int32", "minimum": 0 }, - "series_committed": { + "seriesCommitted": { "type": "integer", "format": "int32", "minimum": 0 }, - "series_matched": { + "seriesMatched": { "type": "integer", "format": "int32", "minimum": 0 }, - "series_total": { + "seriesTotal": { "type": "integer", "format": "int32", "minimum": 0 }, - "series_unmatched": { + "seriesUnmatched": { "type": "integer", "format": "int32", "minimum": 0 }, - "sessions_inserted": { + "sessionsInserted": { "type": "integer", "format": "int32", "minimum": 0 }, - "sessions_reattached": { + "sessionsReattached": { "type": "integer", "format": "int32", "minimum": 0 @@ -40020,11 +40020,11 @@ "required": [ "format", "version", - "exported_at", - "includes_sessions" + "exportedAt", + "includesSessions" ], "properties": { - "exported_at": { + "exportedAt": { "type": "string", "format": "date-time" }, @@ -40032,7 +40032,7 @@ "type": "string", "example": "codex-reading-progress" }, - "includes_sessions": { + "includesSessions": { "type": "boolean" }, "series": { diff --git a/docs/docs/backup-migration/reading-progress-transfer.md b/docs/docs/backup-migration/reading-progress-transfer.md index 361d6a655..682c312f3 100644 --- a/docs/docs/backup-migration/reading-progress-transfer.md +++ b/docs/docs/backup-migration/reading-progress-transfer.md @@ -48,7 +48,7 @@ every reader to re-mark their progress by hand. `POST /api/v1/reading-progress/import`). Run it once as a **dry run** first: it returns the identical report shape without writing anything, so you can check the match quality before committing. -4. **Apply.** Re-run with `dry_run: false` (the Settings page gates this +4. **Apply.** Re-run with `dryRun: false` (the Settings page gates this behind a successful preview). Progress, completions, sessions, and ratings land on the new books and series. @@ -63,7 +63,7 @@ Each series in the file is resolved against the current library, in order, stopping at the first step that finds anything: 1. An external id (a plugin match, ComicInfo, or a manual entry), tried in the - order given by `source_preference`. + order given by `sourcePreference`. 2. The series' path, relative to its library root. 3. The series' normalized name. @@ -73,7 +73,7 @@ its files moved away) is never a candidate at any step. Books are then resolved within that series, in order: their path relative to the series folder, their file name, and finally their filename stem (which survives a `.cbr` repacked to `.cbz`). A repack always changes the file's -hash, so the stem step never checks hashes. Under `hash_mode: verify`, a path +hash, so the stem step never checks hashes. Under `hashMode: verify`, a path or name match whose hash differs is reported as `hash_mismatch`; that includes a file a tagging tool has rewritten since the export, so use `off` if you re-tagged your collection between export and import. @@ -86,7 +86,7 @@ the first were already in the database. **Nothing is ever guessed.** If a step finds more than one candidate, that series or book is reported as `ambiguous` and nothing is written for it. A stem match is reported as `stem_match` and is only applied if you turn on -`accept_stem_matches`: two files can share a stem, so applying it silently +`acceptStemMatches`: two files can share a stem, so applying it silently risks writing progress onto the wrong one. A series or book you cannot see (denied by a sharing tag, or outside your @@ -98,22 +98,22 @@ that the content exists. | Flag | Default | Effect | |---|---|---| -| `dry_run` | `false` | Report the outcome without writing anything | -| `hash_mode` | `verify` | `off` ignores hashes; `verify` rejects a path/name match whose `file_hash` disagrees; `match` additionally uses `file_hash`/`partial_hash` to find a book when path and name both fail (rescues a bulk rename) | -| `source_preference` | `[]` | External-id sources to try, in order, before falling back to path and name | -| `conflict_policy` | `newest` | How to resolve a book/rating that already has a value on this side: `newest` (later `updated_at` wins), `furthest` (further into the book wins; a finished read always beats a partial one), `skip_existing`, or `overwrite`. A rating has no position, so `furthest` behaves like `newest` for ratings, and a file without a rating timestamp never replaces an existing rating except under `overwrite` | -| `reattach_sessions` | `true` | When a session or completion in the file already exists as your own row but is not on a live book (its book was deleted, or the scanner marked it deleted after the file moved), move it onto the matched book instead of skipping it. A no-op, reported as such, when the file carries no sessions | -| `accept_stem_matches` | `false` | Apply a book match found only by filename stem | +| `dryRun` | `false` | Report the outcome without writing anything | +| `hashMode` | `verify` | `off` ignores hashes; `verify` rejects a path/name match whose `fileHash` disagrees; `match` additionally uses `fileHash`/`partialHash` to find a book when path and name both fail (rescues a bulk rename) | +| `sourcePreference` | `[]` | External-id sources to try, in order, before falling back to path and name | +| `conflictPolicy` | `newest` | How to resolve a book/rating that already has a value on this side: `newest` (later `updatedAt` wins), `furthest` (further into the book wins; a finished read always beats a partial one), `skip_existing`, or `overwrite`. A rating has no position, so `furthest` behaves like `newest` for ratings, and a file without a rating timestamp never replaces an existing rating except under `overwrite` | +| `reattachSessions` | `true` | When a session or completion in the file already exists as your own row but is not on a live book (its book was deleted, or the scanner marked it deleted after the file moved), move it onto the matched book instead of skipping it. A no-op, reported as such, when the file carries no sessions | +| `acceptStemMatches` | `false` | Apply a book match found only by filename stem | `GET /api/v1/reading-progress/export` takes one query parameter, -`include_sessions` (default `true`). Turn it off only if you specifically want +`includeSessions` (default `true`). Turn it off only if you specifically want a smaller file: sessions are the only source of every reading statistic, so leaving them out is easy to do by accident and easy not to notice until the numbers are gone. ## The response -The response is the same shape whether or not `dry_run` is set: counts, plus a +The response is the same shape whether or not `dryRun` is set: counts, plus a per-series and per-book breakdown of what matched, what did not, and what was (or would be) written. Each series is applied in its own transaction, so a bad series does not cost every other series in the file its progress; the diff --git a/tests/api/reading_progress_transfer.rs b/tests/api/reading_progress_transfer.rs index e6639526a..c30c66690 100644 --- a/tests/api/reading_progress_transfer.rs +++ b/tests/api/reading_progress_transfer.rs @@ -301,7 +301,7 @@ async fn exercise_library_split_round_trip(db: &DatabaseConnection) { // --- Export before the split --- let app = create_test_router(state.clone()).await; let request = get_request_with_auth( - "/api/v1/reading-progress/export?include_sessions=true", + "/api/v1/reading-progress/export?includeSessions=true", &token, ); let (status, exported): (StatusCode, Option) = @@ -767,7 +767,7 @@ async fn hard_deleting_a_book_then_reimporting_reattaches_its_orphaned_history() // match back against. let app = create_test_router(state.clone()).await; let request = get_request_with_auth( - "/api/v1/reading-progress/export?include_sessions=true", + "/api/v1/reading-progress/export?includeSessions=true", &token, ); let (status, exported): (StatusCode, Option) = diff --git a/web/openapi.json b/web/openapi.json index d23780206..c8ab3d9d8 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -10247,7 +10247,7 @@ "operationId": "export_reading_progress", "parameters": [ { - "name": "include_sessions", + "name": "includeSessions", "in": "query", "description": "Include the reading-session log (default: true). Sessions are the only source of every reading statistic, so this is opt-out rather than opt-in.", "required": false, @@ -10290,7 +10290,7 @@ "Reading Progress Transfer" ], "summary": "Import reading progress from an export document", - "description": "Resolves the file's series and books against the current library through\nthe same access-group / sharing-tag visibility that an ordinary read\nrespects: a book the importing user cannot see resolves as `unmatched`,\nnever as a permission error that would confirm it exists, and nothing is\never written against it.\n\nMatching never guesses: any step (external id, path, file name, or, under\n`hash_mode = \"match\"`, hash) that finds more than one candidate reports\n`ambiguous` and writes nothing for that series or book. `dry_run: true`\nreturns the identical response shape without writing anything, which is\nwhat makes it safe to preview before committing.\n\nEach series is applied in its own transaction; the response's per-series\n`committed` field says which ones actually landed. `read_completions` and\n`reading_sessions` reuse their exported ids on insert, so importing the\nsame file twice leaves row counts unchanged rather than duplicating\nhistory.", + "description": "Resolves the file's series and books against the current library through\nthe same access-group / sharing-tag visibility that an ordinary read\nrespects: a book the importing user cannot see resolves as `unmatched`,\nnever as a permission error that would confirm it exists, and nothing is\never written against it.\n\nMatching never guesses: any step (external id, path, file name, or, under\n`hashMode = \"match\"`, hash) that finds more than one candidate reports\n`ambiguous` and writes nothing for that series or book. `dryRun: true`\nreturns the identical response shape without writing anything, which is\nwhat makes it safe to preview before committing.\n\nEach series is applied in its own transaction; the response's per-series\n`committed` field says which ones actually landed. `read_completions` and\n`reading_sessions` reuse their exported ids on insert, so importing the\nsame file twice leaves row counts unchanged rather than duplicating\nhistory.", "operationId": "import_reading_progress", "requestBody": { "content": { @@ -30663,7 +30663,7 @@ "description": "One book inside a series, keyed for matching by path, name, and hash\nrather than by id: the whole point of the file is that ids on the far side\nare expected to be different.", "required": [ "path", - "file_name" + "fileName" ], "properties": { "completions": { @@ -30672,15 +30672,15 @@ "$ref": "#/components/schemas/ExportCompletionDto" } }, - "file_hash": { + "fileHash": { "type": "string", "description": "Empty when the book was never analyzed; never treated as a value to\nmatch on in that case." }, - "file_name": { + "fileName": { "type": "string", "example": "v01.cbz" }, - "partial_hash": { + "partialHash": { "type": "string" }, "path": { @@ -30699,7 +30699,7 @@ "items": { "$ref": "#/components/schemas/ExportSessionDto" }, - "description": "Omitted entirely (not an empty array) when the export was taken with\n`include_sessions=false`." + "description": "Omitted entirely (not an empty array) when the export was taken with\n`includeSessions=false`." } } }, @@ -30708,11 +30708,11 @@ "description": "One finished read-through. Keeps its original id so re-importing the same\nfile is a no-op rather than a duplicate.", "required": [ "id", - "started_at", - "completed_at" + "startedAt", + "completedAt" ], "properties": { - "completed_at": { + "completedAt": { "type": "string", "format": "date-time" }, @@ -30720,7 +30720,7 @@ "type": "string", "format": "uuid" }, - "started_at": { + "startedAt": { "type": "string", "format": "date-time" } @@ -30830,44 +30830,44 @@ "type": "object", "description": "The live resume position for one book. Retains `r2_progression`: it is the\nonly place the EPUB locator survives, since sessions strip it.", "required": [ - "current_page", + "currentPage", "completed", - "started_at", - "updated_at" + "startedAt", + "updatedAt" ], "properties": { "completed": { "type": "boolean" }, - "completed_at": { + "completedAt": { "type": [ "string", "null" ], "format": "date-time" }, - "current_page": { + "currentPage": { "type": "integer", "format": "int32" }, - "progress_percentage": { + "progressPercentage": { "type": [ "number", "null" ], "format": "double" }, - "r2_progression": { + "r2Progression": { "type": [ "string", "null" ] }, - "started_at": { + "startedAt": { "type": "string", "format": "date-time" }, - "updated_at": { + "updatedAt": { "type": "string", "format": "date-time" } @@ -30877,7 +30877,7 @@ "type": "object", "description": "Query parameters for `GET /api/v1/reading-progress/export`.", "properties": { - "include_sessions": { + "includeSessions": { "type": "boolean", "description": "Sessions are opt-out: they are the only source of every reading\nstatistic, so leaving them out is easy to do by accident and hard to\nnotice until the numbers are gone." } @@ -30887,7 +30887,7 @@ "type": "object", "description": "One series and everything the exporting user recorded against its books.", "required": [ - "library_relative_path", + "libraryRelativePath", "name" ], "properties": { @@ -30897,13 +30897,13 @@ "$ref": "#/components/schemas/ExportBookDto" } }, - "external_ids": { + "externalIds": { "type": "array", "items": { "$ref": "#/components/schemas/ExportExternalIdDto" } }, - "library_relative_path": { + "libraryRelativePath": { "type": "string", "description": "The series path as stored, relative to the library root.", "example": "shonen/Naruto" @@ -30925,7 +30925,7 @@ ], "format": "int32" }, - "rating_updated_at": { + "ratingUpdatedAt": { "type": [ "string", "null" @@ -30940,40 +30940,40 @@ "description": "One row from the reading-session log. `r2_progression` is deliberately\nabsent: nothing reads a session's historical locator, and it is the only\nnon-scalar column on the row.", "required": [ "id", - "device_id", + "deviceId", "pass", "kind", - "duration_source", - "client_started_at", - "client_ended_at", - "server_recorded_at" + "durationSource", + "clientStartedAt", + "clientEndedAt", + "serverRecordedAt" ], "properties": { - "active_duration_ms": { + "activeDurationMs": { "type": [ "integer", "null" ], "format": "int64" }, - "client_ended_at": { + "clientEndedAt": { "type": "string", "format": "date-time" }, - "client_started_at": { + "clientStartedAt": { "type": "string", "format": "date-time" }, - "device_id": { + "deviceId": { "type": "string" }, - "device_name": { + "deviceName": { "type": [ "string", "null" ] }, - "duration_source": { + "durationSource": { "type": "string", "description": "`\"measured\"`, `\"inferred\"`, or `\"unknown\"`.", "example": "measured" @@ -30987,7 +30987,7 @@ "description": "`\"progress\"`, `\"completed\"`, or `\"reset\"`.", "example": "progress" }, - "pages_read": { + "pagesRead": { "type": [ "integer", "null" @@ -30998,18 +30998,18 @@ "type": "integer", "format": "int32" }, - "server_recorded_at": { + "serverRecordedAt": { "type": "string", "format": "date-time" }, - "to_page": { + "toPage": { "type": [ "integer", "null" ], "format": "int32" }, - "to_percentage": { + "toPercentage": { "type": [ "number", "null" @@ -32501,7 +32501,7 @@ "description": "The outcome for one book in the import file.", "required": [ "path", - "file_name", + "fileName", "disposition", "applied", "completions", @@ -32510,7 +32510,7 @@ "properties": { "applied": { "type": "boolean", - "description": "Whether any writes were attempted for this book. False for every\ndisposition except `matched`, and except `stem_match` when\n`accept_stem_matches` is off." + "description": "Whether any writes were attempted for this book. False for every\ndisposition except `matched`, and except `stem_match` when\n`acceptStemMatches` is off." }, "completions": { "$ref": "#/components/schemas/WriteCounts" @@ -32518,10 +32518,10 @@ "disposition": { "$ref": "#/components/schemas/BookDisposition" }, - "file_name": { + "fileName": { "type": "string" }, - "matched_book_id": { + "matchedBookId": { "type": [ "string", "null" @@ -32546,28 +32546,28 @@ "file" ], "properties": { - "accept_stem_matches": { + "acceptStemMatches": { "type": "boolean", "description": "A stem match (`v01.cbr` renamed to `v01.cbz`) is reported either way,\nbut only written when this is set: two files can share a stem, and\napplying it silently risks writing progress onto the wrong one." }, - "conflict_policy": { + "conflictPolicy": { "$ref": "#/components/schemas/ConflictPolicy" }, - "dry_run": { + "dryRun": { "type": "boolean", "description": "Compute and report the outcome without writing anything." }, "file": { "$ref": "#/components/schemas/ReadingProgressExportDocument" }, - "hash_mode": { + "hashMode": { "$ref": "#/components/schemas/HashMode" }, - "reattach_sessions": { + "reattachSessions": { "type": "boolean", "description": "When a session or completion in the file already exists as the\nimporter's own row but is not on a live book (its book was hard-deleted,\nleaving `book_id` null, or the scanner marked it deleted after the file\nmoved), move it onto the matched book instead of skipping it." }, - "source_preference": { + "sourcePreference": { "type": "array", "items": { "type": "string" @@ -32578,15 +32578,15 @@ }, "ImportReadingProgressResponse": { "type": "object", - "description": "The response for both a real import and a dry run: the shape is identical\neither way, so a client cannot tell from the response alone whether\nanything was written. Only `dry_run` (and the DB) says that.", + "description": "The response for both a real import and a dry run: the shape is identical\neither way, so a client cannot tell from the response alone whether\nanything was written. Only `dryRun` (and the DB) says that.", "required": [ - "dry_run", - "sessions_in_file", + "dryRun", + "sessionsInFile", "summary", "series" ], "properties": { - "dry_run": { + "dryRun": { "type": "boolean" }, "notices": { @@ -32594,7 +32594,7 @@ "items": { "type": "string" }, - "description": "Informational notes about the request, e.g. `reattach_sessions` having\nno effect because the file carries no sessions." + "description": "Informational notes about the request, e.g. `reattachSessions` having\nno effect because the file carries no sessions." }, "series": { "type": "array", @@ -32602,7 +32602,7 @@ "$ref": "#/components/schemas/ImportSeriesReport" } }, - "sessions_in_file": { + "sessionsInFile": { "type": "boolean" }, "summary": { @@ -32614,7 +32614,7 @@ "type": "object", "description": "The outcome for one series in the import file.", "required": [ - "library_relative_path", + "libraryRelativePath", "name", "disposition", "attempted", @@ -32645,10 +32645,10 @@ "null" ] }, - "library_relative_path": { + "libraryRelativePath": { "type": "string" }, - "matched_series_id": { + "matchedSeriesId": { "type": [ "string", "null" @@ -32667,106 +32667,106 @@ "type": "object", "description": "Totals across every series in the file, for a one-line summary.", "required": [ - "series_total", - "series_matched", - "series_ambiguous", - "series_unmatched", - "series_committed", - "books_total", - "books_matched", - "books_stem_matched", - "books_ambiguous", - "books_unmatched", - "books_hash_mismatch", - "progress_written", - "ratings_written", - "completions_inserted", - "completions_reattached", - "sessions_inserted", - "sessions_reattached" - ], - "properties": { - "books_ambiguous": { + "seriesTotal", + "seriesMatched", + "seriesAmbiguous", + "seriesUnmatched", + "seriesCommitted", + "booksTotal", + "booksMatched", + "booksStemMatched", + "booksAmbiguous", + "booksUnmatched", + "booksHashMismatch", + "progressWritten", + "ratingsWritten", + "completionsInserted", + "completionsReattached", + "sessionsInserted", + "sessionsReattached" + ], + "properties": { + "booksAmbiguous": { "type": "integer", "format": "int32", "minimum": 0 }, - "books_hash_mismatch": { + "booksHashMismatch": { "type": "integer", "format": "int32", "minimum": 0 }, - "books_matched": { + "booksMatched": { "type": "integer", "format": "int32", "minimum": 0 }, - "books_stem_matched": { + "booksStemMatched": { "type": "integer", "format": "int32", "minimum": 0 }, - "books_total": { + "booksTotal": { "type": "integer", "format": "int32", "minimum": 0 }, - "books_unmatched": { + "booksUnmatched": { "type": "integer", "format": "int32", "minimum": 0 }, - "completions_inserted": { + "completionsInserted": { "type": "integer", "format": "int32", "minimum": 0 }, - "completions_reattached": { + "completionsReattached": { "type": "integer", "format": "int32", "minimum": 0 }, - "progress_written": { + "progressWritten": { "type": "integer", "format": "int32", "minimum": 0 }, - "ratings_written": { + "ratingsWritten": { "type": "integer", "format": "int32", "minimum": 0 }, - "series_ambiguous": { + "seriesAmbiguous": { "type": "integer", "format": "int32", "minimum": 0 }, - "series_committed": { + "seriesCommitted": { "type": "integer", "format": "int32", "minimum": 0 }, - "series_matched": { + "seriesMatched": { "type": "integer", "format": "int32", "minimum": 0 }, - "series_total": { + "seriesTotal": { "type": "integer", "format": "int32", "minimum": 0 }, - "series_unmatched": { + "seriesUnmatched": { "type": "integer", "format": "int32", "minimum": 0 }, - "sessions_inserted": { + "sessionsInserted": { "type": "integer", "format": "int32", "minimum": 0 }, - "sessions_reattached": { + "sessionsReattached": { "type": "integer", "format": "int32", "minimum": 0 @@ -40020,11 +40020,11 @@ "required": [ "format", "version", - "exported_at", - "includes_sessions" + "exportedAt", + "includesSessions" ], "properties": { - "exported_at": { + "exportedAt": { "type": "string", "format": "date-time" }, @@ -40032,7 +40032,7 @@ "type": "string", "example": "codex-reading-progress" }, - "includes_sessions": { + "includesSessions": { "type": "boolean" }, "series": { diff --git a/web/src/api/readingProgressTransfer.ts b/web/src/api/readingProgressTransfer.ts index f475dba58..6c4ffddc4 100644 --- a/web/src/api/readingProgressTransfer.ts +++ b/web/src/api/readingProgressTransfer.ts @@ -1,10 +1,10 @@ import type { components } from "@/types/api.generated"; import { api } from "./client"; -// Re-export generated types for convenience. The document, request, and -// report shapes intentionally use snake_case (see the backend's DTO module -// doc comment): this is a portable file format, not an ordinary camelCase -// API response, so the field names here are passed straight through. +// Re-export generated types for convenience. The document is both a portable +// file and an ordinary API payload: the export returns it as a response body +// and the import nests it inside its request, so it uses the same camelCase +// field names as every other endpoint rather than a second convention. export type ReadingProgressExportDocument = components["schemas"]["ReadingProgressExportDocument"]; export type ImportReadingProgressRequest = @@ -28,7 +28,7 @@ export const readingProgressTransferApi = { ): Promise => { const response = await api.get( "/reading-progress/export", - { params: { include_sessions: includeSessions } }, + { params: { includeSessions } }, ); return response.data; }, diff --git a/web/src/pages/settings/ReadingProgressTransferSettings.test.tsx b/web/src/pages/settings/ReadingProgressTransferSettings.test.tsx index 8dd89ff26..35f6a7773 100644 --- a/web/src/pages/settings/ReadingProgressTransferSettings.test.tsx +++ b/web/src/pages/settings/ReadingProgressTransferSettings.test.tsx @@ -25,18 +25,18 @@ vi.mock("@/api/readingProgressTransfer", async () => { const exportDocument: ReadingProgressExportDocument = { format: "codex-reading-progress", version: 1, - exported_at: "2026-09-27T00:00:00Z", - includes_sessions: true, + exportedAt: "2026-09-27T00:00:00Z", + includesSessions: true, series: [ { - external_ids: [], - library_relative_path: "Naruto", + externalIds: [], + libraryRelativePath: "Naruto", name: "Naruto", books: [ { path: "v01.cbz", - file_name: "v01.cbz", - file_hash: "", + fileName: "v01.cbz", + fileHash: "", partial_hash: "", completions: [], }, @@ -47,7 +47,7 @@ const exportDocument: ReadingProgressExportDocument = { function dryRunResponse(): ImportReadingProgressResponse { return { - dry_run: true, + dryRun: true, sessions_in_file: true, notices: [], summary: { @@ -71,7 +71,7 @@ function dryRunResponse(): ImportReadingProgressResponse { }, series: [ { - library_relative_path: "Naruto", + libraryRelativePath: "Naruto", name: "Naruto", disposition: "matched", matched_series_id: "11111111-1111-1111-1111-111111111111", @@ -80,7 +80,7 @@ function dryRunResponse(): ImportReadingProgressResponse { books: [ { path: "v01.cbz", - file_name: "v01.cbz", + fileName: "v01.cbz", disposition: "matched", matched_book_id: "22222222-2222-2222-2222-222222222222", applied: true, @@ -155,7 +155,7 @@ describe("ReadingProgressTransferSettings", () => { expect(applyButton).toBeEnabled(); }); expect(importProgress).toHaveBeenCalledWith( - expect.objectContaining({ dry_run: true }), + expect.objectContaining({ dryRun: true }), ); }); @@ -196,7 +196,7 @@ describe("ReadingProgressTransferSettings", () => { expect(screen.getAllByText("matched").length).toBeGreaterThan(0); }); - it("calls import with dry_run: false when Apply is pressed", async () => { + it("calls import with dryRun: false when Apply is pressed", async () => { importProgress.mockResolvedValue(dryRunResponse()); const user = userEvent.setup(); renderWithProviders(); @@ -213,7 +213,7 @@ describe("ReadingProgressTransferSettings", () => { importProgress.mockResolvedValue({ ...dryRunResponse(), - dry_run: false, + dryRun: false, series: [ { ...dryRunResponse().series[0], @@ -225,7 +225,7 @@ describe("ReadingProgressTransferSettings", () => { await waitFor(() => { expect(importProgress).toHaveBeenLastCalledWith( - expect.objectContaining({ dry_run: false }), + expect.objectContaining({ dryRun: false }), ); }); }); diff --git a/web/src/pages/settings/ReadingProgressTransferSettings.tsx b/web/src/pages/settings/ReadingProgressTransferSettings.tsx index e04d836a4..e3c94f769 100644 --- a/web/src/pages/settings/ReadingProgressTransferSettings.tsx +++ b/web/src/pages/settings/ReadingProgressTransferSettings.tsx @@ -99,29 +99,29 @@ function SummaryLine({ report }: { report: ImportReadingProgressResponse }) { return ( - Series: {summary.series_matched} matched,{" "} - {summary.series_ambiguous} ambiguous,{" "} - {summary.series_unmatched} unmatched - {!report.dry_run && ( + Series: {summary.seriesMatched} matched,{" "} + {summary.seriesAmbiguous} ambiguous,{" "} + {summary.seriesUnmatched} unmatched + {!report.dryRun && ( <> - , {summary.series_committed} committed + , {summary.seriesCommitted} committed )} - Books: {summary.books_matched} matched,{" "} - {summary.books_stem_matched} stem match,{" "} - {summary.books_ambiguous} ambiguous,{" "} - {summary.books_unmatched} unmatched,{" "} - {summary.books_hash_mismatch} hash mismatch + Books: {summary.booksMatched} matched,{" "} + {summary.booksStemMatched} stem match,{" "} + {summary.booksAmbiguous} ambiguous,{" "} + {summary.booksUnmatched} unmatched,{" "} + {summary.booksHashMismatch} hash mismatch - Progress written: {summary.progress_written} · - Completions: {summary.completions_inserted} inserted /{" "} - {summary.completions_reattached} reattached · Sessions:{" "} - {summary.sessions_inserted} inserted /{" "} - {summary.sessions_reattached} reattached · Ratings:{" "} - {summary.ratings_written} + Progress written: {summary.progressWritten} · Completions:{" "} + {summary.completionsInserted} inserted /{" "} + {summary.completionsReattached} reattached · Sessions:{" "} + {summary.sessionsInserted} inserted /{" "} + {summary.sessionsReattached} reattached · Ratings:{" "} + {summary.ratingsWritten} ); @@ -144,14 +144,14 @@ function ReportTable({ report }: { report: ImportReadingProgressResponse }) { // A split exports same-named series from several libraries, so // path and name alone are not unique. {series.name} - {series.library_relative_path} + {series.libraryRelativePath} {series.error && ( @@ -186,13 +186,13 @@ function ReportTable({ report }: { report: ImportReadingProgressResponse }) { {series.books.map((book) => ( - {book.file_name} + {book.fileName} ))} @@ -262,12 +262,12 @@ export function ReadingProgressTransferSettings() { setImportError(null); importMutation.mutate( { - dry_run: dryRun, - hash_mode: hashMode, - source_preference: [], - conflict_policy: conflictPolicy, - reattach_sessions: reattachSessions, - accept_stem_matches: acceptStemMatches, + dryRun, + hashMode, + sourcePreference: [], + conflictPolicy, + reattachSessions, + acceptStemMatches, file: parsedDocument, }, { @@ -455,13 +455,13 @@ export function ReadingProgressTransferSettings() { - {!report.sessions_in_file && ( + {!report.sessionsInFile && ( This file does not include sessions. diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index 6f6d83db9..2b3a68a2a 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -3322,8 +3322,8 @@ export interface paths { * ever written against it. * * Matching never guesses: any step (external id, path, file name, or, under - * `hash_mode = "match"`, hash) that finds more than one candidate reports - * `ambiguous` and writes nothing for that series or book. `dry_run: true` + * `hashMode = "match"`, hash) that finds more than one candidate reports + * `ambiguous` and writes nothing for that series or book. `dryRun: true` * returns the identical response shape without writing anything, which is * what makes it safe to preview before committing. * @@ -12295,10 +12295,10 @@ export interface components { * @description Empty when the book was never analyzed; never treated as a value to * match on in that case. */ - file_hash?: string; + fileHash?: string; /** @example v01.cbz */ - file_name: string; - partial_hash?: string; + fileName: string; + partialHash?: string; /** * @description Relative to the series folder, so a series move does not invalidate it. * @example Vol 01/v01.cbz @@ -12307,7 +12307,7 @@ export interface components { progress?: components["schemas"]["ExportProgressDto"]; /** * @description Omitted entirely (not an empty array) when the export was taken with - * `include_sessions=false`. + * `includeSessions=false`. */ sessions?: components["schemas"]["ExportSessionDto"][] | null; }; @@ -12317,11 +12317,11 @@ export interface components { */ ExportCompletionDto: { /** Format: date-time */ - completed_at: string; + completedAt: string; /** Format: uuid */ id: string; /** Format: date-time */ - started_at: string; + startedAt: string; }; /** * @description One external identifier attached to a series (a plugin match, a ComicInfo @@ -12367,16 +12367,16 @@ export interface components { ExportProgressDto: { completed: boolean; /** Format: date-time */ - completed_at?: string | null; + completedAt?: string | null; /** Format: int32 */ - current_page: number; + currentPage: number; /** Format: double */ - progress_percentage?: number | null; - r2_progression?: string | null; + progressPercentage?: number | null; + r2Progression?: string | null; /** Format: date-time */ - started_at: string; + startedAt: string; /** Format: date-time */ - updated_at: string; + updatedAt: string; }; /** @description Query parameters for `GET /api/v1/reading-progress/export`. */ ExportReadingProgressQuery: { @@ -12385,17 +12385,17 @@ export interface components { * statistic, so leaving them out is easy to do by accident and hard to * notice until the numbers are gone. */ - include_sessions?: boolean; + includeSessions?: boolean; }; /** @description One series and everything the exporting user recorded against its books. */ ExportSeriesDto: { books?: components["schemas"]["ExportBookDto"][]; - external_ids?: components["schemas"]["ExportExternalIdDto"][]; + externalIds?: components["schemas"]["ExportExternalIdDto"][]; /** * @description The series path as stored, relative to the library root. * @example shonen/Naruto */ - library_relative_path: string; + libraryRelativePath: string; /** @example Naruto */ name: string; notes?: string | null; @@ -12407,7 +12407,7 @@ export interface components { * to tell a stale rating from a fresh one; a file without it never * overwrites an existing rating except under `overwrite`. */ - rating_updated_at?: string | null; + ratingUpdatedAt?: string | null; }; /** * @description One row from the reading-session log. `r2_progression` is deliberately @@ -12416,18 +12416,18 @@ export interface components { */ ExportSessionDto: { /** Format: int64 */ - active_duration_ms?: number | null; + activeDurationMs?: number | null; /** Format: date-time */ - client_ended_at: string; + clientEndedAt: string; /** Format: date-time */ - client_started_at: string; - device_id: string; - device_name?: string | null; + clientStartedAt: string; + deviceId: string; + deviceName?: string | null; /** * @description `"measured"`, `"inferred"`, or `"unknown"`. * @example measured */ - duration_source: string; + durationSource: string; /** Format: uuid */ id: string; /** @@ -12436,15 +12436,15 @@ export interface components { */ kind: string; /** Format: int32 */ - pages_read?: number | null; + pagesRead?: number | null; /** Format: int32 */ pass: number; /** Format: date-time */ - server_recorded_at: string; + serverRecordedAt: string; /** Format: int32 */ - to_page?: number | null; + toPage?: number | null; /** Format: double */ - to_percentage?: number | null; + toPercentage?: number | null; }; /** * @description External ID context for template evaluation. @@ -13275,14 +13275,14 @@ export interface components { /** * @description Whether any writes were attempted for this book. False for every * disposition except `matched`, and except `stem_match` when - * `accept_stem_matches` is off. + * `acceptStemMatches` is off. */ applied: boolean; completions: components["schemas"]["WriteCounts"]; disposition: components["schemas"]["BookDisposition"]; - file_name: string; + fileName: string; /** Format: uuid */ - matched_book_id?: string | null; + matchedBookId?: string | null; path: string; progress?: components["schemas"]["FieldOutcome"]; sessions: components["schemas"]["WriteCounts"]; @@ -13294,39 +13294,39 @@ export interface components { * but only written when this is set: two files can share a stem, and * applying it silently risks writing progress onto the wrong one. */ - accept_stem_matches?: boolean; - conflict_policy?: components["schemas"]["ConflictPolicy"]; + acceptStemMatches?: boolean; + conflictPolicy?: components["schemas"]["ConflictPolicy"]; /** @description Compute and report the outcome without writing anything. */ - dry_run?: boolean; + dryRun?: boolean; file: components["schemas"]["ReadingProgressExportDocument"]; - hash_mode?: components["schemas"]["HashMode"]; + hashMode?: components["schemas"]["HashMode"]; /** * @description When a session or completion in the file already exists as the * importer's own row but is not on a live book (its book was hard-deleted, * leaving `book_id` null, or the scanner marked it deleted after the file * moved), move it onto the matched book instead of skipping it. */ - reattach_sessions?: boolean; + reattachSessions?: boolean; /** * @description External-id sources to try, in order, before falling back to path and * then normalized name. An empty list skips straight to path matching. */ - source_preference?: string[]; + sourcePreference?: string[]; }; /** * @description The response for both a real import and a dry run: the shape is identical * either way, so a client cannot tell from the response alone whether - * anything was written. Only `dry_run` (and the DB) says that. + * anything was written. Only `dryRun` (and the DB) says that. */ ImportReadingProgressResponse: { - dry_run: boolean; + dryRun: boolean; /** - * @description Informational notes about the request, e.g. `reattach_sessions` having + * @description Informational notes about the request, e.g. `reattachSessions` having * no effect because the file carries no sessions. */ notices?: string[]; series: components["schemas"]["ImportSeriesReport"][]; - sessions_in_file: boolean; + sessionsInFile: boolean; summary: components["schemas"]["ImportSummary"]; }; /** @description The outcome for one series in the import file. */ @@ -13344,48 +13344,48 @@ export interface components { committed: boolean; disposition: components["schemas"]["SeriesDisposition"]; error?: string | null; - library_relative_path: string; + libraryRelativePath: string; /** Format: uuid */ - matched_series_id?: string | null; + matchedSeriesId?: string | null; name: string; rating?: components["schemas"]["FieldOutcome"]; }; /** @description Totals across every series in the file, for a one-line summary. */ ImportSummary: { /** Format: int32 */ - books_ambiguous: number; + booksAmbiguous: number; /** Format: int32 */ - books_hash_mismatch: number; + booksHashMismatch: number; /** Format: int32 */ - books_matched: number; + booksMatched: number; /** Format: int32 */ - books_stem_matched: number; + booksStemMatched: number; /** Format: int32 */ - books_total: number; + booksTotal: number; /** Format: int32 */ - books_unmatched: number; + booksUnmatched: number; /** Format: int32 */ - completions_inserted: number; + completionsInserted: number; /** Format: int32 */ - completions_reattached: number; + completionsReattached: number; /** Format: int32 */ - progress_written: number; + progressWritten: number; /** Format: int32 */ - ratings_written: number; + ratingsWritten: number; /** Format: int32 */ - series_ambiguous: number; + seriesAmbiguous: number; /** Format: int32 */ - series_committed: number; + seriesCommitted: number; /** Format: int32 */ - series_matched: number; + seriesMatched: number; /** Format: int32 */ - series_total: number; + seriesTotal: number; /** Format: int32 */ - series_unmatched: number; + seriesUnmatched: number; /** Format: int32 */ - sessions_inserted: number; + sessionsInserted: number; /** Format: int32 */ - sessions_reattached: number; + sessionsReattached: number; }; /** * @description Which layer supplied a value the user is inheriting. @@ -17440,10 +17440,10 @@ export interface components { */ ReadingProgressExportDocument: { /** Format: date-time */ - exported_at: string; + exportedAt: string; /** @example codex-reading-progress */ format: string; - includes_sessions: boolean; + includesSessions: boolean; series?: components["schemas"]["ExportSeriesDto"][]; /** * Format: int32 @@ -29776,7 +29776,7 @@ export interface operations { parameters: { query?: { /** @description Include the reading-session log (default: true). Sessions are the only source of every reading statistic, so this is opt-out rather than opt-in. */ - include_sessions?: boolean; + includeSessions?: boolean; }; header?: never; path?: never;