From e7f9a2ce88d1e70675c4b9babe085ec46ec10c56 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 14:11:33 -0700 Subject: [PATCH 01/27] feat(plugins): add data layer for scheduled user-plugin sync Lay the groundwork for admin-scheduled, per-user-opt-in plugin syncs: an admin sets a cron cadence per plugin, and each user chooses whether their connection syncs automatically on that cadence. - Add nullable `plugins.sync_cron_schedule` column (migration + entity). NULL means no scheduled sync, so existing installs are unchanged. - Add `PluginsRepository::list_sync_scheduled()` returning enabled plugins with a cron set, for the scheduler to load at boot/reload. - Add `user_plugins::Model::auto_sync_enabled()` reading the host-side `config._codex.autoSync` opt-in (default false, manual-only). The `_codex` namespace constant now lives in codex-db and is re-used by codex-tasks so the parser and accessor share one source. No behavior change yet: the column is inert until the scheduler and API surface are wired up. Includes unit tests for the repo query and the auto-sync accessor. --- crates/codex-db/src/entities/plugins.rs | 16 ++++ crates/codex-db/src/entities/user_plugins.rs | 64 +++++++++++++ crates/codex-db/src/repositories/plugins.rs | 89 +++++++++++++++++++ crates/codex-services/src/plugin/manager.rs | 4 + .../src/handlers/user_plugin_sync/settings.rs | 8 +- migration/src/lib.rs | 4 + .../m20260606_000092_add_plugin_sync_cron.rs | 51 +++++++++++ tests/services/apply_dry_run.rs | 1 + tests/services/book_metadata_apply.rs | 1 + tests/services/metadata_apply.rs | 2 + 10 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 migration/src/m20260606_000092_add_plugin_sync_cron.rs diff --git a/crates/codex-db/src/entities/plugins.rs b/crates/codex-db/src/entities/plugins.rs index 5420098e..b93aafba 100644 --- a/crates/codex-db/src/entities/plugins.rs +++ b/crates/codex-db/src/entities/plugins.rs @@ -129,6 +129,17 @@ pub struct Model { #[sea_orm(column_type = "Text")] pub internal_config: Option, + /// Admin-managed cron schedule for automatic user-plugin syncs. + /// + /// - `None`: no scheduled sync (default). + /// - `Some(cron)`: a normalized cron expression. When the schedule fires, + /// the scheduler enqueues a `UserPluginSync` task for every connected + /// user whose connection is enabled, authenticated, and opted into auto + /// sync (`config._codex.autoSync`). Only meaningful for plugins whose + /// manifest declares the `user_read_sync` capability. + #[sea_orm(column_type = "Text")] + pub sync_cron_schedule: Option, + // Timestamps pub created_at: DateTime, pub updated_at: DateTime, @@ -1030,6 +1041,7 @@ mod tests { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, @@ -1211,6 +1223,7 @@ mod tests { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, @@ -1261,6 +1274,7 @@ mod tests { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, @@ -1322,6 +1336,7 @@ mod tests { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, @@ -1366,6 +1381,7 @@ mod tests { use_existing_external_id: true, metadata_targets: None, internal_config: Some("not valid json".to_string()), + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, diff --git a/crates/codex-db/src/entities/user_plugins.rs b/crates/codex-db/src/entities/user_plugins.rs index 6f35da0c..e2ae84db 100644 --- a/crates/codex-db/src/entities/user_plugins.rs +++ b/crates/codex-db/src/entities/user_plugins.rs @@ -27,6 +27,21 @@ use uuid::Uuid; use super::plugins::PluginHealthStatus; +/// JSON key for the Codex-reserved namespace inside `user_plugins.config`. +/// +/// The `config` object may contain a `_codex` key whose value holds +/// server-interpreted preferences (sync filtering, `autoSync`, …). The plugin +/// itself never reads this namespace — it only controls host-side behavior. +/// This is the single source of the namespace name; `codex-tasks` re-uses it. +pub const CODEX_CONFIG_NAMESPACE: &str = "_codex"; + +/// JSON key (inside `_codex`) for the per-user auto-sync opt-in. +/// +/// `config._codex.autoSync = true` opts a connection into scheduled syncs that +/// run on the plugin's admin-configured cron. Absent/false means manual-only +/// (the default). Distinct from `syncMode`, which selects sync *direction*. +pub const AUTO_SYNC_KEY: &str = "autoSync"; + #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "user_plugins")] pub struct Model { @@ -174,6 +189,21 @@ impl Model { self.has_oauth_tokens() || self.has_credentials() } + /// Whether this connection has opted into scheduled (auto) syncs. + /// + /// Reads `config._codex.autoSync` (host-side only; never sent to the + /// plugin). Returns `false` when the key is missing or not a `true` + /// boolean, so connections are manual-only by default. The scheduler + /// combines this with `enabled` and [`Self::is_authenticated`] to decide + /// whether to enqueue a sync when the plugin's cron fires. + pub fn auto_sync_enabled(&self) -> bool { + self.config + .get(CODEX_CONFIG_NAMESPACE) + .and_then(|codex| codex.get(AUTO_SYNC_KEY)) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } + /// Parse health status pub fn health_status_type(&self) -> PluginHealthStatus { self.health_status @@ -322,4 +352,38 @@ mod tests { model.enabled = false; assert!(!model.is_healthy()); } + + #[test] + fn test_auto_sync_enabled_defaults_false() { + // Empty config, missing namespace, and missing key all read as false. + let model = test_model(); + assert!(!model.auto_sync_enabled()); + + let mut model = test_model(); + model.config = serde_json::json!({ "syncMode": "both" }); + assert!(!model.auto_sync_enabled()); + + let mut model = test_model(); + model.config = serde_json::json!({ "_codex": { "includeCompleted": true } }); + assert!(!model.auto_sync_enabled()); + } + + #[test] + fn test_auto_sync_enabled_true() { + let mut model = test_model(); + model.config = serde_json::json!({ "_codex": { "autoSync": true } }); + assert!(model.auto_sync_enabled()); + } + + #[test] + fn test_auto_sync_enabled_non_bool_is_false() { + // A non-boolean value must not be coerced into opting in. + let mut model = test_model(); + model.config = serde_json::json!({ "_codex": { "autoSync": "true" } }); + assert!(!model.auto_sync_enabled()); + + let mut model = test_model(); + model.config = serde_json::json!({ "_codex": { "autoSync": false } }); + assert!(!model.auto_sync_enabled()); + } } diff --git a/crates/codex-db/src/repositories/plugins.rs b/crates/codex-db/src/repositories/plugins.rs index e7247561..1a0821f0 100644 --- a/crates/codex-db/src/repositories/plugins.rs +++ b/crates/codex-db/src/repositories/plugins.rs @@ -82,6 +82,23 @@ impl PluginsRepository { Ok(plugins) } + /// Get all enabled plugins that have an admin-configured sync cron schedule. + /// + /// Returns enabled plugins where `sync_cron_schedule IS NOT NULL`. The + /// scheduler loads these at boot (and on reload) to register one cron per + /// plugin; capability gating (`user_read_sync`) is applied by the caller + /// from the cached manifest. The row count is small (admin-managed), so + /// this loads the whole set without pagination. + pub async fn list_sync_scheduled(db: &DatabaseConnection) -> Result> { + let plugins = Plugins::find() + .filter(plugins::Column::Enabled.eq(true)) + .filter(plugins::Column::SyncCronSchedule.is_not_null()) + .order_by_asc(plugins::Column::Name) + .all(db) + .await?; + Ok(plugins) + } + /// Get all enabled plugins with pagination /// /// Returns a tuple of (plugins, total_count) for building paginated responses. @@ -313,6 +330,7 @@ impl PluginsRepository { use_existing_external_id: Set(true), metadata_targets: Set(None), internal_config: Set(None), + sync_cron_schedule: Set(None), created_at: Set(now), updated_at: Set(now), created_by: Set(created_by), @@ -1844,4 +1862,75 @@ mod tests { assert_eq!(total, 3); assert!(plugins.is_empty()); } + + /// Set `sync_cron_schedule` on an existing plugin row (Phase 3 will add a + /// dedicated repo setter; this keeps the Phase 1 test self-contained). + async fn set_sync_cron(db: &DatabaseConnection, id: Uuid, cron: Option<&str>) { + let model = PluginsRepository::get_by_id(db, id).await.unwrap().unwrap(); + let mut active: plugins::ActiveModel = model.into(); + active.sync_cron_schedule = Set(cron.map(|s| s.to_string())); + active.update(db).await.unwrap(); + } + + async fn create_plain_plugin(db: &DatabaseConnection, name: &str, enabled: bool) -> Uuid { + PluginsRepository::create( + db, + name, + name, + None, + "user", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], + None, + "env", + None, + enabled, + None, + Some(60), + None, + ) + .await + .unwrap() + .id + } + + #[tokio::test] + async fn test_list_sync_scheduled_filters_enabled_and_non_null() { + setup_test_encryption_key(); + let db = setup_test_db().await; + + // Enabled + cron set -> included. + let with_cron = create_plain_plugin(&db, "with_cron", true).await; + set_sync_cron(&db, with_cron, Some("0 0 * * * *")).await; + + // Enabled but no cron -> excluded. + create_plain_plugin(&db, "no_cron", true).await; + + // Cron set but disabled -> excluded. + let disabled = create_plain_plugin(&db, "disabled_with_cron", false).await; + set_sync_cron(&db, disabled, Some("0 0 * * * *")).await; + + let scheduled = PluginsRepository::list_sync_scheduled(&db).await.unwrap(); + assert_eq!(scheduled.len(), 1); + assert_eq!(scheduled[0].id, with_cron); + assert_eq!( + scheduled[0].sync_cron_schedule.as_deref(), + Some("0 0 * * * *") + ); + } + + #[tokio::test] + async fn test_list_sync_scheduled_empty_when_none_configured() { + setup_test_encryption_key(); + let db = setup_test_db().await; + create_plain_plugin(&db, "no_cron", true).await; + + let scheduled = PluginsRepository::list_sync_scheduled(&db).await.unwrap(); + assert!(scheduled.is_empty()); + } } diff --git a/crates/codex-services/src/plugin/manager.rs b/crates/codex-services/src/plugin/manager.rs index 9171392f..40d5822a 100644 --- a/crates/codex-services/src/plugin/manager.rs +++ b/crates/codex-services/src/plugin/manager.rs @@ -2089,6 +2089,7 @@ mod tests { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, @@ -2138,6 +2139,7 @@ mod tests { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, @@ -2187,6 +2189,7 @@ mod tests { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, @@ -2238,6 +2241,7 @@ mod tests { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/settings.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/settings.rs index 8a1abc83..5cad01f2 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/settings.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/settings.rs @@ -4,9 +4,11 @@ /// JSON key for the Codex-reserved namespace in user plugin config. /// /// User plugin config objects may contain a `_codex` key whose value holds -/// server-interpreted preferences (e.g. `includeCompleted`, `syncRatings`). -/// The plugin itself never reads this namespace — it controls server behavior. -pub(crate) const CODEX_CONFIG_NAMESPACE: &str = "_codex"; +/// server-interpreted preferences (e.g. `includeCompleted`, `syncRatings`, +/// `autoSync`). The plugin itself never reads this namespace — it controls +/// server behavior. Defined once in `codex-db` so the entity accessor +/// (`user_plugins::Model::auto_sync_enabled`) and this parser stay in sync. +pub(crate) use codex_db::entities::user_plugins::CODEX_CONFIG_NAMESPACE; /// Codex generic sync settings — server-interpreted preferences that control /// which entries to build and send to the plugin. Stored in the user plugin diff --git a/migration/src/lib.rs b/migration/src/lib.rs index c9ccee59..f444beef 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -186,6 +186,8 @@ mod m20260521_000089_rename_books_file_path_to_path; pub mod m20260524_000090_create_access_groups; // Release tracking: split observed_at (detected) from released_at (publish date) mod m20260530_000091_add_release_ledger_released_at; +// Admin-managed per-plugin cron for scheduled user-plugin syncs +mod m20260606_000092_add_plugin_sync_cron; pub struct Migrator; @@ -343,6 +345,8 @@ impl MigratorTrait for Migrator { // Release ledger: add released_at (upstream publish date); observed_at // now means detection time. Box::new(m20260530_000091_add_release_ledger_released_at::Migration), + // Admin-managed per-plugin cron for scheduled user-plugin syncs + Box::new(m20260606_000092_add_plugin_sync_cron::Migration), ] } } diff --git a/migration/src/m20260606_000092_add_plugin_sync_cron.rs b/migration/src/m20260606_000092_add_plugin_sync_cron.rs new file mode 100644 index 00000000..92e25007 --- /dev/null +++ b/migration/src/m20260606_000092_add_plugin_sync_cron.rs @@ -0,0 +1,51 @@ +//! Add `sync_cron_schedule` column to `plugins`. +//! +//! Admin-managed cadence for scheduled user-plugin syncs. NULL means +//! "no scheduled sync" (the default, so existing installs are unchanged); +//! a non-NULL value is a normalized cron expression. When set on a +//! sync-capable plugin, the scheduler fans out a `UserPluginSync` task for +//! every connected user who has opted into auto sync. +//! +//! The per-user opt-in itself is NOT stored here: it lives host-side in +//! `user_plugins.config._codex.autoSync`, so no `user_plugins` schema +//! change is needed. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Plugins::Table) + .add_column(ColumnDef::new(Plugins::SyncCronSchedule).text()) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Plugins::Table) + .drop_column(Plugins::SyncCronSchedule) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Plugins { + Table, + SyncCronSchedule, +} diff --git a/tests/services/apply_dry_run.rs b/tests/services/apply_dry_run.rs index d72d1772..17b2d56c 100644 --- a/tests/services/apply_dry_run.rs +++ b/tests/services/apply_dry_run.rs @@ -67,6 +67,7 @@ fn create_test_plugin() -> plugins::Model { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, diff --git a/tests/services/book_metadata_apply.rs b/tests/services/book_metadata_apply.rs index d7337eb0..cca90f23 100644 --- a/tests/services/book_metadata_apply.rs +++ b/tests/services/book_metadata_apply.rs @@ -51,6 +51,7 @@ fn create_plugin_with_permissions(permissions: &[&str]) -> plugins::Model { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, diff --git a/tests/services/metadata_apply.rs b/tests/services/metadata_apply.rs index bbe76ebd..3b65fd7d 100644 --- a/tests/services/metadata_apply.rs +++ b/tests/services/metadata_apply.rs @@ -52,6 +52,7 @@ fn create_test_plugin() -> plugins::Model { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, @@ -327,6 +328,7 @@ fn create_plugin_with_permissions(permissions: &[&str]) -> plugins::Model { use_existing_external_id: true, metadata_targets: None, internal_config: None, + sync_cron_schedule: None, created_at: Utc::now(), updated_at: Utc::now(), created_by: None, From 09bcf7b86e8accf9be0df1c3c68cf92d814d8db3 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 14:53:45 -0700 Subject: [PATCH 02/27] feat(plugins): fan out scheduled user syncs on a plugin's cron When an enabled, sync-capable plugin has an admin-configured cron, the scheduler now registers one cron entry per plugin and, on each firing, enqueues a UserPluginSync task for every connected user whose connection is enabled, authenticated, and opted into auto sync (config._codex.autoSync). - Add Scheduler::load_plugin_sync_schedules() + add_plugin_sync_schedule(), wired into start() so reload_schedules() applies cron changes without a restart. Plugins lacking the user_read_sync capability are skipped. - Add fan_out_plugin_sync(): selects eligible connections, skips any with a pending/processing sync (reusing the existing per-user dedup), and logs a one-line summary of the outcome. Also fix a dedup bug this surfaced: enqueue coalesced UserPluginSync by task_type alone (the type has no FK columns and no dedup_params), so every user's fan-out task would have collapsed into one (and the manual path was latently affected too). Add TaskType::plugin_user_dedup() and dedup UserPluginSync on (plugin_id, user_id) in find_existing_task, so different users keep independent tasks while a repeat for the same user coalesces. Includes scheduler and task-queue tests. --- crates/codex-db/src/repositories/task.rs | 19 +- crates/codex-models/src/task.rs | 17 ++ crates/codex-scheduler/src/lib.rs | 359 ++++++++++++++++++++++- tests/task_queue/mod.rs | 68 +++++ 4 files changed, 459 insertions(+), 4 deletions(-) diff --git a/crates/codex-db/src/repositories/task.rs b/crates/codex-db/src/repositories/task.rs index 6fa7c344..c17476ce 100644 --- a/crates/codex-db/src/repositories/task.rs +++ b/crates/codex-db/src/repositories/task.rs @@ -380,7 +380,11 @@ impl TaskRepository { /// task types whose identity lives in `params` (e.g. /// `PollReleaseSource`). Without this, two such tasks differing only /// in `params` would falsely collide on `task_type` alone. - /// 3. None — only `task_type` and status are matched. This is the + /// 3. The `(plugin_id, user_id)` pair returned by + /// `TaskType::plugin_user_dedup()`, for per-user plugin tasks (e.g. + /// `UserPluginSync`). Without this, the scheduled fan-out's per-user + /// tasks would all collide on `task_type` alone and collapse into one. + /// 4. None — only `task_type` and status are matched. This is the /// desired behavior for singleton task types like `FindDuplicates`. async fn find_existing_task( db: &DatabaseConnection, @@ -402,6 +406,19 @@ impl TaskRepository { query = query.filter(tasks::Column::SeriesId.eq(ser_id)); } else if let Some(lib_id) = library_id { query = query.filter(tasks::Column::LibraryId.eq(lib_id)); + } else if let Some((plugin_id, user_id)) = task.plugin_user_dedup() { + // Per-user plugin tasks identify by (plugin_id, user_id) in params; + // dedup on BOTH keys so different users (or the same user across + // plugins) keep independent tasks instead of coalescing by type. + return match Self::find_pending_or_processing_task(db, task_type, plugin_id, user_id) + .await? + { + Some((id, _status)) => Tasks::find_by_id(id) + .one(db) + .await + .context("Failed to load existing task by id"), + None => Ok(None), + }; } else if let Some((key, value)) = task.dedup_params() { // Params-based dedup: route through the helper that knows how // to query JSON params portably across SQLite and Postgres. diff --git a/crates/codex-models/src/task.rs b/crates/codex-models/src/task.rs index d4389b5c..95586f8b 100644 --- a/crates/codex-models/src/task.rs +++ b/crates/codex-models/src/task.rs @@ -539,6 +539,23 @@ impl TaskType { } } + /// For task types whose identity is a `(plugin_id, user_id)` pair stored in + /// JSON params (no FK columns), return that pair so enqueue can dedup on + /// BOTH keys. + /// + /// Without this, `find_existing_task` has no entity id and no single-key + /// [`Self::dedup_params`], so it falls back to matching on `task_type` + /// alone and would coalesce unrelated users' tasks into one. That matters + /// most for the scheduled fan-out, which enqueues one `UserPluginSync` per + /// connected user of the same plugin: type-only coalescing would collapse + /// them all into a single task. + pub fn plugin_user_dedup(&self) -> Option<(Uuid, Uuid)> { + match self { + TaskType::UserPluginSync { plugin_id, user_id } => Some((*plugin_id, *user_id)), + _ => None, + } + } + /// Extract all fields needed for database insertion /// Returns: (type_string, library_id, series_id, book_id, params) pub fn extract_fields( diff --git a/crates/codex-scheduler/src/lib.rs b/crates/codex-scheduler/src/lib.rs index f590bb7e..514ceffa 100644 --- a/crates/codex-scheduler/src/lib.rs +++ b/crates/codex-scheduler/src/lib.rs @@ -7,8 +7,11 @@ use tokio_cron_scheduler::{Job, JobScheduler}; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use codex_db::entities::library_jobs; -use codex_db::repositories::{LibraryJobRepository, LibraryRepository, TaskRepository}; +use codex_db::entities::{library_jobs, plugins, user_plugins}; +use codex_db::repositories::{ + LibraryJobRepository, LibraryRepository, PluginsRepository, TaskRepository, + UserPluginsRepository, +}; use codex_scanner::{ScanMode, ScanningConfig}; use codex_services::library_jobs::{LibraryJobConfig, parse_job_config}; use codex_services::settings::SettingsService; @@ -91,6 +94,9 @@ impl Scheduler { // Load plugin data cleanup schedule (OAuth flows, expired storage) self.load_plugin_data_cleanup_schedule().await?; + // Load admin-configured per-plugin user-sync schedules + self.load_plugin_sync_schedules().await?; + // Load refresh-token cleanup schedule self.load_refresh_token_cleanup_schedule().await?; @@ -720,6 +726,91 @@ impl Scheduler { Ok(()) } + /// Load admin-configured per-plugin user-sync schedules. + /// + /// Each enabled plugin with a `sync_cron_schedule` and the `user_read_sync` + /// capability gets one cron entry. When it fires, the scheduler fans out a + /// `UserPluginSync` task for every connected user who opted into auto sync. + /// The row set is small (admin-managed), so a full reload is cheap. + async fn load_plugin_sync_schedules(&mut self) -> Result<()> { + let plugins = PluginsRepository::list_sync_scheduled(&self.db).await?; + for plugin in plugins { + if let Err(e) = self.add_plugin_sync_schedule(&plugin).await { + warn!( + "Failed to add sync schedule for plugin {} ('{}'): {}", + plugin.id, plugin.name, e + ); + } + } + Ok(()) + } + + /// Register a single plugin's user-sync cron entry. + /// + /// Skips (without erroring the whole load) plugins whose cached manifest + /// does not declare the `user_read_sync` capability, so we never schedule a + /// cron that can only fan out to zero eligible connections. Uses the server + /// default timezone; cadence is an admin/integration concern, not per-user. + async fn add_plugin_sync_schedule(&mut self, plugin: &plugins::Model) -> Result<()> { + let Some(cron_raw) = plugin.sync_cron_schedule.as_deref() else { + return Ok(()); + }; + + let supports_sync = plugin + .cached_manifest() + .map(|m| m.capabilities.user_read_sync) + .unwrap_or(false); + if !supports_sync { + warn!( + "Plugin {} ('{}') has a sync cron but no user_read_sync capability; skipping", + plugin.id, plugin.name + ); + return Ok(()); + } + + let cron = normalize_cron_expression(cron_raw) + .context("Invalid cron expression for plugin sync schedule")?; + let tz = self.default_tz; + + let db = self.db.clone(); + let plugin_id = plugin.id; + let plugin_name = plugin.name.clone(); + + let job = Job::new_async_tz(cron.as_str(), tz, move |_uuid, _lock| { + let db = db.clone(); + let plugin_name = plugin_name.clone(); + Box::pin(async move { + match fan_out_plugin_sync(&db, plugin_id).await { + Ok(summary) => info!( + "Plugin sync '{}' ({}): {} eligible, {} enqueued, {} skipped (ineligible), {} skipped (in flight)", + plugin_name, + plugin_id, + summary.candidates, + summary.enqueued, + summary.skipped_ineligible, + summary.skipped_in_flight + ), + Err(e) => error!( + "Plugin sync fan-out failed for '{}' ({}): {}", + plugin_name, plugin_id, e + ), + } + }) + }) + .context("Failed to create plugin sync cron")?; + + self.scheduler + .add(job) + .await + .context("Failed to add plugin sync cron to scheduler")?; + + info!( + "Added plugin sync schedule for '{}' ({}) cron='{}' tz={}", + plugin.name, plugin.id, cron, tz + ); + Ok(()) + } + /// Reload all schedules (useful when libraries or settings are updated) pub async fn reload_schedules(&mut self) -> Result<()> { info!("Reloading all schedules"); @@ -804,13 +895,106 @@ pub async fn has_active_refresh_for_job(db: &DatabaseConnection, job_id: Uuid) - Ok(result.is_some()) } +/// Whether a connection is eligible for an automatic (scheduled) sync. +/// +/// Eligible only when the connection is enabled, authenticated, and the user +/// has opted into auto sync (`config._codex.autoSync`). `get_users_with_plugin` +/// already filters to enabled rows, but we re-check `enabled` so the predicate +/// is self-contained and correct in isolation. A connection whose token has +/// expired but is otherwise authenticated is still eligible here; the sync +/// handler is responsible for surfacing/refreshing auth (v1 does not refresh in +/// the cron path). +pub(crate) fn is_auto_sync_eligible(up: &user_plugins::Model) -> bool { + up.enabled && up.is_authenticated() && up.auto_sync_enabled() +} + +/// Outcome of one plugin-sync fan-out, used for the per-firing log line. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct FanOutSummary { + /// Connections eligible for auto sync (enabled + authenticated + opted in). + pub candidates: usize, + /// Eligible connections for which a new sync task was enqueued. + pub enqueued: usize, + /// Connections skipped because they were not eligible. + pub skipped_ineligible: usize, + /// Eligible connections skipped because a sync was already pending/processing. + pub skipped_in_flight: usize, +} + +/// Enqueue a `UserPluginSync` task for every eligible connection of `plugin_id`. +/// +/// Skips any connection that already has a pending/processing sync, reusing the +/// exact per-(user, plugin) dedup the manual trigger endpoint uses +/// ([`TaskRepository::has_pending_or_processing`]). A slow plugin therefore +/// never stacks duplicate tasks across cron ticks. Execution rate is bounded +/// downstream by the task queue + worker concurrency, so no jitter is applied. +pub(crate) async fn fan_out_plugin_sync( + db: &DatabaseConnection, + plugin_id: Uuid, +) -> Result { + let connections = UserPluginsRepository::get_users_with_plugin(db, plugin_id).await?; + let mut summary = FanOutSummary::default(); + + for up in &connections { + if !is_auto_sync_eligible(up) { + summary.skipped_ineligible += 1; + continue; + } + summary.candidates += 1; + + match TaskRepository::has_pending_or_processing( + db, + "user_plugin_sync", + plugin_id, + up.user_id, + ) + .await + { + Ok(true) => { + summary.skipped_in_flight += 1; + continue; + } + Ok(false) => {} + Err(e) => { + // Don't let one bad check abort the whole fan-out; skip this user. + warn!( + "Failed to check in-flight sync for user {} plugin {}: {}; skipping", + up.user_id, plugin_id, e + ); + summary.skipped_in_flight += 1; + continue; + } + } + + let task_type = TaskType::UserPluginSync { + plugin_id, + user_id: up.user_id, + }; + match TaskRepository::enqueue(db, task_type, None).await { + Ok(_) => summary.enqueued += 1, + Err(e) => { + error!( + "Failed to enqueue auto sync for user {} plugin {}: {}", + up.user_id, plugin_id, e + ); + summary.skipped_in_flight += 1; + } + } + } + + Ok(summary) +} + #[cfg(test)] mod tests { use super::*; - use codex_db::repositories::LibraryRepository; + use chrono::Utc; + use codex_db::entities::users; + use codex_db::repositories::{LibraryRepository, UserRepository}; use codex_db::test_helpers::setup_test_db; use codex_models::ScanningStrategy; use codex_tasks::types::TaskType; + use sea_orm::{ActiveModelTrait, Set}; #[test] fn test_scheduler_can_be_created() { @@ -864,4 +1048,173 @@ mod tests { assert!(active_a, "job A has the in-flight task"); assert!(!active_b, "job B has no in-flight task"); } + + /// Build an in-memory user_plugins row for predicate testing (no DB). + fn make_up(enabled: bool, authed: bool, auto: bool) -> user_plugins::Model { + user_plugins::Model { + id: Uuid::new_v4(), + plugin_id: Uuid::new_v4(), + user_id: Uuid::new_v4(), + credentials: if authed { Some(vec![1, 2, 3]) } else { None }, + config: if auto { + serde_json::json!({ "_codex": { "autoSync": true } }) + } else { + serde_json::json!({}) + }, + oauth_access_token: None, + oauth_refresh_token: None, + oauth_expires_at: None, + oauth_scope: None, + external_user_id: None, + external_username: None, + external_avatar_url: None, + enabled, + health_status: "unknown".to_string(), + failure_count: 0, + last_failure_at: None, + last_success_at: None, + last_sync_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + #[test] + fn is_auto_sync_eligible_matrix() { + assert!( + is_auto_sync_eligible(&make_up(true, true, true)), + "enabled + authed + auto is eligible" + ); + assert!( + !is_auto_sync_eligible(&make_up(false, true, true)), + "disabled is ineligible" + ); + assert!( + !is_auto_sync_eligible(&make_up(true, false, true)), + "unauthenticated is ineligible" + ); + assert!( + !is_auto_sync_eligible(&make_up(true, true, false)), + "manual (opt-out) is ineligible" + ); + } + + async fn create_sync_plugin(db: &DatabaseConnection, name: &str) -> Uuid { + PluginsRepository::create( + db, + name, + name, + None, + "user", + "node", + vec![], + vec![], + None, + vec![], + vec![], + vec![], + None, + "env", + None, + true, + None, + Some(60), + None, + ) + .await + .unwrap() + .id + } + + /// Create a real connection (user + user_plugins row) and set its auth / + /// auto-sync state via a direct ActiveModel update. Returns the user id. + async fn create_connection( + db: &DatabaseConnection, + plugin_id: Uuid, + authed: bool, + auto: bool, + ) -> Uuid { + let user = users::Model { + id: Uuid::new_v4(), + username: format!("u_{}", Uuid::new_v4()), + email: format!("{}@example.com", Uuid::new_v4()), + password_hash: "hash".to_string(), + role: "reader".to_string(), + is_active: true, + email_verified: false, + permissions: serde_json::json!([]), + created_at: Utc::now(), + updated_at: Utc::now(), + last_login_at: None, + }; + let user = UserRepository::create(db, &user).await.unwrap(); + + let up = UserPluginsRepository::create(db, plugin_id, user.id) + .await + .unwrap(); + let mut active: user_plugins::ActiveModel = up.into(); + if authed { + active.credentials = Set(Some(vec![1, 2, 3])); + } + if auto { + active.config = Set(serde_json::json!({ "_codex": { "autoSync": true } })); + } + active.update(db).await.unwrap(); + + user.id + } + + #[tokio::test] + async fn fan_out_enqueues_only_eligible_and_dedups() { + let db = setup_test_db().await; + let plugin_id = create_sync_plugin(&db, "sync_plugin").await; + let other_plugin = create_sync_plugin(&db, "other_plugin").await; + + let user_a = create_connection(&db, plugin_id, true, true).await; // eligible + let _user_b = create_connection(&db, plugin_id, true, false).await; // manual + let _user_c = create_connection(&db, plugin_id, false, true).await; // unauthed + let user_d = create_connection(&db, plugin_id, true, true).await; // eligible, in flight + TaskRepository::enqueue( + &db, + TaskType::UserPluginSync { + plugin_id, + user_id: user_d, + }, + None, + ) + .await + .unwrap(); + // Eligible connection on a different plugin must be untouched. + let user_e = create_connection(&db, other_plugin, true, true).await; + + let summary = fan_out_plugin_sync(&db, plugin_id).await.unwrap(); + assert_eq!(summary.candidates, 2, "A and D are eligible"); + assert_eq!(summary.enqueued, 1, "only A is enqueued"); + assert_eq!(summary.skipped_ineligible, 2, "B (manual) and C (unauthed)"); + assert_eq!(summary.skipped_in_flight, 1, "D already pending"); + + assert!( + TaskRepository::has_pending_or_processing(&db, "user_plugin_sync", plugin_id, user_a) + .await + .unwrap(), + "A now has a sync task" + ); + assert!( + !TaskRepository::has_pending_or_processing( + &db, + "user_plugin_sync", + other_plugin, + user_e + ) + .await + .unwrap(), + "other plugin's user must not be enqueued by this plugin's fan-out" + ); + + // Second tick while A's task is still pending: nothing new is enqueued. + let summary2 = fan_out_plugin_sync(&db, plugin_id).await.unwrap(); + assert_eq!(summary2.candidates, 2); + assert_eq!(summary2.enqueued, 0, "dedup across cron ticks"); + assert_eq!(summary2.skipped_in_flight, 2, "A and D both in flight now"); + } } diff --git a/tests/task_queue/mod.rs b/tests/task_queue/mod.rs index 545ca67b..27829f13 100644 --- a/tests/task_queue/mod.rs +++ b/tests/task_queue/mod.rs @@ -2088,6 +2088,74 @@ async fn test_has_pending_or_processing_different_task_type() { assert!(!result, "Should not match different task_type"); } +/// Enqueuing UserPluginSync for two different users of the same plugin must +/// create two independent tasks. Regression guard for the scheduled fan-out: +/// the enqueue dedup keys on (plugin_id, user_id), not task_type alone. +#[tokio::test] +async fn test_enqueue_user_plugin_sync_independent_per_user() { + let (db, _temp_dir) = setup_test_db().await; + + let plugin_id = Uuid::new_v4(); + let user_a = Uuid::new_v4(); + let user_b = Uuid::new_v4(); + + let id_a = TaskRepository::enqueue( + &db, + TaskType::UserPluginSync { + plugin_id, + user_id: user_a, + }, + None, + ) + .await + .expect("enqueue A"); + let id_b = TaskRepository::enqueue( + &db, + TaskType::UserPluginSync { + plugin_id, + user_id: user_b, + }, + None, + ) + .await + .expect("enqueue B"); + + assert_ne!(id_a, id_b, "different users must get distinct sync tasks"); + assert!( + TaskRepository::has_pending_or_processing(&db, "user_plugin_sync", plugin_id, user_a) + .await + .unwrap() + ); + assert!( + TaskRepository::has_pending_or_processing(&db, "user_plugin_sync", plugin_id, user_b) + .await + .unwrap() + ); +} + +/// Enqueuing UserPluginSync twice for the *same* (plugin, user) coalesces onto +/// the in-flight task instead of stacking duplicates. +#[tokio::test] +async fn test_enqueue_user_plugin_sync_coalesces_same_user() { + let (db, _temp_dir) = setup_test_db().await; + + let plugin_id = Uuid::new_v4(); + let user_id = Uuid::new_v4(); + let task_type = || TaskType::UserPluginSync { plugin_id, user_id }; + + let first = TaskRepository::enqueue(&db, task_type(), None) + .await + .expect("enqueue first"); + let second = TaskRepository::enqueue(&db, task_type(), None) + .await + .expect("enqueue second"); + + assert_eq!( + first, second, + "second enqueue for the same user must return the in-flight task" + ); +} + /// Test has_pending_or_processing with recommendations task type #[tokio::test] async fn test_find_duplicates_handler_reads_trusted_sources_setting() { From f546088bd87607b3a722a2bf076db1bf16f1133d Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 16:59:56 -0700 Subject: [PATCH 03/27] feat(plugins): API for admin sync cron and per-user auto/manual sync Expose the scheduled-sync controls over the API: - Admin: PATCH /admin/plugins/{id} accepts syncCronSchedule (omit = no change, null = clear, string = set). The value is validated and normalized, and rejected unless the plugin's manifest declares the user_read_sync capability. PluginDto now returns the stored schedule. - User: new PATCH /user/plugins/{id}/sync-mode { auto } toggles a connection between automatic and manual sync. It is capability-gated and does a read-modify-write of the host-only config._codex.autoSync flag, preserving all other config keys. UserPluginDto exposes a derived auto_sync field. Reload the cron scheduler when a schedule changes so it takes effect without a server restart: on plugin update when the schedule field is present, and on enable/disable for plugins that carry a schedule (since registration is gated on the enabled state). Includes API tests for both endpoints (validation, capability gating, clearing, and config-merge preservation). --- crates/codex-api/src/docs.rs | 2 + crates/codex-api/src/routes/v1/dto/plugins.rs | 19 +++ .../src/routes/v1/dto/user_plugins.rs | 16 ++ .../src/routes/v1/handlers/plugins.rs | 63 +++++++- .../src/routes/v1/handlers/user_plugins.rs | 100 +++++++++++- .../src/routes/v1/routes/user_plugins.rs | 5 + crates/codex-db/src/repositories/plugins.rs | 5 + docs/api/openapi.json | 82 ++++++++++ tests/api/plugins.rs | 137 +++++++++++++++- tests/api/user_plugins.rs | 151 ++++++++++++++++++ web/openapi.json | 82 ++++++++++ web/src/types/api.generated.ts | 99 ++++++++++++ 12 files changed, 755 insertions(+), 6 deletions(-) diff --git a/crates/codex-api/src/docs.rs b/crates/codex-api/src/docs.rs index df26f127..9f45da41 100644 --- a/crates/codex-api/src/docs.rs +++ b/crates/codex-api/src/docs.rs @@ -486,6 +486,7 @@ The following paths are exempt from rate limiting: v1::handlers::user_plugins::disconnect_plugin, v1::handlers::user_plugins::get_user_plugin, v1::handlers::user_plugins::update_user_plugin_config, + v1::handlers::user_plugins::set_sync_mode, v1::handlers::user_plugins::oauth_start, v1::handlers::user_plugins::oauth_callback, v1::handlers::user_plugins::set_user_credentials, @@ -977,6 +978,7 @@ The following paths are exempt from rate limiting: v1::dto::UserPluginsListResponse, v1::dto::OAuthStartResponse, v1::dto::UpdateUserPluginConfigRequest, + v1::dto::SetSyncModeRequest, v1::dto::SetUserCredentialsRequest, v1::dto::SyncTriggerResponse, v1::dto::SyncStatusDto, diff --git a/crates/codex-api/src/routes/v1/dto/plugins.rs b/crates/codex-api/src/routes/v1/dto/plugins.rs index afa8e1b0..6c3cf79e 100644 --- a/crates/codex-api/src/routes/v1/dto/plugins.rs +++ b/crates/codex-api/src/routes/v1/dto/plugins.rs @@ -157,6 +157,12 @@ pub struct PluginDto { #[serde(skip_serializing_if = "Option::is_none")] pub internal_config: Option, + /// Admin-managed cron schedule for automatic user-plugin syncs + /// (null = no scheduled sync) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = "0 0 */6 * * *")] + pub sync_cron_schedule: Option, + /// Number of users who have enabled this plugin (only for user-type plugins) #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 3)] @@ -224,6 +230,7 @@ impl From for PluginDto { .internal_config .as_deref() .and_then(|s| serde_json::from_str(s).ok()), + sync_cron_schedule: model.sync_cron_schedule, user_count: None, } } @@ -718,6 +725,18 @@ pub struct UpdatePluginRequest { /// Validated as InternalPluginConfig on the server #[serde(skip_serializing_if = "Option::is_none")] pub internal_config: Option, + + /// Admin-managed cron schedule for automatic user-plugin syncs. + /// Omit to leave unchanged; `null` clears it (no scheduled sync); a string + /// sets/normalizes it. Only allowed on plugins whose manifest declares the + /// `user_read_sync` capability. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_nullable" + )] + #[schema(example = "0 0 */6 * * *")] + pub sync_cron_schedule: Option, } // ============================================================================= diff --git a/crates/codex-api/src/routes/v1/dto/user_plugins.rs b/crates/codex-api/src/routes/v1/dto/user_plugins.rs index 679cdc3e..05d4c358 100644 --- a/crates/codex-api/src/routes/v1/dto/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/dto/user_plugins.rs @@ -71,6 +71,10 @@ pub struct UserPluginDto { pub user_setup_instructions: Option, /// Per-user configuration pub config: serde_json::Value, + /// Whether this connection is opted into automatic scheduled syncs + /// (host-side preference, derived from `config._codex.autoSync`). When + /// false (the default), syncs run only when manually triggered. + pub auto_sync: bool, /// Plugin capabilities (derived from manifest) pub capabilities: UserPluginCapabilitiesDto, /// User-facing configuration schema (from plugin manifest) @@ -125,6 +129,15 @@ pub struct UpdateUserPluginConfigRequest { pub config: serde_json::Value, } +/// Request to set a connection's automatic-sync preference (manual vs auto). +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SetSyncModeRequest { + /// `true` opts the connection into scheduled syncs on the plugin's + /// admin-configured cadence; `false` is manual-only (the default). + pub auto: bool, +} + /// Request to set user credentials (e.g., personal access token) #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] @@ -378,6 +391,7 @@ mod tests { description: None, user_setup_instructions: None, config: serde_json::json!({}), + auto_sync: false, capabilities: UserPluginCapabilitiesDto { read_sync: true, user_recommendation_provider: false, @@ -426,6 +440,7 @@ mod tests { description: None, user_setup_instructions: None, config: serde_json::json!({}), + auto_sync: false, capabilities: UserPluginCapabilitiesDto { read_sync: true, user_recommendation_provider: false, @@ -469,6 +484,7 @@ mod tests { description: None, user_setup_instructions: None, config: serde_json::json!({}), + auto_sync: false, capabilities: UserPluginCapabilitiesDto { read_sync: true, user_recommendation_provider: false, diff --git a/crates/codex-api/src/routes/v1/handlers/plugins.rs b/crates/codex-api/src/routes/v1/handlers/plugins.rs index 2ee94457..34c2cbbf 100644 --- a/crates/codex-api/src/routes/v1/handlers/plugins.rs +++ b/crates/codex-api/src/routes/v1/handlers/plugins.rs @@ -63,6 +63,17 @@ use uuid::Uuid; #[allow(dead_code)] // OpenAPI documentation struct - referenced by utoipa derive macros pub struct PluginsApi; +/// Reload the cron scheduler so per-plugin sync schedules take effect without a +/// server restart. No-op when the scheduler isn't running (e.g. API-only mode). +async fn reload_sync_scheduler(state: &AppState) { + if let Some(scheduler) = state.scheduler.as_ref() { + let mut s = scheduler.lock().await; + if let Err(e) = s.reload_schedules().await { + tracing::warn!("Scheduler reload after plugin change failed: {e:#}"); + } + } +} + /// List all plugins #[utoipa::path( get, @@ -496,6 +507,37 @@ pub async fn update_plugin( .env .map(|e| e.into_iter().map(|ev| (ev.key, ev.value)).collect()); + // Validate + capability-gate the sync cron schedule if the field is present. + // Absent = leave unchanged; `null` = clear; a string = set (only on + // sync-capable plugins, normalized to the scheduler's 6-field cron form). + let sync_cron_schedule: Option> = match request.sync_cron_schedule { + None => None, + Some(v) if v.is_null() => Some(None), + Some(v) => { + let raw = v.as_str().ok_or_else(|| { + ApiError::BadRequest("syncCronSchedule must be a string or null".to_string()) + })?; + let existing = PluginsRepository::get_by_id(&state.db, id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not found".to_string()))?; + let supports_sync = existing + .cached_manifest() + .map(|m| m.capabilities.user_read_sync) + .unwrap_or(false); + if !supports_sync { + return Err(ApiError::BadRequest( + "Plugin does not support user sync (user_read_sync); cannot set a sync schedule" + .to_string(), + )); + } + let normalized = codex_utils::cron::validate_cron_expression(raw) + .map_err(|e| ApiError::BadRequest(format!("Invalid cron expression: {}", e)))?; + Some(Some(normalized)) + } + }; + let sync_schedule_changed = sync_cron_schedule.is_some(); + // Update the plugin let plugin = PluginsRepository::update( &state.db, @@ -514,6 +556,7 @@ pub async fn update_plugin( Some(auth.user_id), request.rate_limit_requests_per_minute, request.request_timeout_seconds, + sync_cron_schedule, ) .await .map_err(|e| { @@ -634,6 +677,12 @@ pub async fn update_plugin( tracing::warn!("Failed to reload plugin manager after update: {}", e); } + // If the sync schedule changed, reload the cron scheduler so the new/cleared + // schedule takes effect without a server restart. + if sync_schedule_changed { + reload_sync_scheduler(&state).await; + } + // Broadcast plugin updated event let event = EntityChangeEvent::new( EntityEvent::PluginUpdated { plugin_id: id }, @@ -754,7 +803,7 @@ pub async fn enable_plugin( ) -> Result, ApiError> { auth.require_permission(&Permission::PluginsManage)?; - let _plugin = PluginsRepository::enable(&state.db, id, Some(auth.user_id)) + let plugin = PluginsRepository::enable(&state.db, id, Some(auth.user_id)) .await .map_err(|e| { if e.to_string().contains("not found") { @@ -769,6 +818,12 @@ pub async fn enable_plugin( tracing::warn!("Failed to reload plugin manager after enable: {}", e); } + // Re-register the sync cron now that the plugin is enabled (registration is + // gated on enabled state). Only needed when the plugin carries a schedule. + if plugin.sync_cron_schedule.is_some() { + reload_sync_scheduler(&state).await; + } + // Broadcast plugin enabled event let event = EntityChangeEvent::new( EntityEvent::PluginEnabled { plugin_id: id }, @@ -856,6 +911,12 @@ pub async fn disable_plugin( tracing::warn!("Failed to reload plugin manager after disable: {}", e); } + // Drop the sync cron now that the plugin is disabled (registration is gated + // on enabled state). Only needed when the plugin carries a schedule. + if plugin.sync_cron_schedule.is_some() { + reload_sync_scheduler(&state).await; + } + // Broadcast plugin disabled event let event = EntityChangeEvent::new( EntityEvent::PluginDisabled { plugin_id: id }, diff --git a/crates/codex-api/src/routes/v1/handlers/user_plugins.rs b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs index 98afb950..eaaff674 100644 --- a/crates/codex-api/src/routes/v1/handlers/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs @@ -6,10 +6,10 @@ use super::super::dto::plugins::ConfigSchemaDto; use super::super::dto::user_plugins::{ - AvailablePluginDto, OAuthCallbackQuery, OAuthStartResponse, SetUserCredentialsRequest, - SyncStatusDto, SyncStatusQuery, SyncTriggerResponse, UpdateUserPluginConfigRequest, - UserPluginCapabilitiesDto, UserPluginDto, UserPluginTaskDto, UserPluginTasksQuery, - UserPluginsListResponse, + AvailablePluginDto, OAuthCallbackQuery, OAuthStartResponse, SetSyncModeRequest, + SetUserCredentialsRequest, SyncStatusDto, SyncStatusQuery, SyncTriggerResponse, + UpdateUserPluginConfigRequest, UserPluginCapabilitiesDto, UserPluginDto, UserPluginTaskDto, + UserPluginTasksQuery, UserPluginsListResponse, }; use crate::extractors::auth::AuthContext; use crate::{error::ApiError, extractors::AppState}; @@ -154,6 +154,7 @@ async fn build_user_plugin_dto( description: manifest.as_ref().and_then(|m| m.user_description.clone()), user_setup_instructions: manifest.and_then(|m| m.user_setup_instructions), config: instance.config.clone(), + auto_sync: instance.auto_sync_enabled(), capabilities, user_config_schema, last_sync_result, @@ -721,6 +722,97 @@ pub async fn update_user_plugin_config( )) } +/// Set a connection's automatic-sync preference (manual vs auto) +/// +/// Writes the host-only `config._codex.autoSync` flag (the plugin never sees +/// it). When `true`, the connection is synced automatically on the plugin's +/// admin-configured cron; when `false` (the default) syncs run only on demand. +/// Only allowed for plugins whose manifest declares the `user_read_sync` +/// capability. +#[utoipa::path( + patch, + path = "/api/v1/user/plugins/{plugin_id}/sync-mode", + params( + ("plugin_id" = Uuid, Path, description = "Plugin ID to set sync mode for") + ), + request_body = SetSyncModeRequest, + responses( + (status = 200, description = "Sync mode updated", body = UserPluginDto), + (status = 400, description = "Plugin does not support sync"), + (status = 401, description = "Not authenticated"), + (status = 404, description = "Plugin not enabled for this user"), + ), + tag = "User Plugins" +)] +pub async fn set_sync_mode( + State(state): State>, + auth: AuthContext, + Path(plugin_id): Path, + Json(request): Json, +) -> Result, ApiError> { + use codex_db::entities::user_plugins::{AUTO_SYNC_KEY, CODEX_CONFIG_NAMESPACE}; + + let instance = + UserPluginsRepository::get_by_user_and_plugin(&state.db, auth.user_id, plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Database error: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not enabled for this user".to_string()))?; + + let plugin = PluginsRepository::get_by_id(&state.db, plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::Internal("Plugin definition not found".to_string()))?; + + // Only sync-capable plugins can be put into auto mode. + let supports_sync = parse_manifest(&plugin) + .map(|m| m.capabilities.user_read_sync) + .unwrap_or(false); + if !supports_sync { + return Err(ApiError::BadRequest( + "Plugin does not support reading sync".to_string(), + )); + } + + // Read-modify-write the host-only `_codex.autoSync` flag, preserving every + // other config key (top-level plugin config and other `_codex` settings). + let mut config = match instance.config.clone() { + serde_json::Value::Object(map) => map, + _ => serde_json::Map::new(), + }; + let codex_ns = config + .entry(CODEX_CONFIG_NAMESPACE) + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + if !codex_ns.is_object() { + *codex_ns = serde_json::Value::Object(serde_json::Map::new()); + } + codex_ns + .as_object_mut() + .expect("ensured object above") + .insert( + AUTO_SYNC_KEY.to_string(), + serde_json::Value::Bool(request.auto), + ); + + let updated = UserPluginsRepository::update_config( + &state.db, + instance.id, + serde_json::Value::Object(config), + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update sync mode: {}", e)))?; + + debug!( + user_id = %auth.user_id, + plugin_id = %plugin_id, + auto = request.auto, + "User updated plugin sync mode" + ); + + Ok(Json( + build_user_plugin_dto(&state.db, &updated, &plugin, None).await, + )) +} + /// Trigger a sync operation for a user plugin /// /// Enqueues a background sync task that will push/pull reading progress diff --git a/crates/codex-api/src/routes/v1/routes/user_plugins.rs b/crates/codex-api/src/routes/v1/routes/user_plugins.rs index 789beb55..cac0440e 100644 --- a/crates/codex-api/src/routes/v1/routes/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/routes/user_plugins.rs @@ -47,6 +47,11 @@ pub fn routes(_state: Arc) -> Router> { "/user/plugins/{plugin_id}/config", patch(handlers::user_plugins::update_user_plugin_config), ) + // Auto/manual scheduled-sync preference + .route( + "/user/plugins/{plugin_id}/sync-mode", + patch(handlers::user_plugins::set_sync_mode), + ) // User credentials (personal access token) .route( "/user/plugins/{plugin_id}/credentials", diff --git a/crates/codex-db/src/repositories/plugins.rs b/crates/codex-db/src/repositories/plugins.rs index 1a0821f0..44a07b5f 100644 --- a/crates/codex-db/src/repositories/plugins.rs +++ b/crates/codex-db/src/repositories/plugins.rs @@ -364,6 +364,7 @@ impl PluginsRepository { updated_by: Option, rate_limit_requests_per_minute: Option>, request_timeout_seconds: Option>, + sync_cron_schedule: Option>, ) -> Result { let existing = Self::get_by_id(db, id) .await? @@ -434,6 +435,10 @@ impl PluginsRepository { active_model.request_timeout_seconds = Set(timeout); } + if let Some(cron) = sync_cron_schedule { + active_model.sync_cron_schedule = Set(cron); + } + let result = active_model.update(db).await?; Ok(result) } diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 8cec9b2f..c2f26ca6 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -15354,6 +15354,59 @@ } } }, + "/api/v1/user/plugins/{plugin_id}/sync-mode": { + "patch": { + "tags": [ + "User Plugins" + ], + "summary": "Set a connection's automatic-sync preference (manual vs auto)", + "description": "Writes the host-only `config._codex.autoSync` flag (the plugin never sees\nit). When `true`, the connection is synced automatically on the plugin's\nadmin-configured cron; when `false` (the default) syncs run only on demand.\nOnly allowed for plugins whose manifest declares the `user_read_sync`\ncapability.", + "operationId": "set_sync_mode", + "parameters": [ + { + "name": "plugin_id", + "in": "path", + "description": "Plugin ID to set sync mode for", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetSyncModeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Sync mode updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPluginDto" + } + } + } + }, + "400": { + "description": "Plugin does not support sync" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "Plugin not enabled for this user" + } + } + } + }, "/api/v1/user/plugins/{plugin_id}/sync/status": { "get": { "tags": [ @@ -34117,6 +34170,14 @@ ], "description": "Handlebars template for customizing search queries" }, + "syncCronSchedule": { + "type": [ + "string", + "null" + ], + "description": "Admin-managed cron schedule for automatic user-plugin syncs\n(null = no scheduled sync)", + "example": "0 0 */6 * * *" + }, "updatedAt": { "type": "string", "format": "date-time", @@ -38799,6 +38860,19 @@ } } }, + "SetSyncModeRequest": { + "type": "object", + "description": "Request to set a connection's automatic-sync preference (manual vs auto).", + "required": [ + "auto" + ], + "properties": { + "auto": { + "type": "boolean", + "description": "`true` opts the connection into scheduled syncs on the plugin's\nadmin-configured cadence; `false` is manual-only (the default)." + } + } + }, "SetUserCredentialsRequest": { "type": "object", "description": "Request to set user credentials (e.g., personal access token)", @@ -41793,6 +41867,9 @@ "searchQueryTemplate": { "description": "Handlebars template for customizing search queries (null = clear template)" }, + "syncCronSchedule": { + "description": "Admin-managed cron schedule for automatic user-plugin syncs.\nOmit to leave unchanged; `null` clears it (no scheduled sync); a string\nsets/normalizes it. Only allowed on plugins whose manifest declares the\n`user_read_sync` capability." + }, "useExistingExternalId": { "type": [ "boolean", @@ -42288,10 +42365,15 @@ "requiresOauth", "oauthConfigured", "config", + "autoSync", "capabilities", "createdAt" ], "properties": { + "autoSync": { + "type": "boolean", + "description": "Whether this connection is opted into automatic scheduled syncs\n(host-side preference, derived from `config._codex.autoSync`). When\nfalse (the default), syncs run only when manually triggered." + }, "capabilities": { "$ref": "#/components/schemas/UserPluginCapabilitiesDto", "description": "Plugin capabilities (derived from manifest)" diff --git a/tests/api/plugins.rs b/tests/api/plugins.rs index d31f0c8a..a6b75db8 100644 --- a/tests/api/plugins.rs +++ b/tests/api/plugins.rs @@ -28,7 +28,7 @@ use serde_json::json; use codex::api::routes::v1::dto::{ PluginDto, PluginHealthResponse, PluginStatusResponse, PluginTestResult, PluginsListResponse, }; -use codex::db::repositories::UserRepository; +use codex::db::repositories::{PluginsRepository, UserRepository}; use codex::utils::password; // ============================================================================= @@ -1831,3 +1831,138 @@ async fn test_apply_book_metadata_requires_auth() { assert_eq!(status, StatusCode::UNAUTHORIZED); } + +// ============================================================================= +// Sync Cron Schedule Tests (admin-managed per-plugin cadence) +// ============================================================================= + +/// Create a plugin and give it a manifest declaring the `user_read_sync` +/// capability, so it is eligible for a sync cron schedule. Returns its ID. +async fn create_sync_capable_plugin( + db: &sea_orm::DatabaseConnection, + state: &std::sync::Arc, + token: &str, + name: &str, +) -> uuid::Uuid { + let app = create_test_router(state.clone()).await; + let body = json!({ + "name": name, + "displayName": name, + "command": "node", + "enabled": false, + }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::CREATED); + let id = response.expect("create body").plugin.id; + + let manifest = json!({ + "name": name, + "displayName": name, + "version": "1.0.0", + "protocolVersion": "1.0", + "capabilities": { "userReadSync": true }, + }); + PluginsRepository::update_manifest(db, id, Some(manifest)) + .await + .unwrap(); + id +} + +#[tokio::test] +async fn test_set_sync_cron_on_capable_plugin() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app_state = state.clone(); + let token = create_admin_and_token(&db, &state).await; + let id = create_sync_capable_plugin(&db, &app_state, &token, "sync-cap").await; + + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/admin/plugins/{}", id), + &json!({ "syncCronSchedule": "0 0 * * *" }), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + let dto = dto.expect("update body"); + // Stored normalized to the scheduler's 6-field cron form. + assert_eq!(dto.sync_cron_schedule.as_deref(), Some("0 0 0 * * *")); +} + +#[tokio::test] +async fn test_set_sync_cron_rejects_bad_cron() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app_state = state.clone(); + let token = create_admin_and_token(&db, &state).await; + let id = create_sync_capable_plugin(&db, &app_state, &token, "sync-badcron").await; + + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/admin/plugins/{}", id), + &json!({ "syncCronSchedule": "not a cron" }), + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_set_sync_cron_rejected_for_non_capable_plugin() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // Plugin without a manifest -> not sync-capable. + let app = create_test_router(state.clone()).await; + let body = json!({ "name": "no-cap", "displayName": "No Cap", "command": "node" }); + let request = post_json_request_with_auth("/api/v1/admin/plugins", &body, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::CREATED); + let id = response.expect("create body").plugin.id; + + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/admin/plugins/{}", id), + &json!({ "syncCronSchedule": "0 0 * * *" }), + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_clear_sync_cron_with_null() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app_state = state.clone(); + let token = create_admin_and_token(&db, &state).await; + let id = create_sync_capable_plugin(&db, &app_state, &token, "sync-clear").await; + + // Set a schedule first. + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/admin/plugins/{}", id), + &json!({ "syncCronSchedule": "0 0 * * *" }), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + assert!(dto.expect("set body").sync_cron_schedule.is_some()); + + // Clear it with an explicit null. + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/admin/plugins/{}", id), + &json!({ "syncCronSchedule": null }), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(dto.expect("clear body").sync_cron_schedule, None); +} diff --git a/tests/api/user_plugins.rs b/tests/api/user_plugins.rs index ff48ff40..db0e1502 100644 --- a/tests/api/user_plugins.rs +++ b/tests/api/user_plugins.rs @@ -1538,3 +1538,154 @@ async fn test_get_plugin_tasks_reader_role_can_access() { assert_eq!(task_dto.task_type, "user_plugin_sync"); assert_eq!(task_dto.status, "pending"); } + +// ============================================================================= +// Sync Mode (auto/manual) Tests +// ============================================================================= + +/// Enable a sync-capable plugin for a user and return the connection's plugin_id. +async fn enable_sync_plugin_for_user( + state: &std::sync::Arc, + token: &str, + plugin_id: uuid::Uuid, +) { + let app = create_test_router(state.clone()).await; + let request = + post_request_with_auth(&format!("/api/v1/user/plugins/{}/enable", plugin_id), token); + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn test_set_sync_mode_defaults_to_manual() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_user_and_token(&db, &state, "syncuser").await; + let plugin_id = create_sync_plugin(&db, "sync-default", "Sync Default").await; + + // Enabling does not opt into auto sync. + let app = create_test_router(state.clone()).await; + let request = post_request_with_auth( + &format!("/api/v1/user/plugins/{}/enable", plugin_id), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + assert!( + !dto.expect("enable body").auto_sync, + "new connections default to manual (auto_sync = false)" + ); +} + +#[tokio::test] +async fn test_set_sync_mode_toggles_auto() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_user_and_token(&db, &state, "syncuser").await; + let plugin_id = create_sync_plugin(&db, "sync-toggle", "Sync Toggle").await; + enable_sync_plugin_for_user(&state, &token, plugin_id).await; + + // Opt in. + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/sync-mode", plugin_id), + &json!({ "auto": true }), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + let dto = dto.expect("sync-mode body"); + assert!(dto.auto_sync, "auto should be enabled after opting in"); + // The host-only flag lives under config._codex.autoSync. + assert_eq!(dto.config["_codex"]["autoSync"], serde_json::json!(true)); + + // Opt back out. + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/sync-mode", plugin_id), + &json!({ "auto": false }), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + assert!( + !dto.expect("sync-mode body").auto_sync, + "auto should be disabled after opting out" + ); +} + +#[tokio::test] +async fn test_set_sync_mode_preserves_other_codex_settings() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_user_and_token(&db, &state, "syncuser").await; + let plugin_id = create_sync_plugin(&db, "sync-merge", "Sync Merge").await; + enable_sync_plugin_for_user(&state, &token, plugin_id).await; + + // Seed an existing _codex setting via the config endpoint. + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/config", plugin_id), + &json!({ "config": { "_codex": { "includeCompleted": false }, "progressUnit": "volumes" } }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + + // Toggling auto must not clobber the other keys. + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/sync-mode", plugin_id), + &json!({ "auto": true }), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + let dto = dto.expect("sync-mode body"); + assert_eq!(dto.config["_codex"]["autoSync"], serde_json::json!(true)); + assert_eq!( + dto.config["_codex"]["includeCompleted"], + serde_json::json!(false), + "existing _codex keys must be preserved" + ); + assert_eq!(dto.config["progressUnit"], serde_json::json!("volumes")); +} + +#[tokio::test] +async fn test_set_sync_mode_rejected_for_non_sync_plugin() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_user_and_token(&db, &state, "syncuser").await; + // No manifest -> not sync-capable. + let plugin_id = create_user_type_plugin(&db, "no-sync", "No Sync").await; + enable_sync_plugin_for_user(&state, &token, plugin_id).await; + + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/sync-mode", plugin_id), + &json!({ "auto": true }), + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_set_sync_mode_requires_enabled_connection() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_user_and_token(&db, &state, "syncuser").await; + // Capable plugin, but the user never enabled it. + let plugin_id = create_sync_plugin(&db, "sync-unenabled", "Sync Unenabled").await; + + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/sync-mode", plugin_id), + &json!({ "auto": true }), + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/web/openapi.json b/web/openapi.json index 8cec9b2f..c2f26ca6 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -15354,6 +15354,59 @@ } } }, + "/api/v1/user/plugins/{plugin_id}/sync-mode": { + "patch": { + "tags": [ + "User Plugins" + ], + "summary": "Set a connection's automatic-sync preference (manual vs auto)", + "description": "Writes the host-only `config._codex.autoSync` flag (the plugin never sees\nit). When `true`, the connection is synced automatically on the plugin's\nadmin-configured cron; when `false` (the default) syncs run only on demand.\nOnly allowed for plugins whose manifest declares the `user_read_sync`\ncapability.", + "operationId": "set_sync_mode", + "parameters": [ + { + "name": "plugin_id", + "in": "path", + "description": "Plugin ID to set sync mode for", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetSyncModeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Sync mode updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPluginDto" + } + } + } + }, + "400": { + "description": "Plugin does not support sync" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "Plugin not enabled for this user" + } + } + } + }, "/api/v1/user/plugins/{plugin_id}/sync/status": { "get": { "tags": [ @@ -34117,6 +34170,14 @@ ], "description": "Handlebars template for customizing search queries" }, + "syncCronSchedule": { + "type": [ + "string", + "null" + ], + "description": "Admin-managed cron schedule for automatic user-plugin syncs\n(null = no scheduled sync)", + "example": "0 0 */6 * * *" + }, "updatedAt": { "type": "string", "format": "date-time", @@ -38799,6 +38860,19 @@ } } }, + "SetSyncModeRequest": { + "type": "object", + "description": "Request to set a connection's automatic-sync preference (manual vs auto).", + "required": [ + "auto" + ], + "properties": { + "auto": { + "type": "boolean", + "description": "`true` opts the connection into scheduled syncs on the plugin's\nadmin-configured cadence; `false` is manual-only (the default)." + } + } + }, "SetUserCredentialsRequest": { "type": "object", "description": "Request to set user credentials (e.g., personal access token)", @@ -41793,6 +41867,9 @@ "searchQueryTemplate": { "description": "Handlebars template for customizing search queries (null = clear template)" }, + "syncCronSchedule": { + "description": "Admin-managed cron schedule for automatic user-plugin syncs.\nOmit to leave unchanged; `null` clears it (no scheduled sync); a string\nsets/normalizes it. Only allowed on plugins whose manifest declares the\n`user_read_sync` capability." + }, "useExistingExternalId": { "type": [ "boolean", @@ -42288,10 +42365,15 @@ "requiresOauth", "oauthConfigured", "config", + "autoSync", "capabilities", "createdAt" ], "properties": { + "autoSync": { + "type": "boolean", + "description": "Whether this connection is opted into automatic scheduled syncs\n(host-side preference, derived from `config._codex.autoSync`). When\nfalse (the default), syncs run only when manually triggered." + }, "capabilities": { "$ref": "#/components/schemas/UserPluginCapabilitiesDto", "description": "Plugin capabilities (derived from manifest)" diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index a310a0f2..dc70964c 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -5247,6 +5247,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/user/plugins/{plugin_id}/sync-mode": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Set a connection's automatic-sync preference (manual vs auto) + * @description Writes the host-only `config._codex.autoSync` flag (the plugin never sees + * it). When `true`, the connection is synced automatically on the plugin's + * admin-configured cron; when `false` (the default) syncs run only on demand. + * Only allowed for plugins whose manifest declares the `user_read_sync` + * capability. + */ + patch: operations["set_sync_mode"]; + trace?: never; + }; "/api/v1/user/plugins/{plugin_id}/sync/status": { parameters: { query?: never; @@ -15103,6 +15127,12 @@ export interface components { searchPreprocessingRules?: unknown; /** @description Handlebars template for customizing search queries */ searchQueryTemplate?: string | null; + /** + * @description Admin-managed cron schedule for automatic user-plugin syncs + * (null = no scheduled sync) + * @example 0 0 *\/6 * * * + */ + syncCronSchedule?: string | null; /** * Format: date-time * @description When the plugin was last updated @@ -17799,6 +17829,14 @@ export interface components { */ tags: string[]; }; + /** @description Request to set a connection's automatic-sync preference (manual vs auto). */ + SetSyncModeRequest: { + /** + * @description `true` opts the connection into scheduled syncs on the plugin's + * admin-configured cadence; `false` is manual-only (the default). + */ + auto: boolean; + }; /** @description Request to set user credentials (e.g., personal access token) */ SetUserCredentialsRequest: { /** @description The access token or API key to store */ @@ -19267,6 +19305,13 @@ export interface components { searchPreprocessingRules?: unknown; /** @description Handlebars template for customizing search queries (null = clear template) */ searchQueryTemplate?: unknown; + /** + * @description Admin-managed cron schedule for automatic user-plugin syncs. + * Omit to leave unchanged; `null` clears it (no scheduled sync); a string + * sets/normalizes it. Only allowed on plugins whose manifest declares the + * `user_read_sync` capability. + */ + syncCronSchedule?: unknown; /** @description Whether to skip search when external ID exists for this plugin */ useExistingExternalId?: boolean | null; /** @description Updated working directory */ @@ -19546,6 +19591,12 @@ export interface components { }; /** @description User plugin instance status */ UserPluginDto: { + /** + * @description Whether this connection is opted into automatic scheduled syncs + * (host-side preference, derived from `config._codex.autoSync`). When + * false (the default), syncs run only when manually triggered. + */ + autoSync: boolean; /** @description Plugin capabilities (derived from manifest) */ capabilities: components["schemas"]["UserPluginCapabilitiesDto"]; /** @description Per-user configuration */ @@ -31410,6 +31461,54 @@ export interface operations { }; }; }; + set_sync_mode: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Plugin ID to set sync mode for */ + plugin_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetSyncModeRequest"]; + }; + }; + responses: { + /** @description Sync mode updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPluginDto"]; + }; + }; + /** @description Plugin does not support sync */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Plugin not enabled for this user */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; get_sync_status: { parameters: { query?: { From 78de94f8460c5875bae1593e891c24c3f7f12a35 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 17:14:42 -0700 Subject: [PATCH 04/27] feat(plugins): UI for admin sync cron and per-user auto/manual sync Surface the scheduled-sync controls in the web app: - Admin: a "Sync Schedule (cron)" input on the plugin edit form, shown only for plugins whose manifest declares the user_read_sync capability. It initializes from the plugin and is sent only for sync-capable plugins (a string sets the cadence, empty clears it). - User: an "Automatic sync" switch on the connection card, shown only for connected, sync-capable integrations. Toggling it calls the sync-mode endpoint and reflects the connection's autoSync state. Adds userPluginsApi.setSyncMode and wires it through a mutation with cache invalidation and toasts. Includes component tests covering the switch's visibility (capability gating), checked state, and toggle callback. --- web/src/api/userPlugins.ts | 17 ++++++ .../plugins/UserPluginCard.test.tsx | 54 +++++++++++++++++++ web/src/components/plugins/UserPluginCard.tsx | 24 +++++++++ .../pages/settings/IntegrationsSettings.tsx | 32 +++++++++++ web/src/pages/settings/PluginsSettings.tsx | 9 ++++ web/src/pages/settings/plugins/PluginForm.tsx | 13 +++++ 6 files changed, 149 insertions(+) diff --git a/web/src/api/userPlugins.ts b/web/src/api/userPlugins.ts index 4a03eb66..7e452ce3 100644 --- a/web/src/api/userPlugins.ts +++ b/web/src/api/userPlugins.ts @@ -15,6 +15,7 @@ export type OAuthStartResponse = components["schemas"]["OAuthStartResponse"]; export type UpdateUserPluginConfigRequest = components["schemas"]["UpdateUserPluginConfigRequest"]; export type SyncTriggerResponse = components["schemas"]["SyncTriggerResponse"]; +export type SetSyncModeRequest = components["schemas"]["SetSyncModeRequest"]; export type SyncStatusDto = components["schemas"]["SyncStatusDto"]; export type ConfigSchemaDto = components["schemas"]["ConfigSchemaDto"]; export type UserPluginTaskDto = components["schemas"]["UserPluginTaskDto"]; @@ -105,6 +106,22 @@ export const userPluginsApi = { return response.data; }, + /** + * Set the automatic-sync preference (manual vs auto) for a connection. + * `auto: true` opts into scheduled syncs on the plugin's admin-configured + * cadence; `false` is manual-only. Only valid for sync-capable plugins. + */ + setSyncMode: async ( + pluginId: string, + auto: boolean, + ): Promise => { + const response = await api.patch( + `/user/plugins/${pluginId}/sync-mode`, + { auto } satisfies SetSyncModeRequest, + ); + return response.data; + }, + /** * Get sync status for a plugin * Pass live=true to query the plugin process for real-time counts (more expensive) diff --git a/web/src/components/plugins/UserPluginCard.test.tsx b/web/src/components/plugins/UserPluginCard.test.tsx index b636ce92..78df8f12 100644 --- a/web/src/components/plugins/UserPluginCard.test.tsx +++ b/web/src/components/plugins/UserPluginCard.test.tsx @@ -29,6 +29,7 @@ const connectedPlugin: UserPluginDto = { oauthConfigured: true, description: "Sync your reading progress with AniList", config: {}, + autoSync: false, capabilities: { readSync: true, userRecommendationProvider: false }, createdAt: new Date().toISOString(), } as UserPluginDto; @@ -169,6 +170,59 @@ describe("ConnectedPluginCard", () => { expect(onDisconnect).toHaveBeenCalledWith("plugin-1"); }); + it("shows the automatic-sync switch for a connected sync plugin", () => { + renderWithProviders( + , + ); + expect( + screen.getByRole("switch", { name: /automatic sync/i }), + ).toBeInTheDocument(); + }); + + it("does not show the automatic-sync switch for a non-sync plugin", () => { + renderWithProviders( + , + ); + expect( + screen.queryByRole("switch", { name: /automatic sync/i }), + ).not.toBeInTheDocument(); + }); + + it("does not show the automatic-sync switch without an onSetSyncMode handler", () => { + renderWithProviders(); + expect( + screen.queryByRole("switch", { name: /automatic sync/i }), + ).not.toBeInTheDocument(); + }); + + it("reflects the autoSync state on the switch", () => { + renderWithProviders( + , + ); + expect( + screen.getByRole("switch", { name: /automatic sync/i }), + ).toBeChecked(); + }); + + it("calls onSetSyncMode when the switch is toggled on", async () => { + const user = userEvent.setup(); + const onSetSyncMode = vi.fn(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("switch", { name: /automatic sync/i })); + expect(onSetSyncMode).toHaveBeenCalledWith("plugin-1", true); + }); + it("shows Sync Now button when connected with onSync handler", () => { const onSync = vi.fn(); renderWithProviders( diff --git a/web/src/components/plugins/UserPluginCard.tsx b/web/src/components/plugins/UserPluginCard.tsx index b4218f69..e3b18617 100644 --- a/web/src/components/plugins/UserPluginCard.tsx +++ b/web/src/components/plugins/UserPluginCard.tsx @@ -8,6 +8,7 @@ import { Divider, Group, PasswordInput, + Switch, Text, Tooltip, } from "@mantine/core"; @@ -109,12 +110,14 @@ interface ConnectedPluginCardProps { onConnect: (pluginId: string) => void; onSaveToken?: (pluginId: string, token: string) => void; onRefreshStatus?: (pluginId: string) => void; + onSetSyncMode?: (pluginId: string, auto: boolean) => void; syncStatus?: SyncStatusDto | null; disconnecting?: boolean; disabling?: boolean; syncing?: boolean; savingToken?: boolean; refreshingStatus?: boolean; + settingSyncMode?: boolean; } export function ConnectedPluginCard({ @@ -126,12 +129,14 @@ export function ConnectedPluginCard({ onConnect, onSaveToken, onRefreshStatus, + onSetSyncMode, syncStatus, disconnecting, disabling, syncing, savingToken, refreshingStatus, + settingSyncMode, }: ConnectedPluginCardProps) { const health = healthBadge(plugin.healthStatus); const [tokenValue, setTokenValue] = useState(""); @@ -268,6 +273,25 @@ export function ConnectedPluginCard({ )} + {plugin.connected && isSyncPlugin && onSetSyncMode && ( + +
+ Automatic sync + + Sync on the schedule set by your administrator + +
+ + onSetSyncMode(plugin.pluginId, e.currentTarget.checked) + } + disabled={settingSyncMode} + aria-label="Automatic sync" + /> +
+ )} + {plugin.connected && isRecommendationPlugin && ( diff --git a/web/src/pages/settings/IntegrationsSettings.tsx b/web/src/pages/settings/IntegrationsSettings.tsx index 905ab97f..d3faa8e7 100644 --- a/web/src/pages/settings/IntegrationsSettings.tsx +++ b/web/src/pages/settings/IntegrationsSettings.tsx @@ -222,6 +222,29 @@ export function IntegrationsSettings() { }, }); + // Auto/manual sync-mode mutation + const setSyncModeMutation = useMutation({ + mutationFn: ({ pluginId, auto }: { pluginId: string; auto: boolean }) => + userPluginsApi.setSyncMode(pluginId, auto), + onSuccess: (_data, { auto }) => { + queryClient.invalidateQueries({ queryKey: ["user-plugins"] }); + notifications.show({ + title: auto ? "Automatic sync enabled" : "Automatic sync disabled", + message: auto + ? "This integration will sync on your administrator's schedule." + : "This integration will only sync when you trigger it manually.", + color: "green", + }); + }, + onError: (error: Error) => { + notifications.show({ + title: "Error", + message: error.message || "Failed to update sync mode", + color: "red", + }); + }, + }); + // Sync mutation const syncMutation = useMutation({ mutationFn: (pluginId: string) => userPluginsApi.triggerSync(pluginId), @@ -315,6 +338,10 @@ export function IntegrationsSettings() { setSettingsTarget(pluginId); }; + const handleSetSyncMode = (pluginId: string, auto: boolean) => { + setSyncModeMutation.mutate({ pluginId, auto }); + }; + const handleRefreshStatus = (pluginId: string) => { setLiveStatusPluginId(pluginId); queryClient.invalidateQueries({ queryKey: ["sync-status", pluginId] }); @@ -380,6 +407,7 @@ export function IntegrationsSettings() { onSync={handleSync} onSettings={handleSettings} onRefreshStatus={handleRefreshStatus} + onSetSyncMode={handleSetSyncMode} syncStatus={ liveStatusPluginId === plugin.pluginId ? (liveStatus ?? null) @@ -406,6 +434,10 @@ export function IntegrationsSettings() { refreshingStatus={ fetchingLiveStatus && liveStatusPluginId === plugin.pluginId } + settingSyncMode={ + setSyncModeMutation.isPending && + setSyncModeMutation.variables?.pluginId === plugin.pluginId + } /> ))} diff --git a/web/src/pages/settings/PluginsSettings.tsx b/web/src/pages/settings/PluginsSettings.tsx index 251bfded..1198329d 100644 --- a/web/src/pages/settings/PluginsSettings.tsx +++ b/web/src/pages/settings/PluginsSettings.tsx @@ -185,6 +185,11 @@ export function PluginsSettings() { id: string; values: PluginFormValues; }) => { + // Only send the sync schedule for sync-capable plugins; the field is + // hidden otherwise, and the backend rejects a cron on plugins without + // the user_read_sync capability. Empty string clears any existing cron. + const isSyncCapable = + selectedPlugin?.manifest?.capabilities?.userReadSync === true; return pluginsApi.update(id, { displayName: values.displayName.trim(), description: values.description.trim() || null, @@ -208,6 +213,9 @@ export function PluginsSettings() { requestTimeoutSeconds: values.requestTimeoutOverrideEnabled ? values.requestTimeoutSeconds : null, + ...(isSyncCapable + ? { syncCronSchedule: values.syncCronSchedule.trim() || null } + : {}), }); }, onSuccess: () => { @@ -393,6 +401,7 @@ export function PluginsSettings() { rateLimitRequestsPerMinute: plugin.rateLimitRequestsPerMinute ?? 60, requestTimeoutOverrideEnabled: plugin.requestTimeoutSeconds != null, requestTimeoutSeconds: plugin.requestTimeoutSeconds ?? 30, + syncCronSchedule: plugin.syncCronSchedule ?? "", }); openEditModal(); }; diff --git a/web/src/pages/settings/plugins/PluginForm.tsx b/web/src/pages/settings/plugins/PluginForm.tsx index 0eafffd5..e9859caf 100644 --- a/web/src/pages/settings/plugins/PluginForm.tsx +++ b/web/src/pages/settings/plugins/PluginForm.tsx @@ -47,6 +47,7 @@ export interface PluginFormValues { rateLimitRequestsPerMinute: number; requestTimeoutOverrideEnabled: boolean; requestTimeoutSeconds: number; + syncCronSchedule: string; } export const defaultFormValues: PluginFormValues = { @@ -65,6 +66,7 @@ export const defaultFormValues: PluginFormValues = { rateLimitRequestsPerMinute: 60, requestTimeoutOverrideEnabled: false, requestTimeoutSeconds: 30, + syncCronSchedule: "", }; // Normalize plugin name to slug format (lowercase alphanumeric with hyphens) @@ -361,6 +363,17 @@ export function PluginForm({ {...form.getInputProps("requestTimeoutSeconds")} /> )} + {manifest?.capabilities?.userReadSync && ( + <> + + + + )} From 007d98edf53a80628886399aaa95da043dd6fdab Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 18:06:38 -0700 Subject: [PATCH 05/27] docs(plugins): document scheduled automatic sync Document the admin-managed sync cadence and the per-user auto/manual opt-in: - Plugins overview gains a "Scheduled (Automatic) Sync" section covering how an admin sets a per-plugin cron on the Execution tab, how users opt their connection in (off by default), and what happens when the schedule fires (enabled + connected + opted-in connections only, deduped, expired credentials skipped). Adds autoSync to the _codex settings reference. - AniList Sync page gains a "Manual vs Automatic Sync" subsection that points at the overview. --- docs/docs/plugins/anilist-sync.md | 6 ++++++ docs/docs/plugins/index.md | 28 +++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/docs/plugins/anilist-sync.md b/docs/docs/plugins/anilist-sync.md index ac4da4b4..a36fc052 100644 --- a/docs/docs/plugins/anilist-sync.md +++ b/docs/docs/plugins/anilist-sync.md @@ -59,6 +59,12 @@ Configure the sync direction in **Settings** > **Integrations** > **Settings**: | **Pull Only** | Import from AniList without writing back | | **Push Only** | Export to AniList without importing | +### Manual vs Automatic Sync + +By default, sync only runs when you click **Sync Now**. If your administrator has set a sync schedule for the plugin, you'll see an **Automatic sync** switch on your connection card in **Settings → Integrations**. Turn it on to have Codex sync your AniList progress automatically on that schedule; leave it off to keep syncing manually. + +The switch is off by default and only appears once an admin has configured a schedule. Automatic runs use the same sync direction and settings as a manual sync. See [Scheduled (Automatic) Sync](./index.md#scheduled-automatic-sync) for the full details. + ### Sync Flow When a sync runs in **Pull & Push** mode, it executes two phases in order: diff --git a/docs/docs/plugins/index.md b/docs/docs/plugins/index.md index a857a4e3..b4c33a53 100644 --- a/docs/docs/plugins/index.md +++ b/docs/docs/plugins/index.md @@ -86,7 +86,8 @@ The settings are stored in the user's plugin config under the `_codex` namespace "includeCompleted": true, "includeInProgress": true, "countPartialProgress": false, - "syncRatings": true + "syncRatings": true, + "autoSync": false } } ``` @@ -97,9 +98,34 @@ The settings are stored in the user's plugin config under the `_codex` namespace | `includeInProgress` | `true` | Include series where at least one book has been started | | `countPartialProgress` | `false` | Count partially-read books in the progress count | | `syncRatings` | `true` | Include scores and notes in push/pull operations | +| `autoSync` | `false` | Opt this connection into scheduled (automatic) syncs — see below | These are **server-interpreted** — the server reads them to filter and build sync entries. Plugins never read `_codex` keys. Plugin-specific settings (like `progressUnit` for AniList) live in the plugin's own `userConfigSchema` and are only read by the plugin. +## Scheduled (Automatic) Sync + +By default a sync only runs when a user clicks **Sync Now**. Codex can also run syncs automatically on a schedule, which is controlled in two places: the **admin** sets the cadence per plugin, and each **user** opts their own connection in. + +### Admin: set the cadence (per plugin) + +In **Settings → Plugins**, edit a sync-capable plugin and set the **Sync Schedule (cron)** field on the **Execution** tab. It accepts a standard cron expression (5-field Unix or 6-field with seconds); for example `0 0 */6 * * *` runs every six hours. + +- The field only appears for plugins whose manifest declares the `user_read_sync` capability. +- Leave it empty to disable scheduled syncs entirely (users can still sync manually). +- Changes take effect immediately — no server restart needed. + +The cadence is a property of the integration (its rate limits, what's polite to the external service), which is why it is admin-managed and shared by all users of that plugin rather than configured per user. + +### User: opt in (per connection) + +Once an admin has set a schedule, each user can flip the **Automatic sync** switch on their connection card in **Settings → Integrations**. It is **off by default** — existing and new connections are manual-only until the user opts in, so Codex never makes calls to an external service on a user's behalf without consent. + +### When the schedule fires + +On each cron tick, Codex enqueues a sync for every connection of that plugin that is **enabled**, **authenticated/connected**, and has **Automatic sync** turned on. Connections that are disabled, disconnected, or still manual are skipped. The scheduled run respects the user's chosen [sync direction](./anilist-sync.md#sync-modes) and all [Codex Sync Settings](#codex-sync-settings) — it is the same operation as **Sync Now**, just triggered by the scheduler. + +A connection that already has a sync in progress is skipped for that tick, so a slow plugin never stacks duplicate jobs. If a connection's credentials have expired, it is skipped for that run and the user can reconnect. + ## Security Model Codex applies multiple layers of security to ensure plugins operate safely and user data is protected. From 6b0cbaa803e52b71a89a9c3211941cdb08503ab0 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 19:02:11 -0700 Subject: [PATCH 06/27] refactor(plugins): extract shared per-series engagement builder The "fetch library data + aggregate per-series reading progress" logic was copy-pasted across build_user_library (recommendations) and build_push_entries/build_unmatched_entries (sync), each re-fetching books, progress, metadata, and ratings and folding them the same way. Collapse it into a single batched build_series_engagements in codex-services: one pass fetches series metadata, books, the user's reading progress and ratings, library names, and optionally taxonomy, then folds book-level progress into a per-series SeriesEngagement. Each caller projects that aggregate into its own wire DTO; the sync path adds a shared project_sync_entry and drops the now-dead series_library_info. An EngagementOptions::include_taxonomy flag lets the sync path skip the genres/tags/alternate-title/external-id queries it doesn't need, keeping its query count unchanged. No behaviour change; existing sync push and recommendation tests pass unmodified, with new builder unit tests added. --- crates/codex-services/src/plugin/library.rs | 432 +++++++++--- .../src/handlers/user_plugin_sync/push.rs | 619 +++++------------- 2 files changed, 493 insertions(+), 558 deletions(-) diff --git a/crates/codex-services/src/plugin/library.rs b/crates/codex-services/src/plugin/library.rs index feb1d394..14dac9b0 100644 --- a/crates/codex-services/src/plugin/library.rs +++ b/crates/codex-services/src/plugin/library.rs @@ -1,16 +1,24 @@ //! User Library Builder //! -//! Builds `Vec` from a user's Codex library data for -//! sending to recommendation plugins. Uses batch queries for efficiency. +//! Builds the data sent to user plugins from a user's Codex library. +//! +//! [`build_series_engagements`] is the shared, batched data-gathering layer: for +//! a set of series it fetches metadata, reading progress, ratings, and +//! (optionally) taxonomy in one pass and folds book-level progress into a +//! per-series [`SeriesEngagement`]. Callers project that aggregate into their own +//! wire DTO: +//! - [`build_user_library`] → `Vec` for recommendation plugins. +//! - the sync push builders → `Vec` for sync plugins. use anyhow::Result; +use chrono::{DateTime, Utc}; use sea_orm::DatabaseConnection; use std::collections::HashMap; use tracing::{debug, warn}; use uuid::Uuid; use crate::plugin::protocol::{UserLibraryEntry, UserLibraryExternalId, UserReadingStatus}; -use codex_db::entities::SeriesStatus; +use codex_db::entities::{SeriesStatus, series, series_metadata}; use codex_db::repositories::{ AlternateTitleRepository, BookRepository, GenreRepository, LibraryRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, @@ -30,60 +38,107 @@ pub async fn library_names(db: &DatabaseConnection, library_ids: &[Uuid]) -> Has } } -/// Build the user library as `Vec` for recommendation plugins. +/// Controls how much library data [`build_series_engagements`] fetches. /// -/// Fetches series, metadata, genres, tags, external IDs, reading progress, -/// and user ratings in batch, then assembles them into library entries. +/// The progress aggregate, series metadata, ratings, and library context are +/// always fetched (every caller needs them). Taxonomy is optional: recommendation +/// library building always wants it, while sync push only needs it when it is +/// going to send full metadata — so the sync path can leave it off to avoid four +/// extra batch queries per run. +#[derive(Debug, Clone, Copy, Default)] +pub struct EngagementOptions { + /// Also fetch genres, tags, alternate titles, and external IDs. + pub include_taxonomy: bool, +} + +/// Per-series aggregate of a user's engagement, plus the library data needed to +/// project it into a protocol DTO. Built in batch by [`build_series_engagements`]. /// -/// Only series in a library the plugin is scoped to are included. -/// `allowed_library_ids` empty means "all libraries". -pub async fn build_user_library( +/// The reading-progress fields are folded from the user's owned books in the +/// series. `metadata`/taxonomy carry the source data callers map into their DTO. +#[derive(Debug, Clone)] +pub struct SeriesEngagement { + pub series_id: Uuid, + pub library_id: Uuid, + pub library_name: String, + /// Title with the series-name fallback applied (`metadata.title` or + /// `series.name`). Callers that want a metadata-only title (no fallback) + /// should read [`Self::metadata`] directly instead. + pub title: String, + /// Series metadata row, when present. + pub metadata: Option, + + /// Number of books the user owns in this series. + pub books_owned: i32, + /// Books the user has completed. + pub books_read: i32, + /// Books with reading progress that are not yet complete. + pub in_progress_count: i32, + /// Earliest `started_at` across books with progress. + pub earliest_started: Option>, + /// Latest `updated_at` across books with progress. + pub latest_read_at: Option>, + /// Latest `completed_at` across completed books. + pub latest_completed_at: Option>, + + /// Genres — populated only when [`EngagementOptions::include_taxonomy`]. + pub genres: Vec, + /// Tags — populated only when [`EngagementOptions::include_taxonomy`]. + pub tags: Vec, + /// Alternate titles — populated only when [`EngagementOptions::include_taxonomy`]. + pub alternate_titles: Vec, + /// External IDs — populated only when [`EngagementOptions::include_taxonomy`]. + pub external_ids: Vec, + + /// User's personal rating (1-100 scale), when set. + pub user_rating: Option, + /// User's personal notes, when set. + pub user_notes: Option, +} + +impl SeriesEngagement { + /// Whether the user has any reading progress (complete or in-progress) for + /// this series. + pub fn has_any_progress(&self) -> bool { + self.books_read > 0 || self.in_progress_count > 0 + } +} + +/// Build per-series [`SeriesEngagement`] aggregates for the given series in one +/// batched pass. +/// +/// Fetches series metadata, books, the user's reading progress and ratings, and +/// library names; optionally genres/tags/alternate-titles/external-IDs (see +/// [`EngagementOptions`]). Book-level progress is folded into per-series counts +/// and timestamps. The caller decides which series to pass and how to project +/// the result. +pub async fn build_series_engagements( db: &DatabaseConnection, user_id: Uuid, - allowed_library_ids: &[Uuid], -) -> Result> { - // 1. Get all series, then drop any outside the plugin's library scope. - let mut all_series = SeriesRepository::list_all(db, None).await?; - if !allowed_library_ids.is_empty() { - all_series.retain(|s| allowed_library_ids.contains(&s.library_id)); - } - if all_series.is_empty() { - return Ok(vec![]); + series: &[series::Model], + opts: EngagementOptions, +) -> Result> { + if series.is_empty() { + return Ok(HashMap::new()); } - let series_ids: Vec = all_series.iter().map(|s| s.id).collect(); + let series_ids: Vec = series.iter().map(|s| s.id).collect(); - // Resolve library names so each entry can carry its library context. + // Resolve library names so each engagement can carry its library context. let library_ids: Vec = { - let mut ids: Vec = all_series.iter().map(|s| s.library_id).collect(); + let mut ids: Vec = series.iter().map(|s| s.library_id).collect(); ids.sort_unstable(); ids.dedup(); ids }; let lib_names = library_names(db, &library_ids).await; - // 2. Batch-fetch all related data + // Always needed: metadata (titles/totals/summary), books, progress, ratings. let metadata_map = SeriesMetadataRepository::get_by_series_ids(db, &series_ids).await?; - let genres_map = GenreRepository::get_genres_for_series_ids(db, &series_ids).await?; - let tags_map = TagRepository::get_tags_for_series_ids(db, &series_ids).await?; - let ext_ids_map = SeriesExternalIdRepository::get_for_series_ids(db, &series_ids).await?; - let alt_titles_map = AlternateTitleRepository::get_for_series_ids(db, &series_ids).await?; - - // 3. Batch-fetch all books and reading progress - let all_books = BookRepository::list_by_series_ids(db, &series_ids).await?; - let mut books_by_series: HashMap> = HashMap::new(); - for book in &all_books { - books_by_series - .entry(book.series_id) - .or_default() - .push(book.id); - } - - let all_progress = ReadProgressRepository::get_by_user(db, user_id).await?; - let progress_by_book: HashMap = - all_progress.into_iter().map(|p| (p.book_id, p)).collect(); - - // 4. Batch-fetch user ratings + let books_map = BookRepository::get_by_series_ids(db, &series_ids).await?; + let all_book_ids: Vec = books_map.values().flatten().map(|b| b.id).collect(); + let progress_map = + ReadProgressRepository::get_for_user_books(db, user_id, &all_book_ids).await?; let ratings_map: HashMap = match UserSeriesRatingRepository::get_all_for_user(db, user_id).await { Ok(ratings) => ratings.into_iter().map(|r| (r.series_id, r)).collect(), @@ -93,27 +148,37 @@ pub async fn build_user_library( } }; - // 5. Build entries - let mut entries = Vec::new(); + // Optional taxonomy — only fetched when the caller will use it. + let (genres_map, tags_map, alt_titles_map, ext_ids_map) = if opts.include_taxonomy { + ( + GenreRepository::get_genres_for_series_ids(db, &series_ids).await?, + TagRepository::get_tags_for_series_ids(db, &series_ids).await?, + AlternateTitleRepository::get_for_series_ids(db, &series_ids).await?, + SeriesExternalIdRepository::get_for_series_ids(db, &series_ids).await?, + ) + } else { + Default::default() + }; - for series in &all_series { - let meta = metadata_map.get(&series.id); + let mut engagements = HashMap::with_capacity(series.len()); + for s in series { + let meta = metadata_map.get(&s.id); let title = meta .map(|m| m.title.clone()) - .unwrap_or_else(|| series.name.clone()); + .unwrap_or_else(|| s.name.clone()); - let book_ids = books_by_series.get(&series.id); - let books_owned = book_ids.map(|b| b.len() as i32).unwrap_or(0); + let books = books_map.get(&s.id); + let books_owned = books.map(|b| b.len() as i32).unwrap_or(0); - // Aggregate reading progress let mut books_read = 0i32; - let mut earliest_started: Option> = None; - let mut latest_read_at: Option> = None; - let mut latest_completed_at: Option> = None; + let mut in_progress_count = 0i32; + let mut earliest_started: Option> = None; + let mut latest_read_at: Option> = None; + let mut latest_completed_at: Option> = None; - if let Some(book_ids) = book_ids { - for book_id in book_ids { - if let Some(progress) = progress_by_book.get(book_id) { + if let Some(books) = books { + for book in books { + if let Some(progress) = progress_map.get(&book.id) { if progress.completed { books_read += 1; if let Some(cat) = progress.completed_at { @@ -123,6 +188,8 @@ pub async fn build_user_library( None => cat, }); } + } else { + in_progress_count += 1; } earliest_started = Some(match earliest_started { Some(existing) if progress.started_at < existing => progress.started_at, @@ -138,29 +205,20 @@ pub async fn build_user_library( } } - // Derive reading status - let reading_status = if books_read == 0 { - Some(UserReadingStatus::Unread) - } else if books_read >= books_owned && books_owned > 0 { - Some(UserReadingStatus::Completed) - } else { - Some(UserReadingStatus::Reading) - }; - - // Genres and tags as string names let genres = genres_map - .get(&series.id) + .get(&s.id) .map(|gs| gs.iter().map(|g| g.name.clone()).collect()) .unwrap_or_default(); - let tags = tags_map - .get(&series.id) + .get(&s.id) .map(|ts| ts.iter().map(|t| t.name.clone()).collect()) .unwrap_or_default(); - - // External IDs + let alternate_titles = alt_titles_map + .get(&s.id) + .map(|alts| alts.iter().map(|a| a.title.clone()).collect()) + .unwrap_or_default(); let external_ids = ext_ids_map - .get(&series.id) + .get(&s.id) .map(|eids| { eids.iter() .map(|e| UserLibraryExternalId { @@ -172,46 +230,110 @@ pub async fn build_user_library( }) .unwrap_or_default(); - // Alternate titles - let alternate_titles = alt_titles_map - .get(&series.id) - .map(|alts| alts.iter().map(|a| a.title.clone()).collect()) - .unwrap_or_default(); - - // User rating/notes - let (user_rating, user_notes) = match ratings_map.get(&series.id) { + let (user_rating, user_notes) = match ratings_map.get(&s.id) { Some(r) => (Some(r.rating), r.notes.clone()), None => (None, None), }; + engagements.insert( + s.id, + SeriesEngagement { + series_id: s.id, + library_id: s.library_id, + library_name: lib_names.get(&s.library_id).cloned().unwrap_or_default(), + title, + metadata: meta.cloned(), + books_owned, + books_read, + in_progress_count, + earliest_started, + latest_read_at, + latest_completed_at, + genres, + tags, + alternate_titles, + external_ids, + user_rating, + user_notes, + }, + ); + } + + Ok(engagements) +} + +/// Build the user library as `Vec` for recommendation plugins. +/// +/// Assembles every series in scope (with full taxonomy) via +/// [`build_series_engagements`] and projects each into a `UserLibraryEntry`. +/// +/// Only series in a library the plugin is scoped to are included. +/// `allowed_library_ids` empty means "all libraries". +pub async fn build_user_library( + db: &DatabaseConnection, + user_id: Uuid, + allowed_library_ids: &[Uuid], +) -> Result> { + // Get all series, then drop any outside the plugin's library scope. + let mut all_series = SeriesRepository::list_all(db, None).await?; + if !allowed_library_ids.is_empty() { + all_series.retain(|s| allowed_library_ids.contains(&s.library_id)); + } + if all_series.is_empty() { + return Ok(vec![]); + } + + let engagements = build_series_engagements( + db, + user_id, + &all_series, + EngagementOptions { + include_taxonomy: true, + }, + ) + .await?; + + let mut entries = Vec::with_capacity(all_series.len()); + for series in &all_series { + let Some(e) = engagements.get(&series.id) else { + continue; + }; + + // Derive reading status from the aggregate. + let reading_status = if e.books_read == 0 { + Some(UserReadingStatus::Unread) + } else if e.books_read >= e.books_owned && e.books_owned > 0 { + Some(UserReadingStatus::Completed) + } else { + Some(UserReadingStatus::Reading) + }; + + let meta = e.metadata.as_ref(); entries.push(UserLibraryEntry { - series_id: series.id.to_string(), - library_id: series.library_id.to_string(), - library_name: lib_names - .get(&series.library_id) - .cloned() - .unwrap_or_default(), - title, - alternate_titles, + series_id: e.series_id.to_string(), + library_id: e.library_id.to_string(), + library_name: e.library_name.clone(), + title: e.title.clone(), + alternate_titles: e.alternate_titles.clone(), year: meta.and_then(|m| m.year), status: meta.and_then(|m| { m.status .as_deref() .and_then(|s| s.parse::().ok()) }), - genres, - tags, + genres: e.genres.clone(), + tags: e.tags.clone(), total_volume_count: meta.and_then(|m| m.total_volume_count), total_chapter_count: meta.and_then(|m| m.total_chapter_count), - external_ids, + external_ids: e.external_ids.clone(), reading_status, - books_read, - books_owned, - user_rating, - user_notes, - started_at: earliest_started.map(|dt| dt.to_rfc3339()), - last_read_at: latest_read_at.map(|dt| dt.to_rfc3339()), - completed_at: latest_completed_at.map(|dt| dt.to_rfc3339()), + books_read: e.books_read, + books_owned: e.books_owned, + user_rating: e.user_rating, + user_notes: e.user_notes.clone(), + started_at: e.earliest_started.map(|dt| dt.to_rfc3339()), + last_read_at: e.latest_read_at.map(|dt| dt.to_rfc3339()), + completed_at: e.latest_completed_at.map(|dt| dt.to_rfc3339()), }); } @@ -228,9 +350,63 @@ pub async fn build_user_library( mod tests { use super::*; use codex_db::ScanningStrategy; - use codex_db::repositories::{LibraryRepository, SeriesRepository}; + use codex_db::entities::{books, users}; + use codex_db::repositories::{ + BookRepository, LibraryRepository, ReadProgressRepository, SeriesRepository, UserRepository, + }; use codex_db::test_helpers::create_test_db; + /// Insert a minimal user row so reading-progress FKs are satisfied. + async fn create_user(db: &DatabaseConnection) -> Uuid { + let user = users::Model { + id: Uuid::new_v4(), + username: format!("u_{}", Uuid::new_v4()), + email: format!("{}@example.com", Uuid::new_v4()), + password_hash: "x".to_string(), + role: "user".to_string(), + is_active: true, + email_verified: false, + permissions: serde_json::json!([]), + created_at: Utc::now(), + updated_at: Utc::now(), + last_login_at: None, + }; + UserRepository::create(db, &user).await.unwrap().id + } + + /// Insert a minimal book row in `series` for tests. + async fn create_book( + db: &DatabaseConnection, + series_id: Uuid, + library_id: Uuid, + ) -> books::Model { + let book = books::Model { + id: Uuid::new_v4(), + series_id, + library_id, + path: format!("/test/book_{}.cbz", Uuid::new_v4()), + file_name: "book.cbz".to_string(), + file_size: 1024, + file_hash: format!("hash_{}", Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count: 50, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: None, + epub_positions: None, + epub_spine_items: None, + }; + BookRepository::create(db, &book, None).await.unwrap() + } + #[tokio::test] async fn test_build_user_library_respects_library_scope_and_stamps_info() { let (db, _temp_dir) = create_test_db().await; @@ -263,4 +439,62 @@ mod tests { let all = build_user_library(conn, user_id, &[]).await.unwrap(); assert_eq!(all.len(), 2); } + + #[tokio::test] + async fn test_build_series_engagements_aggregates_progress() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + let user_id = create_user(conn).await; + + let lib = LibraryRepository::create(conn, "Lib", "/l", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, lib.id, "Engaged Series", None) + .await + .unwrap(); + + // Three books: one completed, one in progress, one untouched. + let done = create_book(conn, series.id, lib.id).await; + let reading = create_book(conn, series.id, lib.id).await; + let _untouched = create_book(conn, series.id, lib.id).await; + ReadProgressRepository::upsert(conn, user_id, done.id, 50, true) + .await + .unwrap(); + ReadProgressRepository::upsert(conn, user_id, reading.id, 10, false) + .await + .unwrap(); + + let engagements = build_series_engagements( + conn, + user_id, + &[series.clone()], + EngagementOptions::default(), + ) + .await + .unwrap(); + + let e = engagements.get(&series.id).expect("engagement present"); + assert_eq!(e.books_owned, 3); + assert_eq!(e.books_read, 1); + assert_eq!(e.in_progress_count, 1); + assert!(e.has_any_progress()); + assert!(e.earliest_started.is_some()); + assert!(e.latest_read_at.is_some()); + assert!(e.latest_completed_at.is_some()); + assert_eq!(e.library_name, "Lib"); + // Taxonomy not requested → empty. + assert!(e.genres.is_empty()); + assert!(e.external_ids.is_empty()); + } + + #[tokio::test] + async fn test_build_series_engagements_empty_input() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + let engagements = + build_series_engagements(conn, Uuid::new_v4(), &[], EngagementOptions::default()) + .await + .unwrap(); + assert!(engagements.is_empty()); + } } diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs index 439a7b40..75c6cbd7 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs @@ -2,52 +2,136 @@ //! external services. use sea_orm::DatabaseConnection; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use tracing::{debug, warn}; use uuid::Uuid; +use codex_db::entities::series; use codex_db::repositories::{ - BookRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, - SeriesRepository, UserSeriesRatingRepository, + BookRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesRepository, +}; +use codex_services::plugin::library::{ + EngagementOptions, SeriesEngagement, build_series_engagements, }; -use codex_services::plugin::library::library_names; use codex_services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; use super::settings::CodexSyncSettings; -/// Resolve `series_id -> (library_id, library_name)` for the given series so -/// push entries can carry their library context and be filtered by library -/// scope. Degrades to an empty map on failure; entries then fall back to empty -/// library fields. -async fn series_library_info( +/// Whether a series in `library_id` is in scope for a plugin allowed to act on +/// `allowed_library_ids`. An empty allowed set means "all libraries". +fn library_in_scope(allowed_library_ids: &[Uuid], library_id: Uuid) -> bool { + allowed_library_ids.is_empty() || allowed_library_ids.contains(&library_id) +} + +/// Fetch the series rows for `series_ids` and drop any outside the plugin's +/// library scope. Degrades to an empty Vec on lookup failure. +async fn scoped_series( db: &DatabaseConnection, series_ids: &[Uuid], -) -> HashMap { - let series = SeriesRepository::get_by_ids(db, series_ids) + allowed_library_ids: &[Uuid], +) -> Vec { + SeriesRepository::get_by_ids(db, series_ids) .await - .unwrap_or_default(); - - let library_ids: Vec = { - let mut ids: Vec = series.iter().map(|s| s.library_id).collect(); - ids.sort_unstable(); - ids.dedup(); - ids - }; - let names = library_names(db, &library_ids).await; - - series + .unwrap_or_default() .into_iter() - .map(|s| { - let name = names.get(&s.library_id).cloned().unwrap_or_default(); - (s.id, (s.library_id, name)) - }) + .filter(|s| library_in_scope(allowed_library_ids, s.library_id)) .collect() } -/// Whether a series in `library_id` is in scope for a plugin allowed to act on -/// `allowed_library_ids`. An empty allowed set means "all libraries". -fn library_in_scope(allowed_library_ids: &[Uuid], library_id: Uuid) -> bool { - allowed_library_ids.is_empty() || allowed_library_ids.contains(&library_id) +/// Project a [`SeriesEngagement`] into a `SyncEntry`, applying `CodexSyncSettings`. +/// +/// Returns `None` when the series should be skipped: no reading progress, or +/// filtered out by `include_completed` / `include_in_progress`. `external_id` and +/// `title` are supplied by the caller (matched entries use the source external ID +/// and a metadata-only title; search-fallback entries use `""` and a required +/// title). +fn project_sync_entry( + e: &SeriesEngagement, + external_id: String, + title: Option, + settings: &CodexSyncSettings, +) -> Option { + // Skip series with no progress at all. + if !e.has_any_progress() { + return None; + } + + let completed_count = e.books_read; + // `has_any_progress` guarantees at least one owned book. + let all_completed = completed_count == e.books_owned; + let is_in_progress = !all_completed; + + // Apply Codex sync settings filters. + if all_completed && !settings.include_completed { + return None; + } + if is_in_progress && !settings.include_in_progress { + return None; + } + + let progress_count = if settings.count_partial_progress { + completed_count + e.in_progress_count + } else { + completed_count + }; + + // Completion / progress totals from series metadata. + let meta = e.metadata.as_ref(); + let total_volume_count = meta + .and_then(|m| m.total_volume_count) + .filter(|&total| total > 0); + let total_chapter_count = meta + .and_then(|m| m.total_chapter_count) + .filter(|c| c.is_finite() && *c > 0.0); + + // Mark as Completed only when all local books are read AND the series has a + // known total_volume_count that we've reached. Otherwise default to Reading — + // we can't be sure the local library is complete. + let status = if all_completed { + let is_truly_complete = total_volume_count.is_some_and(|total| completed_count >= total); + if is_truly_complete { + SyncReadingStatus::Completed + } else { + SyncReadingStatus::Reading + } + } else { + SyncReadingStatus::Reading + }; + + // Server always sends books-read as `volumes`. Codex tracks books (each file + // = 1 volume), not chapters. `chapters` is left `None`; the plugin decides how + // to map this to service-specific fields. + let progress = SyncProgress { + chapters: None, + volumes: Some(progress_count), + pages: None, + total_chapters: total_chapter_count.map(|c| c as i32), + total_volumes: total_volume_count, + }; + + let (score, notes) = if settings.sync_ratings { + (e.user_rating.map(|r| r as f64), e.user_notes.clone()) + } else { + (None, None) + }; + + Some(SyncEntry { + external_id, + completed_at: if status == SyncReadingStatus::Completed { + e.latest_completed_at.map(|dt| dt.to_rfc3339()) + } else { + None + }, + status, + progress: Some(progress), + score, + started_at: e.earliest_started.map(|dt| dt.to_rfc3339()), + notes, + latest_updated_at: e.latest_read_at.map(|dt| dt.to_rfc3339()), + title, + library_id: e.library_id.to_string(), + library_name: e.library_name.clone(), + }) } /// Build push entries from a user's Codex reading progress. @@ -67,7 +151,7 @@ pub(crate) async fn build_push_entries( settings: &CodexSyncSettings, allowed_library_ids: &[Uuid], ) -> Vec { - // 1. Get all series that have external IDs for this source (1 query) + // 1. Get all series that have external IDs for this source. let external_ids = match SeriesExternalIdRepository::find_by_source(db, external_id_source).await { Ok(ids) => ids, @@ -91,258 +175,48 @@ pub(crate) async fn build_push_entries( return vec![]; } - // Resolve library context (id + name) for every candidate series, then drop - // series whose library is out of scope for this plugin. + // 2. Resolve series rows for the candidates and drop out-of-scope ones, then + // build the shared engagement aggregates in one batched pass. let candidate_series_ids: Vec = external_ids.iter().map(|e| e.series_id).collect(); - let lib_info = series_library_info(db, &candidate_series_ids).await; - let external_ids: Vec<_> = external_ids - .into_iter() - .filter(|e| match lib_info.get(&e.series_id) { - Some((lib, _)) => library_in_scope(allowed_library_ids, *lib), - // Unknown library (lookup failed): keep only when unscoped. - None => allowed_library_ids.is_empty(), - }) - .collect(); - - let external_ids_count = external_ids.len(); - let matched_series_ids: HashSet = external_ids.iter().map(|e| e.series_id).collect(); - - // Collect all series IDs for batch queries - let series_ids: Vec = external_ids.iter().map(|e| e.series_id).collect(); - - // 2. Batch-fetch all books grouped by series (1 query instead of N) - let books_map = match BookRepository::get_by_series_ids(db, &series_ids).await { - Ok(map) => map, - Err(e) => { - warn!( - "Task {}: Failed to batch-fetch books for {} series: {}", - task_id, - series_ids.len(), - e - ); - return vec![]; - } - }; - - // Collect all book IDs for batch progress lookup - let all_book_ids: Vec = books_map.values().flatten().map(|b| b.id).collect(); - - // 3. Batch-fetch all reading progress for these books (1 query instead of N*M) - let progress_map = - match ReadProgressRepository::get_for_user_books(db, user_id, &all_book_ids).await { + let series = scoped_series(db, &candidate_series_ids, allowed_library_ids).await; + let engagements = + match build_series_engagements(db, user_id, &series, EngagementOptions::default()).await { Ok(map) => map, Err(e) => { warn!( - "Task {}: Failed to batch-fetch reading progress: {}", + "Task {}: Failed to build series engagements for push: {}", task_id, e ); - HashMap::new() + return vec![]; } }; - // 4. Batch-fetch all series metadata (1 query instead of N) - let metadata_map = match SeriesMetadataRepository::get_by_series_ids(db, &series_ids).await { - Ok(map) => map, - Err(e) => { - warn!( - "Task {}: Failed to batch-fetch series metadata: {}", - task_id, e - ); - HashMap::new() - } - }; + // Series we matched by external ID (and that are in scope) — used to exclude + // them from the search-fallback pass below. + let matched_series_ids: HashSet = engagements.keys().copied().collect(); - // 5. Batch-fetch all user ratings (1 query — already batched) - let ratings_map: HashMap = - if settings.sync_ratings { - match UserSeriesRatingRepository::get_all_for_user(db, user_id).await { - Ok(ratings) => ratings.into_iter().map(|r| (r.series_id, r)).collect(), - Err(e) => { - warn!( - "Task {}: Failed to fetch user ratings for push: {}", - task_id, e - ); - HashMap::new() - } - } - } else { - HashMap::new() - }; - - // Now iterate using in-memory lookups only — zero additional queries + // 3. Project each external-ID-bearing, in-scope series into a SyncEntry. let mut entries = Vec::new(); - for ext_id in &external_ids { - let books = match books_map.get(&ext_id.series_id) { - Some(b) if !b.is_empty() => b, - _ => continue, - }; - - // Check reading progress for each book using the pre-fetched map - let mut completed_count: i32 = 0; - let mut in_progress_count: i32 = 0; - let mut has_any_progress = false; - let mut earliest_started: Option> = None; - let mut latest_completed_at: Option> = None; - let mut latest_updated_at: Option> = None; - - for book in books { - if let Some(progress) = progress_map.get(&book.id) { - has_any_progress = true; - if progress.completed { - completed_count += 1; - if let Some(cat) = progress.completed_at { - latest_completed_at = Some(match latest_completed_at { - Some(existing) if cat > existing => cat, - Some(existing) => existing, - None => cat, - }); - } - } else { - in_progress_count += 1; - } - earliest_started = Some(match earliest_started { - Some(existing) if progress.started_at < existing => progress.started_at, - Some(existing) => existing, - None => progress.started_at, - }); - latest_updated_at = Some(match latest_updated_at { - Some(existing) if progress.updated_at > existing => progress.updated_at, - Some(existing) => existing, - None => progress.updated_at, - }); - } - } - - // Skip series with no progress at all - if !has_any_progress { - debug!( - "Task {}: Skipping series {} (ext_id={}) — no reading progress", - task_id, ext_id.series_id, ext_id.external_id - ); - continue; - } - - let all_completed = completed_count == books.len() as i32; - let is_in_progress = !all_completed; - - // Apply Codex sync settings filters - if all_completed && !settings.include_completed { - debug!( - "Task {}: Skipping series {} (ext_id={}) — completed but includeCompleted=false", - task_id, ext_id.series_id, ext_id.external_id - ); + let Some(e) = engagements.get(&ext_id.series_id) else { continue; - } - if is_in_progress && !settings.include_in_progress { - debug!( - "Task {}: Skipping series {} (ext_id={}) — in-progress but includeInProgress=false", - task_id, ext_id.series_id, ext_id.external_id - ); - continue; - } - - // Calculate progress count based on settings - let progress_count = if settings.count_partial_progress { - completed_count + in_progress_count - } else { - completed_count }; - - debug!( - "Task {}: Series {} (ext_id={}): {}/{} books completed, {} in-progress, progress_count={}", - task_id, - ext_id.series_id, - ext_id.external_id, - completed_count, - books.len(), - in_progress_count, - progress_count, - ); - - // Use pre-fetched series metadata for completion / progress totals. - let series_meta = metadata_map.get(&ext_id.series_id); - let total_volume_count = series_meta - .and_then(|m| m.total_volume_count) - .filter(|&total| total > 0); - let total_chapter_count = series_meta - .and_then(|m| m.total_chapter_count) - .filter(|c| c.is_finite() && *c > 0.0); - - // Mark as Completed only when: - // 1. All local books are read, AND - // 2. The series has a known total_volume_count in metadata, AND - // 3. completed_count >= total_volume_count - // Otherwise default to Reading — we can't be sure the library is complete. - let status = if all_completed { - let is_truly_complete = - total_volume_count.is_some_and(|total| completed_count >= total); - if is_truly_complete { - SyncReadingStatus::Completed - } else { - SyncReadingStatus::Reading - } - } else { - SyncReadingStatus::Reading - }; - - // Server always sends books-read as `volumes`. Codex tracks books - // (each file = 1 volume), not chapters. `chapters` is left `None`. - // The plugin decides how to map this to service-specific fields - // (e.g. AniList's `progress` vs `progressVolumes` based on its own - // `progressUnit` config). - let progress = SyncProgress { - chapters: None, - volumes: Some(progress_count), - pages: None, - total_chapters: total_chapter_count.map(|c| c as i32), - total_volumes: total_volume_count, - }; - - // Look up rating/notes if sync_ratings is enabled - let (score, notes) = if settings.sync_ratings { - match ratings_map.get(&ext_id.series_id) { - Some(r) => (Some(r.rating as f64), r.notes.clone()), - None => (None, None), - } - } else { - (None, None) - }; - - entries.push(SyncEntry { - external_id: ext_id.external_id.clone(), - status: status.clone(), - progress: Some(progress), - score, - started_at: earliest_started.map(|dt| dt.to_rfc3339()), - completed_at: if status == SyncReadingStatus::Completed { - latest_completed_at.map(|dt| dt.to_rfc3339()) - } else { - None - }, - notes, - latest_updated_at: latest_updated_at.map(|dt| dt.to_rfc3339()), - title: metadata_map.get(&ext_id.series_id).map(|m| m.title.clone()), - library_id: lib_info - .get(&ext_id.series_id) - .map(|(id, _)| id.to_string()) - .unwrap_or_default(), - library_name: lib_info - .get(&ext_id.series_id) - .map(|(_, name)| name.clone()) - .unwrap_or_default(), - }); + // Matched entries carry a metadata-only title (no series-name fallback). + let title = e.metadata.as_ref().map(|m| m.title.clone()); + if let Some(entry) = project_sync_entry(e, ext_id.external_id.clone(), title, settings) { + entries.push(entry); + } } - let matched_count = entries.len(); - debug!( - "Task {}: Built {} push entries from {} series with external IDs", - task_id, matched_count, external_ids_count + "Task {}: Built {} push entries from {} in-scope series with external IDs", + task_id, + entries.len(), + matched_series_ids.len() ); - // When search_fallback is enabled, also include series that have reading - // progress but no external ID for this source. The plugin will search by title. + // 4. When search_fallback is enabled, also include series that have reading + // progress but no external ID for this source. The plugin searches by title. if settings.search_fallback { let unmatched = build_unmatched_entries( db, @@ -377,7 +251,7 @@ async fn build_unmatched_entries( matched_series_ids: &HashSet, allowed_library_ids: &[Uuid], ) -> Vec { - // 1. Get all reading progress for this user + // 1. Get all reading progress for this user, then map books → series. let all_progress = match ReadProgressRepository::get_by_user(db, user_id).await { Ok(p) => p, Err(e) => { @@ -393,7 +267,6 @@ async fn build_unmatched_entries( return vec![]; } - // 2. Get book IDs → look up books → get series IDs let book_ids: Vec = all_progress.iter().map(|p| p.book_id).collect(); let books = match BookRepository::get_by_ids(db, &book_ids).await { Ok(b) => b, @@ -406,16 +279,11 @@ async fn build_unmatched_entries( } }; - // Map book_id → series_id - let book_to_series: HashMap = books.iter().map(|b| (b.id, b.series_id)).collect(); - - // Collect unmatched series IDs (have progress but no external ID for this source) + // Collect unmatched series IDs (have progress but no external ID for this source). let mut unmatched_series_ids: HashSet = HashSet::new(); - for progress in &all_progress { - if let Some(&series_id) = book_to_series.get(&progress.book_id) - && !matched_series_ids.contains(&series_id) - { - unmatched_series_ids.insert(series_id); + for book in &books { + if !matched_series_ids.contains(&book.series_id) { + unmatched_series_ids.insert(book.series_id); } } @@ -423,202 +291,35 @@ async fn build_unmatched_entries( return vec![]; } - let unmatched_ids_vec: Vec = unmatched_series_ids.iter().copied().collect(); - - // Resolve library context (id + name) for each unmatched series. - let lib_info = series_library_info(db, &unmatched_ids_vec).await; - - // 3. Batch-fetch books, progress, and metadata for unmatched series - let books_map = match BookRepository::get_by_series_ids(db, &unmatched_ids_vec).await { - Ok(m) => m, - Err(e) => { - warn!( - "Task {}: Failed to fetch books for unmatched series: {}", - task_id, e - ); - return vec![]; - } - }; - - let all_book_ids: Vec = books_map.values().flatten().map(|b| b.id).collect(); - let progress_map = - match ReadProgressRepository::get_for_user_books(db, user_id, &all_book_ids).await { - Ok(m) => m, - Err(e) => { - warn!( - "Task {}: Failed to fetch progress for unmatched series: {}", - task_id, e - ); - HashMap::new() - } - }; - - let metadata_map = - match SeriesMetadataRepository::get_by_series_ids(db, &unmatched_ids_vec).await { - Ok(m) => m, + // 2. Resolve series rows, drop out-of-scope ones, build engagements. + let unmatched_ids_vec: Vec = unmatched_series_ids.into_iter().collect(); + let series = scoped_series(db, &unmatched_ids_vec, allowed_library_ids).await; + let engagements = + match build_series_engagements(db, user_id, &series, EngagementOptions::default()).await { + Ok(map) => map, Err(e) => { warn!( - "Task {}: Failed to fetch metadata for unmatched series: {}", + "Task {}: Failed to build series engagements for unmatched series: {}", task_id, e ); - HashMap::new() - } - }; - - let ratings_map: HashMap = - if settings.sync_ratings { - match UserSeriesRatingRepository::get_all_for_user(db, user_id).await { - Ok(ratings) => ratings.into_iter().map(|r| (r.series_id, r)).collect(), - Err(e) => { - warn!( - "Task {}: Failed to fetch ratings for unmatched series: {}", - task_id, e - ); - HashMap::new() - } + return vec![]; } - } else { - HashMap::new() }; - // 4. Build entries — same logic as matched entries, but with external_id: "" + // 3. Project each unmatched, in-scope series with metadata into a SyncEntry. let mut entries = Vec::new(); - - for &series_id in &unmatched_series_ids { - // Skip series outside the plugin's library scope. - let in_scope = match lib_info.get(&series_id) { - Some((lib, _)) => library_in_scope(allowed_library_ids, *lib), - None => allowed_library_ids.is_empty(), - }; - if !in_scope { + for s in &series { + let Some(e) = engagements.get(&s.id) else { continue; - } - - let title = match metadata_map.get(&series_id) { - Some(m) => m.title.clone(), - None => continue, // Skip series without metadata — we need a title for search }; - - let books = match books_map.get(&series_id) { - Some(b) if !b.is_empty() => b, - _ => continue, + // Need a title to search the external service by; skip series without metadata. + let title = match e.metadata.as_ref() { + Some(m) => m.title.clone(), + None => continue, }; - - let mut completed_count: i32 = 0; - let mut in_progress_count: i32 = 0; - let mut has_any_progress = false; - let mut earliest_started: Option> = None; - let mut latest_completed_at: Option> = None; - let mut latest_updated_at: Option> = None; - - for book in books { - if let Some(progress) = progress_map.get(&book.id) { - has_any_progress = true; - if progress.completed { - completed_count += 1; - if let Some(cat) = progress.completed_at { - latest_completed_at = Some(match latest_completed_at { - Some(existing) if cat > existing => cat, - Some(existing) => existing, - None => cat, - }); - } - } else { - in_progress_count += 1; - } - earliest_started = Some(match earliest_started { - Some(existing) if progress.started_at < existing => progress.started_at, - Some(existing) => existing, - None => progress.started_at, - }); - latest_updated_at = Some(match latest_updated_at { - Some(existing) if progress.updated_at > existing => progress.updated_at, - Some(existing) => existing, - None => progress.updated_at, - }); - } + if let Some(entry) = project_sync_entry(e, String::new(), Some(title), settings) { + entries.push(entry); } - - if !has_any_progress { - continue; - } - - let all_completed = completed_count == books.len() as i32; - let is_in_progress = !all_completed; - - if all_completed && !settings.include_completed { - continue; - } - if is_in_progress && !settings.include_in_progress { - continue; - } - - let progress_count = if settings.count_partial_progress { - completed_count + in_progress_count - } else { - completed_count - }; - - let series_meta = metadata_map.get(&series_id); - let total_volume_count = series_meta - .and_then(|m| m.total_volume_count) - .filter(|&total| total > 0); - let total_chapter_count = series_meta - .and_then(|m| m.total_chapter_count) - .filter(|c| c.is_finite() && *c > 0.0); - - let status = if all_completed { - let is_truly_complete = - total_volume_count.is_some_and(|total| completed_count >= total); - if is_truly_complete { - SyncReadingStatus::Completed - } else { - SyncReadingStatus::Reading - } - } else { - SyncReadingStatus::Reading - }; - - let progress = SyncProgress { - chapters: None, - volumes: Some(progress_count), - pages: None, - total_chapters: total_chapter_count.map(|c| c as i32), - total_volumes: total_volume_count, - }; - - let (score, notes) = if settings.sync_ratings { - match ratings_map.get(&series_id) { - Some(r) => (Some(r.rating as f64), r.notes.clone()), - None => (None, None), - } - } else { - (None, None) - }; - - entries.push(SyncEntry { - external_id: String::new(), - status: status.clone(), - progress: Some(progress), - score, - started_at: earliest_started.map(|dt| dt.to_rfc3339()), - completed_at: if status == SyncReadingStatus::Completed { - latest_completed_at.map(|dt| dt.to_rfc3339()) - } else { - None - }, - notes, - latest_updated_at: latest_updated_at.map(|dt| dt.to_rfc3339()), - title: Some(title), - library_id: lib_info - .get(&series_id) - .map(|(id, _)| id.to_string()) - .unwrap_or_default(), - library_name: lib_info - .get(&series_id) - .map(|(_, name)| name.clone()) - .unwrap_or_default(), - }); } entries From 49a221f523101c5b6cf5246d6e5d33f35bde0a58 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 19:36:17 -0700 Subject: [PATCH 07/27] feat(plugins): add opt-in series metadata to library and sync entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a SeriesMetadata bibliographic block (summary, publisher, role-carrying authors, age rating, language, reading direction) plus two optional, independently gated fields — metadata and customMetadata — to the UserLibraryEntry and SyncEntry wire types. Both are omitted when unset, so existing plugins are unaffected. SeriesEngagement gains projection helpers that build the block and parse the user-defined custom_metadata JSON. Author parsing is refactored into a shared helper that is resilient per-element: an entry with an unrecognized role keeps its name rather than dropping the whole list. Covers and a standalone artists field are deliberately omitted (no external cover URL; role on the author subsumes artists). No caller enables the new fields yet; this only establishes the contract. Tests added for serialization, omission, author parsing, and the projections. --- crates/codex-services/src/plugin/library.rs | 179 +++++++++++- crates/codex-services/src/plugin/protocol.rs | 257 ++++++++++++++++-- .../src/plugin/recommendations.rs | 4 + crates/codex-services/src/plugin/sync.rs | 26 ++ .../src/handlers/user_plugin_sync/push.rs | 3 + .../src/handlers/user_plugin_sync/tests.rs | 20 ++ 6 files changed, 467 insertions(+), 22 deletions(-) diff --git a/crates/codex-services/src/plugin/library.rs b/crates/codex-services/src/plugin/library.rs index 14dac9b0..a4019fb5 100644 --- a/crates/codex-services/src/plugin/library.rs +++ b/crates/codex-services/src/plugin/library.rs @@ -17,7 +17,9 @@ use std::collections::HashMap; use tracing::{debug, warn}; use uuid::Uuid; -use crate::plugin::protocol::{UserLibraryEntry, UserLibraryExternalId, UserReadingStatus}; +use crate::plugin::protocol::{ + SeriesMetadata, UserLibraryEntry, UserLibraryExternalId, UserReadingStatus, parse_authors_json, +}; use codex_db::entities::{SeriesStatus, series, series_metadata}; use codex_db::repositories::{ AlternateTitleRepository, BookRepository, GenreRepository, LibraryRepository, @@ -102,6 +104,46 @@ impl SeriesEngagement { pub fn has_any_progress(&self) -> bool { self.books_read > 0 || self.in_progress_count > 0 } + + /// Build the bibliographic [`SeriesMetadata`] block for this series, for + /// plugins that opted into `sendMetadata`. Returns `None` when there is no + /// metadata row or the block would carry no data. + /// + /// Drawn from the always-fetched series metadata row, so it works regardless + /// of [`EngagementOptions::include_taxonomy`]. + pub fn series_metadata_block(&self) -> Option { + let m = self.metadata.as_ref()?; + let block = SeriesMetadata { + summary: m.summary.clone(), + publisher: m.publisher.clone(), + authors: m + .authors_json + .as_deref() + .map(parse_authors_json) + .unwrap_or_default(), + age_rating: m.age_rating, + language: m.language.clone(), + reading_direction: m.reading_direction.clone(), + }; + if block.is_empty() { None } else { Some(block) } + } + + /// Parse the user-defined `custom_metadata` JSON for this series, for plugins + /// that opted into `sendCustomMetadata`. Returns `None` when absent or + /// unparseable (logged). + pub fn custom_metadata_value(&self) -> Option { + let raw = self.metadata.as_ref()?.custom_metadata.as_deref()?; + match serde_json::from_str(raw) { + Ok(value) => Some(value), + Err(e) => { + warn!( + "Failed to parse custom_metadata for series {}: {}", + self.series_id, e + ); + None + } + } + } } /// Build per-series [`SeriesEngagement`] aggregates for the given series in one @@ -334,6 +376,9 @@ pub async fn build_user_library( started_at: e.earliest_started.map(|dt| dt.to_rfc3339()), last_read_at: e.latest_read_at.map(|dt| dt.to_rfc3339()), completed_at: e.latest_completed_at.map(|dt| dt.to_rfc3339()), + // Enrichment is wired in a later phase; entries carry no metadata yet. + metadata: None, + custom_metadata: None, }); } @@ -352,10 +397,44 @@ mod tests { use codex_db::ScanningStrategy; use codex_db::entities::{books, users}; use codex_db::repositories::{ - BookRepository, LibraryRepository, ReadProgressRepository, SeriesRepository, UserRepository, + BookRepository, LibraryRepository, ReadProgressRepository, SeriesMetadataRepository, + SeriesRepository, UserRepository, }; use codex_db::test_helpers::create_test_db; + /// Fetch the `series_metadata` row auto-created alongside a series. + async fn fetch_meta(conn: &DatabaseConnection, series_id: Uuid) -> series_metadata::Model { + SeriesMetadataRepository::get_by_series_ids(conn, &[series_id]) + .await + .unwrap() + .remove(&series_id) + .expect("metadata row auto-created with series") + } + + /// Build a `SeriesEngagement` carrying the given metadata row, with neutral + /// progress/taxonomy, for testing the projection helpers in isolation. + fn engagement_with_meta(metadata: Option) -> SeriesEngagement { + SeriesEngagement { + series_id: Uuid::new_v4(), + library_id: Uuid::new_v4(), + library_name: "L".to_string(), + title: "S".to_string(), + metadata, + books_owned: 0, + books_read: 0, + in_progress_count: 0, + earliest_started: None, + latest_read_at: None, + latest_completed_at: None, + genres: vec![], + tags: vec![], + alternate_titles: vec![], + external_ids: vec![], + user_rating: None, + user_notes: None, + } + } + /// Insert a minimal user row so reading-progress FKs are satisfied. async fn create_user(db: &DatabaseConnection) -> Uuid { let user = users::Model { @@ -467,7 +546,7 @@ mod tests { let engagements = build_series_engagements( conn, user_id, - &[series.clone()], + std::slice::from_ref(&series), EngagementOptions::default(), ) .await @@ -497,4 +576,98 @@ mod tests { .unwrap(); assert!(engagements.is_empty()); } + + #[tokio::test] + async fn test_series_metadata_block_projection() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + let lib = LibraryRepository::create(conn, "Lib", "/l", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, lib.id, "Berserk", None) + .await + .unwrap(); + // Real metadata row, then populate the fields the block projects. + let mut meta = fetch_meta(conn, series.id).await; + meta.summary = Some("A dark fantasy".to_string()); + meta.publisher = Some("Hakusensha".to_string()); + meta.age_rating = Some(18); + meta.language = Some("ja".to_string()); + meta.reading_direction = Some("rtl".to_string()); + meta.authors_json = Some( + r#"[{"name":"Kentaro Miura","role":"author"},{"name":"Studio Gaga","role":"illustrator"}]"# + .to_string(), + ); + + let block = engagement_with_meta(Some(meta)) + .series_metadata_block() + .expect("non-empty block"); + assert_eq!(block.summary.as_deref(), Some("A dark fantasy")); + assert_eq!(block.publisher.as_deref(), Some("Hakusensha")); + assert_eq!(block.age_rating, Some(18)); + assert_eq!(block.language.as_deref(), Some("ja")); + assert_eq!(block.reading_direction.as_deref(), Some("rtl")); + assert_eq!(block.authors.len(), 2); + assert_eq!(block.authors[0].name, "Kentaro Miura"); + // Role is preserved, so a plugin can tell artist from author. + assert_eq!(block.authors[1].name, "Studio Gaga"); + } + + #[tokio::test] + async fn test_series_metadata_block_empty_is_none() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + let lib = LibraryRepository::create(conn, "Lib", "/l", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, lib.id, "Bare", None) + .await + .unwrap(); + // Metadata row exists but carries no bibliographic data → block omitted. + let meta = fetch_meta(conn, series.id).await; + assert!( + engagement_with_meta(Some(meta)) + .series_metadata_block() + .is_none() + ); + // No metadata row at all → also None. + assert!(engagement_with_meta(None).series_metadata_block().is_none()); + } + + #[tokio::test] + async fn test_custom_metadata_value_parses_and_degrades() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + let lib = LibraryRepository::create(conn, "Lib", "/l", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, lib.id, "S", None) + .await + .unwrap(); + let mut meta = fetch_meta(conn, series.id).await; + + // Valid JSON parses into a Value. + meta.custom_metadata = Some(r#"{"shelf":"favorites","priority":3}"#.to_string()); + let value = engagement_with_meta(Some(meta.clone())) + .custom_metadata_value() + .expect("parsed value"); + assert_eq!(value["shelf"], "favorites"); + assert_eq!(value["priority"], 3); + + // Malformed JSON degrades to None (logged), never panics. + meta.custom_metadata = Some("{not valid".to_string()); + assert!( + engagement_with_meta(Some(meta.clone())) + .custom_metadata_value() + .is_none() + ); + + // Absent custom metadata → None. + meta.custom_metadata = None; + assert!( + engagement_with_meta(Some(meta)) + .custom_metadata_value() + .is_none() + ); + } } diff --git a/crates/codex-services/src/plugin/protocol.rs b/crates/codex-services/src/plugin/protocol.rs index b6a6f798..6dba87d5 100644 --- a/crates/codex-services/src/plugin/protocol.rs +++ b/crates/codex-services/src/plugin/protocol.rs @@ -715,7 +715,7 @@ pub struct PluginBookMetadata { } /// Structured author with role information -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BookAuthor { /// Author's display name @@ -748,24 +748,19 @@ pub enum BookAuthorRole { CoverArtist, } -/// Custom deserializer for series authors that accepts both: -/// - Legacy format: `["Author Name", "Another Author"]` (Vec) -/// - New format: `[{"name": "Author Name", "role": "author"}]` (Vec) -fn deserialize_series_authors<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum AuthorItem { - Structured(BookAuthor), - Plain(String), - } +/// One entry of an `authors_json` array: either a structured `{name, role, …}` +/// object or a bare name string. Shared by the serde deserializer and the +/// standalone string parser so both tolerate either shape. +#[derive(Deserialize)] +#[serde(untagged)] +enum AuthorItem { + Structured(BookAuthor), + Plain(String), +} - let items: Vec = Vec::deserialize(deserializer)?; - Ok(items - .into_iter() - .filter_map(|item| match item { +impl AuthorItem { + fn into_author(self) -> Option { + match self { AuthorItem::Structured(author) => Some(author), AuthorItem::Plain(name) => { let name = name.trim().to_string(); @@ -779,10 +774,57 @@ where }) } } - }) + } + } +} + +/// Custom deserializer for series authors that accepts both the legacy +/// `["Author Name", …]` (`Vec`) and the structured +/// `[{"name": "…", "role": "…"}]` (`Vec`) shapes. +fn deserialize_series_authors<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let items: Vec = Vec::deserialize(deserializer)?; + Ok(items + .into_iter() + .filter_map(AuthorItem::into_author) .collect()) } +/// Parse a `series_metadata.authors_json` string into [`BookAuthor`]s, tolerating +/// both structured `{name, role}` entries and bare name strings. +/// +/// Parsing is per-element so one bad entry doesn't drop the whole list: an entry +/// whose `role` isn't a known [`BookAuthorRole`] still keeps its name (with the +/// default role) rather than being discarded. Returns an empty vec only when the +/// top-level JSON isn't an array. +pub fn parse_authors_json(raw: &str) -> Vec { + let Ok(items) = serde_json::from_str::>(raw) else { + return Vec::new(); + }; + items + .into_iter() + .filter_map(|value| { + match serde_json::from_value::(value.clone()) { + Ok(item) => item.into_author(), + // Structured entry with an unrecognized role (or other mismatch): + // salvage the name instead of dropping the credit entirely. + Err(_) => value + .get("name") + .and_then(|n| n.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| BookAuthor { + name: name.to_string(), + role: BookAuthorRole::default(), + sort_name: None, + }), + } + }) + .collect() +} + /// Book cover with size and source information #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -896,6 +938,57 @@ pub enum ExternalLinkType { Other, } +// ============================================================================= +// Series Metadata Enrichment (opt-in, shared by sync + recommendations) +// ============================================================================= + +/// Optional bibliographic metadata block attached to library/sync entries when +/// a plugin declares `wantsFullMetadata` and the user opts in via the +/// `sendMetadata` toggle. +/// +/// Public-ish bibliographic fields only — external services already have most of +/// this. The user-defined `custom_metadata` escape hatch is deliberately NOT +/// here: it carries private data and is gated by a separate `sendCustomMetadata` +/// toggle, so it rides on the parent entry as its own field. Genres, tags, year, +/// status, and total counts also live on the parent entry and are not duplicated. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesMetadata { + /// Series summary / description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Publisher name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publisher: Option, + /// Credits (name + role), parsed from `series_metadata.authors_json`. Role + /// distinguishes author / artist / editor / etc., so there is no separate + /// `artists` field. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authors: Vec, + /// Age rating (e.g. 0, 13, 16, 18). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub age_rating: Option, + /// BCP47 language code (e.g. "en", "ja", "ko"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + /// Reading direction: "ltr", "rtl", or "ttb". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reading_direction: Option, +} + +impl SeriesMetadata { + /// Whether the block carries no data (so callers can omit it entirely rather + /// than emit an empty object). + pub fn is_empty(&self) -> bool { + self.summary.is_none() + && self.publisher.is_none() + && self.authors.is_empty() + && self.age_rating.is_none() + && self.language.is_none() + && self.reading_direction.is_none() + } +} + // ============================================================================= // User Library Data Contract (Sync Providers) // ============================================================================= @@ -970,6 +1063,16 @@ pub struct UserLibraryEntry { /// When the user completed the series (ISO 8601) #[serde(default, skip_serializing_if = "Option::is_none")] pub completed_at: Option, + + /// Bibliographic metadata, attached only when the plugin declares + /// `wantsFullMetadata` and the user enables the `sendMetadata` toggle. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// User-defined custom metadata (parsed `series_metadata.custom_metadata`), + /// attached only when the plugin declares `wantsFullMetadata` and the user + /// enables the separate `sendCustomMetadata` toggle. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_metadata: Option, } /// External ID mapping for a library entry @@ -2147,6 +2250,8 @@ mod tests { series_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), library_id: "11111111-1111-1111-1111-111111111111".to_string(), library_name: "Manga".to_string(), + metadata: None, + custom_metadata: None, title: "One Piece".to_string(), alternate_titles: vec!["ワンピース".to_string()], year: Some(1997), @@ -2224,6 +2329,8 @@ mod tests { completed_at: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }; let json = serde_json::to_value(&entry).unwrap(); assert_eq!(json["seriesId"], "abc"); @@ -2342,6 +2449,8 @@ mod tests { completed_at: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }; let json = serde_json::to_value(&entry).unwrap(); let ids = json["externalIds"].as_array().unwrap(); @@ -2522,4 +2631,114 @@ mod tests { .contains_key("searchURITemplate") ); } + + // ========================================================================= + // Series Metadata Enrichment Tests + // ========================================================================= + + #[test] + fn test_series_metadata_serializes_camel_case_and_skips_empty() { + let block = SeriesMetadata { + summary: Some("A dark fantasy".to_string()), + publisher: None, + authors: vec![BookAuthor { + name: "Kentaro Miura".to_string(), + role: BookAuthorRole::Author, + sort_name: None, + }], + age_rating: Some(18), + language: None, + reading_direction: Some("rtl".to_string()), + }; + let json = serde_json::to_value(&block).unwrap(); + assert_eq!(json["summary"], "A dark fantasy"); + assert_eq!(json["ageRating"], 18); + assert_eq!(json["readingDirection"], "rtl"); + assert_eq!(json["authors"][0]["name"], "Kentaro Miura"); + let obj = json.as_object().unwrap(); + // None / empty fields are omitted. + assert!(!obj.contains_key("publisher")); + assert!(!obj.contains_key("language")); + } + + #[test] + fn test_series_metadata_is_empty() { + assert!(SeriesMetadata::default().is_empty()); + let with_summary = SeriesMetadata { + summary: Some("x".to_string()), + ..Default::default() + }; + assert!(!with_summary.is_empty()); + let with_authors = SeriesMetadata { + authors: vec![BookAuthor { + name: "A".to_string(), + role: BookAuthorRole::Author, + sort_name: None, + }], + ..Default::default() + }; + assert!(!with_authors.is_empty()); + } + + #[test] + fn test_parse_authors_json() { + // Structured entries keep their role. + let authors = + parse_authors_json(r#"[{"name":"Miura","role":"illustrator"},{"name":"Gaga"}]"#); + assert_eq!(authors.len(), 2); + assert_eq!(authors[0].name, "Miura"); + assert_eq!(authors[0].role, BookAuthorRole::Illustrator); + // Bare-name entry defaults to Author. + assert_eq!(authors[1].name, "Gaga"); + assert_eq!(authors[1].role, BookAuthorRole::Author); + + // Plain string array is tolerated; blanks dropped. + let plain = parse_authors_json(r#"["Solo", " "]"#); + assert_eq!(plain.len(), 1); + assert_eq!(plain[0].name, "Solo"); + + // Unknown role: keep the name (default role) rather than dropping it, and + // don't let it nuke sibling entries. + let mixed = parse_authors_json(r#"[{"name":"Odd","role":"mascot"},{"name":"Keep"}]"#); + assert_eq!(mixed.len(), 2); + assert_eq!(mixed[0].name, "Odd"); + assert_eq!(mixed[0].role, BookAuthorRole::Author); + assert_eq!(mixed[1].name, "Keep"); + + // Malformed JSON yields an empty vec, never panics. + assert!(parse_authors_json("{not json").is_empty()); + } + + #[test] + fn test_user_library_entry_omits_enrichment_when_none() { + let entry = UserLibraryEntry { + series_id: "s1".to_string(), + library_id: String::new(), + library_name: String::new(), + title: "T".to_string(), + alternate_titles: vec![], + year: None, + status: None, + genres: vec![], + tags: vec![], + total_volume_count: None, + total_chapter_count: None, + external_ids: vec![], + reading_status: None, + books_read: 0, + books_owned: 0, + user_rating: None, + user_notes: None, + started_at: None, + last_read_at: None, + completed_at: None, + metadata: None, + custom_metadata: None, + }; + let obj = serde_json::to_value(&entry).unwrap(); + let obj = obj.as_object().unwrap(); + // Backward compatible: enrichment keys absent unless populated. + assert!(!obj.contains_key("metadata")); + assert!(!obj.contains_key("customMetadata")); + } } diff --git a/crates/codex-services/src/plugin/recommendations.rs b/crates/codex-services/src/plugin/recommendations.rs index cb6dc809..8d670aad 100644 --- a/crates/codex-services/src/plugin/recommendations.rs +++ b/crates/codex-services/src/plugin/recommendations.rs @@ -273,6 +273,8 @@ mod tests { completed_at: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }], limit: Some(10), exclude_ids: vec!["99999".to_string()], @@ -531,6 +533,8 @@ mod tests { completed_at: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }], }; let json = serde_json::to_value(&req).unwrap(); diff --git a/crates/codex-services/src/plugin/sync.rs b/crates/codex-services/src/plugin/sync.rs index 75a5d553..ec0bd4a7 100644 --- a/crates/codex-services/src/plugin/sync.rs +++ b/crates/codex-services/src/plugin/sync.rs @@ -111,6 +111,18 @@ pub struct SyncEntry { /// push; empty on pulled entries. #[serde(default)] pub library_name: String, + /// Bibliographic metadata, attached on push only when the plugin declares + /// `wantsFullMetadata` and the user enables the `sendMetadata` toggle. Lets + /// rule-based plugins act on summary/authors/age-rating/etc. Empty on pulled + /// entries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// User-defined custom metadata (parsed `series_metadata.custom_metadata`), + /// attached on push only when the plugin declares `wantsFullMetadata` and the + /// user enables the separate `sendCustomMetadata` toggle. Empty on pulled + /// entries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_metadata: Option, } /// Reading progress details @@ -380,6 +392,8 @@ mod tests { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }; let json = serde_json::to_value(&entry).unwrap(); assert_eq!(json["externalId"], "12345"); @@ -498,6 +512,8 @@ mod tests { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }, SyncEntry { external_id: "2".to_string(), @@ -511,6 +527,8 @@ mod tests { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }, ], }; @@ -639,6 +657,8 @@ mod tests { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }], next_cursor: Some("page2".to_string()), has_more: true, @@ -734,6 +754,8 @@ mod tests { title: Some("Berserk".to_string()), library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }; let json = serde_json::to_value(&entry).unwrap(); assert_eq!(json["title"], "Berserk"); @@ -754,6 +776,8 @@ mod tests { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }; let json = serde_json::to_value(&entry).unwrap(); assert!(!json.as_object().unwrap().contains_key("title")); @@ -795,6 +819,8 @@ mod tests { title: None, library_id: "11111111-1111-1111-1111-111111111111".to_string(), library_name: "Manga".to_string(), + metadata: None, + custom_metadata: None, }; let json = serde_json::to_value(&entry).unwrap(); assert_eq!(json["libraryId"], "11111111-1111-1111-1111-111111111111"); diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs index 75c6cbd7..45c2c33f 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs @@ -131,6 +131,9 @@ fn project_sync_entry( title, library_id: e.library_id.to_string(), library_name: e.library_name.clone(), + // Enrichment is wired in a later phase; entries carry no metadata yet. + metadata: None, + custom_metadata: None, }) } diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs index 163390ee..6aed63f7 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs @@ -268,6 +268,8 @@ async fn test_match_and_apply_no_source() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -328,6 +330,8 @@ async fn test_match_and_apply_with_matches() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }, SyncEntry { external_id: "99999".to_string(), // no match @@ -341,6 +345,8 @@ async fn test_match_and_apply_with_matches() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }, ]; @@ -412,6 +418,8 @@ async fn test_match_and_apply_pulled_entries_applies_progress() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -511,6 +519,8 @@ async fn test_match_and_apply_skips_already_read() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -542,6 +552,8 @@ fn pulled_completed_entry(external_id: &str) -> SyncEntry { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, } } @@ -1291,6 +1303,8 @@ async fn test_apply_pulled_entry_uses_volumes() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }; // Build pre-fetched maps for apply_pulled_entry (via match_and_apply which calls it) @@ -1591,6 +1605,8 @@ async fn test_apply_pulled_rating_no_existing() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }]; let (matched, _applied) = pull::match_and_apply_pulled_entries( @@ -1683,6 +1699,8 @@ async fn test_apply_pulled_rating_existing_not_overwritten() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }]; let (_matched, _applied) = pull::match_and_apply_pulled_entries( @@ -1760,6 +1778,8 @@ async fn test_apply_pulled_rating_disabled() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, }]; let (_matched, _applied) = pull::match_and_apply_pulled_entries( From 035565ec4feb442611ef72c0a931fec436ee9e73 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 20:21:52 -0700 Subject: [PATCH 08/27] feat(plugins): gate metadata enrichment behind per-field opt-in toggles Wire the previously-defined enrichment fields to plugins, controlled by a manifest capability plus four independent per-user toggles so a connection sends exactly as much as it opts into. - Add a `wantsFullMetadata` plugin capability; the host only does the extra work, and the UI only shows the toggles, when a plugin declares it. - Add four host-only `_codex` toggles (sendTags, sendGenres, sendMetadata, sendCustomMetadata) with entity accessors; the effective flag per field is capability AND the toggle. All default off, so existing connections push the same minimal payload as before. - Add top-level genres/tags to sync entries (they carried no taxonomy), so a rules-based sync plugin can filter on tags without receiving heavier bibliographic fields. - Attach each opted-in field on sync push (matched and search-fallback entries) and on recommendation seeds; taxonomy is only fetched when a toggle needs it. Recommendations keep sending genres/tags as before. - Add PATCH /api/v1/user/plugins/{id}/metadata-settings (partial update, preserves sibling _codex keys, rejected when the plugin lacks the capability), and expose the capability and current toggle values on the user-plugin DTO. Tests added across the entity accessors, settings parsing, per-flag sync push, and the new endpoint. --- crates/codex-api/src/docs.rs | 2 + .../src/routes/v1/dto/user_plugins.rs | 46 ++++++ .../src/routes/v1/handlers/user_plugins.rs | 116 +++++++++++++- .../src/routes/v1/routes/user_plugins.rs | 5 + crates/codex-db/src/entities/user_plugins.rs | 60 ++++++- crates/codex-models/src/plugin.rs | 7 + crates/codex-services/src/plugin/sync.rs | 24 +++ .../handlers/user_plugin_recommendations.rs | 95 +++++++++++- .../src/handlers/user_plugin_sync/mod.rs | 22 ++- .../src/handlers/user_plugin_sync/push.rs | 100 ++++++++---- .../src/handlers/user_plugin_sync/settings.rs | 29 +++- .../src/handlers/user_plugin_sync/tests.rs | 146 +++++++++++++++++- docs/api/openapi.json | 111 ++++++++++++- tests/api/user_plugins.rs | 120 ++++++++++++++ web/openapi.json | 111 ++++++++++++- web/src/types/api.generated.ts | 101 ++++++++++++ 16 files changed, 1049 insertions(+), 46 deletions(-) diff --git a/crates/codex-api/src/docs.rs b/crates/codex-api/src/docs.rs index 9f45da41..611ba14b 100644 --- a/crates/codex-api/src/docs.rs +++ b/crates/codex-api/src/docs.rs @@ -487,6 +487,7 @@ The following paths are exempt from rate limiting: v1::handlers::user_plugins::get_user_plugin, v1::handlers::user_plugins::update_user_plugin_config, v1::handlers::user_plugins::set_sync_mode, + v1::handlers::user_plugins::set_metadata_settings, v1::handlers::user_plugins::oauth_start, v1::handlers::user_plugins::oauth_callback, v1::handlers::user_plugins::set_user_credentials, @@ -979,6 +980,7 @@ The following paths are exempt from rate limiting: v1::dto::OAuthStartResponse, v1::dto::UpdateUserPluginConfigRequest, v1::dto::SetSyncModeRequest, + v1::dto::SetMetadataSettingsRequest, v1::dto::SetUserCredentialsRequest, v1::dto::SyncTriggerResponse, v1::dto::SyncStatusDto, diff --git a/crates/codex-api/src/routes/v1/dto/user_plugins.rs b/crates/codex-api/src/routes/v1/dto/user_plugins.rs index 05d4c358..bc9ca470 100644 --- a/crates/codex-api/src/routes/v1/dto/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/dto/user_plugins.rs @@ -75,6 +75,12 @@ pub struct UserPluginDto { /// (host-side preference, derived from `config._codex.autoSync`). When /// false (the default), syncs run only when manually triggered. pub auto_sync: bool, + /// Per-field metadata-enrichment opt-ins (host-side, from `config._codex.send*`). + /// Only meaningful when the plugin declares `wantsFullMetadata`; all default false. + pub send_tags: bool, + pub send_genres: bool, + pub send_metadata: bool, + pub send_custom_metadata: bool, /// Plugin capabilities (derived from manifest) pub capabilities: UserPluginCapabilitiesDto, /// User-facing configuration schema (from plugin manifest) @@ -119,6 +125,9 @@ pub struct UserPluginCapabilitiesDto { pub read_sync: bool, /// Can provide recommendations pub user_recommendation_provider: bool, + /// Consumes enriched series data; gates whether the `_codex.send*` metadata + /// toggles are shown on the connection. + pub wants_full_metadata: bool, } /// Request to update user plugin configuration @@ -138,6 +147,28 @@ pub struct SetSyncModeRequest { pub auto: bool, } +/// Request to set a connection's metadata-enrichment opt-ins. +/// +/// Each field is optional; only the provided ones are updated (partial update), +/// and other `_codex` settings are preserved. Only meaningful for plugins that +/// declare the `wantsFullMetadata` capability. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SetMetadataSettingsRequest { + /// Send series tags (top-level) to the plugin. + #[serde(default)] + pub send_tags: Option, + /// Send series genres (top-level) to the plugin. + #[serde(default)] + pub send_genres: Option, + /// Send the bibliographic metadata block to the plugin. + #[serde(default)] + pub send_metadata: Option, + /// Send user-defined custom metadata to the plugin. + #[serde(default)] + pub send_custom_metadata: Option, +} + /// Request to set user credentials (e.g., personal access token) #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] @@ -392,9 +423,14 @@ mod tests { user_setup_instructions: None, config: serde_json::json!({}), auto_sync: false, + send_tags: false, + send_genres: false, + send_metadata: false, + send_custom_metadata: false, capabilities: UserPluginCapabilitiesDto { read_sync: true, user_recommendation_provider: false, + wants_full_metadata: false, }, user_config_schema: None, last_sync_result: None, @@ -441,9 +477,14 @@ mod tests { user_setup_instructions: None, config: serde_json::json!({}), auto_sync: false, + send_tags: false, + send_genres: false, + send_metadata: false, + send_custom_metadata: false, capabilities: UserPluginCapabilitiesDto { read_sync: true, user_recommendation_provider: false, + wants_full_metadata: false, }, user_config_schema: Some(schema), last_sync_result: None, @@ -485,9 +526,14 @@ mod tests { user_setup_instructions: None, config: serde_json::json!({}), auto_sync: false, + send_tags: false, + send_genres: false, + send_metadata: false, + send_custom_metadata: false, capabilities: UserPluginCapabilitiesDto { read_sync: true, user_recommendation_provider: false, + wants_full_metadata: false, }, user_config_schema: None, last_sync_result: Some(sync_result.clone()), diff --git a/crates/codex-api/src/routes/v1/handlers/user_plugins.rs b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs index eaaff674..e9cefc3b 100644 --- a/crates/codex-api/src/routes/v1/handlers/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs @@ -6,10 +6,10 @@ use super::super::dto::plugins::ConfigSchemaDto; use super::super::dto::user_plugins::{ - AvailablePluginDto, OAuthCallbackQuery, OAuthStartResponse, SetSyncModeRequest, - SetUserCredentialsRequest, SyncStatusDto, SyncStatusQuery, SyncTriggerResponse, - UpdateUserPluginConfigRequest, UserPluginCapabilitiesDto, UserPluginDto, UserPluginTaskDto, - UserPluginTasksQuery, UserPluginsListResponse, + AvailablePluginDto, OAuthCallbackQuery, OAuthStartResponse, SetMetadataSettingsRequest, + SetSyncModeRequest, SetUserCredentialsRequest, SyncStatusDto, SyncStatusQuery, + SyncTriggerResponse, UpdateUserPluginConfigRequest, UserPluginCapabilitiesDto, UserPluginDto, + UserPluginTaskDto, UserPluginTasksQuery, UserPluginsListResponse, }; use crate::extractors::auth::AuthContext; use crate::{error::ApiError, extractors::AppState}; @@ -119,6 +119,10 @@ async fn build_user_plugin_dto( .as_ref() .map(|m| m.capabilities.user_recommendation_provider) .unwrap_or(false), + wants_full_metadata: manifest + .as_ref() + .map(|m| m.capabilities.wants_full_metadata) + .unwrap_or(false), }; let user_config_schema = manifest @@ -155,6 +159,10 @@ async fn build_user_plugin_dto( user_setup_instructions: manifest.and_then(|m| m.user_setup_instructions), config: instance.config.clone(), auto_sync: instance.auto_sync_enabled(), + send_tags: instance.send_tags_enabled(), + send_genres: instance.send_genres_enabled(), + send_metadata: instance.send_metadata_enabled(), + send_custom_metadata: instance.send_custom_metadata_enabled(), capabilities, user_config_schema, last_sync_result, @@ -248,6 +256,10 @@ pub async fn list_user_plugins( .as_ref() .map(|m| m.capabilities.user_recommendation_provider) .unwrap_or(false), + wants_full_metadata: manifest + .as_ref() + .map(|m| m.capabilities.wants_full_metadata) + .unwrap_or(false), }, } }) @@ -813,6 +825,102 @@ pub async fn set_sync_mode( )) } +/// Set a connection's metadata-enrichment opt-ins (tags/genres/metadata/custom). +/// +/// Writes the host-only `config._codex.send*` flags (the plugin never reads +/// them). Each is a partial update: only provided fields change, and other +/// `_codex` keys are preserved. Only allowed for plugins whose manifest declares +/// the `wantsFullMetadata` capability. +#[utoipa::path( + patch, + path = "/api/v1/user/plugins/{plugin_id}/metadata-settings", + params( + ("plugin_id" = Uuid, Path, description = "Plugin ID to set metadata settings for") + ), + request_body = SetMetadataSettingsRequest, + responses( + (status = 200, description = "Metadata settings updated", body = UserPluginDto), + (status = 400, description = "Plugin does not consume full metadata"), + (status = 401, description = "Not authenticated"), + (status = 404, description = "Plugin not enabled for this user"), + ), + tag = "User Plugins" +)] +pub async fn set_metadata_settings( + State(state): State>, + auth: AuthContext, + Path(plugin_id): Path, + Json(request): Json, +) -> Result, ApiError> { + use codex_db::entities::user_plugins::{ + CODEX_CONFIG_NAMESPACE, SEND_CUSTOM_METADATA_KEY, SEND_GENRES_KEY, SEND_METADATA_KEY, + SEND_TAGS_KEY, + }; + + let instance = + UserPluginsRepository::get_by_user_and_plugin(&state.db, auth.user_id, plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Database error: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Plugin not enabled for this user".to_string()))?; + + let plugin = PluginsRepository::get_by_id(&state.db, plugin_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? + .ok_or_else(|| ApiError::Internal("Plugin definition not found".to_string()))?; + + // Only plugins that consume enriched data expose these toggles. + let wants_full_metadata = parse_manifest(&plugin) + .map(|m| m.capabilities.wants_full_metadata) + .unwrap_or(false); + if !wants_full_metadata { + return Err(ApiError::BadRequest( + "Plugin does not consume full metadata".to_string(), + )); + } + + // Read-modify-write the host-only `_codex` object, touching only provided keys + // and preserving every sibling (top-level config and other `_codex` settings). + let mut config = match instance.config.clone() { + serde_json::Value::Object(map) => map, + _ => serde_json::Map::new(), + }; + let codex_ns = config + .entry(CODEX_CONFIG_NAMESPACE) + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + if !codex_ns.is_object() { + *codex_ns = serde_json::Value::Object(serde_json::Map::new()); + } + let codex_obj = codex_ns.as_object_mut().expect("ensured object above"); + for (key, value) in [ + (SEND_TAGS_KEY, request.send_tags), + (SEND_GENRES_KEY, request.send_genres), + (SEND_METADATA_KEY, request.send_metadata), + (SEND_CUSTOM_METADATA_KEY, request.send_custom_metadata), + ] { + if let Some(v) = value { + codex_obj.insert(key.to_string(), serde_json::Value::Bool(v)); + } + } + + let updated = UserPluginsRepository::update_config( + &state.db, + instance.id, + serde_json::Value::Object(config), + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update metadata settings: {}", e)))?; + + debug!( + user_id = %auth.user_id, + plugin_id = %plugin_id, + "User updated plugin metadata settings" + ); + + Ok(Json( + build_user_plugin_dto(&state.db, &updated, &plugin, None).await, + )) +} + /// Trigger a sync operation for a user plugin /// /// Enqueues a background sync task that will push/pull reading progress diff --git a/crates/codex-api/src/routes/v1/routes/user_plugins.rs b/crates/codex-api/src/routes/v1/routes/user_plugins.rs index cac0440e..b1bb341b 100644 --- a/crates/codex-api/src/routes/v1/routes/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/routes/user_plugins.rs @@ -52,6 +52,11 @@ pub fn routes(_state: Arc) -> Router> { "/user/plugins/{plugin_id}/sync-mode", patch(handlers::user_plugins::set_sync_mode), ) + // Per-field metadata-enrichment opt-ins (tags/genres/metadata/custom) + .route( + "/user/plugins/{plugin_id}/metadata-settings", + patch(handlers::user_plugins::set_metadata_settings), + ) // User credentials (personal access token) .route( "/user/plugins/{plugin_id}/credentials", diff --git a/crates/codex-db/src/entities/user_plugins.rs b/crates/codex-db/src/entities/user_plugins.rs index e2ae84db..9e816deb 100644 --- a/crates/codex-db/src/entities/user_plugins.rs +++ b/crates/codex-db/src/entities/user_plugins.rs @@ -42,6 +42,16 @@ pub const CODEX_CONFIG_NAMESPACE: &str = "_codex"; /// (the default). Distinct from `syncMode`, which selects sync *direction*. pub const AUTO_SYNC_KEY: &str = "autoSync"; +/// JSON keys (inside `_codex`) for the per-field metadata-enrichment opt-ins. +/// +/// Each gates whether the host attaches that data to the entries it sends a +/// plugin that declares the `wantsFullMetadata` capability. Absent/false means +/// the data is not sent (the default), so payloads stay minimal unless opted in. +pub const SEND_TAGS_KEY: &str = "sendTags"; +pub const SEND_GENRES_KEY: &str = "sendGenres"; +pub const SEND_METADATA_KEY: &str = "sendMetadata"; +pub const SEND_CUSTOM_METADATA_KEY: &str = "sendCustomMetadata"; + #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "user_plugins")] pub struct Model { @@ -197,13 +207,39 @@ impl Model { /// combines this with `enabled` and [`Self::is_authenticated`] to decide /// whether to enqueue a sync when the plugin's cron fires. pub fn auto_sync_enabled(&self) -> bool { + self.codex_flag(AUTO_SYNC_KEY) + } + + /// Read a host-only `config._codex.` boolean, defaulting to false when + /// the namespace, key, or boolean type is absent. + fn codex_flag(&self, key: &str) -> bool { self.config .get(CODEX_CONFIG_NAMESPACE) - .and_then(|codex| codex.get(AUTO_SYNC_KEY)) + .and_then(|codex| codex.get(key)) .and_then(|v| v.as_bool()) .unwrap_or(false) } + /// Whether the user opted into sending series `tags` to this plugin. + pub fn send_tags_enabled(&self) -> bool { + self.codex_flag(SEND_TAGS_KEY) + } + + /// Whether the user opted into sending series `genres` to this plugin. + pub fn send_genres_enabled(&self) -> bool { + self.codex_flag(SEND_GENRES_KEY) + } + + /// Whether the user opted into sending the bibliographic metadata block. + pub fn send_metadata_enabled(&self) -> bool { + self.codex_flag(SEND_METADATA_KEY) + } + + /// Whether the user opted into sending user-defined custom metadata. + pub fn send_custom_metadata_enabled(&self) -> bool { + self.codex_flag(SEND_CUSTOM_METADATA_KEY) + } + /// Parse health status pub fn health_status_type(&self) -> PluginHealthStatus { self.health_status @@ -386,4 +422,26 @@ mod tests { model.config = serde_json::json!({ "_codex": { "autoSync": false } }); assert!(!model.auto_sync_enabled()); } + + #[test] + fn test_metadata_flags_default_false() { + let model = test_model(); + assert!(!model.send_tags_enabled()); + assert!(!model.send_genres_enabled()); + assert!(!model.send_metadata_enabled()); + assert!(!model.send_custom_metadata_enabled()); + } + + #[test] + fn test_metadata_flags_independent() { + let mut model = test_model(); + model.config = serde_json::json!({ + "_codex": { "sendTags": true, "sendCustomMetadata": true } + }); + // Each flag is read independently; only the enabled ones are true. + assert!(model.send_tags_enabled()); + assert!(model.send_custom_metadata_enabled()); + assert!(!model.send_genres_enabled()); + assert!(!model.send_metadata_enabled()); + } } diff --git a/crates/codex-models/src/plugin.rs b/crates/codex-models/src/plugin.rs index 34cccff7..28cb6f0a 100644 --- a/crates/codex-models/src/plugin.rs +++ b/crates/codex-models/src/plugin.rs @@ -111,6 +111,13 @@ pub struct PluginCapabilities { /// Can provide personalized recommendations (v2) #[serde(default)] pub user_recommendation_provider: bool, + /// Whether this plugin consumes enriched series data (tags, genres, + /// bibliographic metadata, custom metadata) on the entries it receives. + /// When set, the host exposes the per-field `_codex.send*` toggles and only + /// then attaches the opted-in data to sync/recommendation entries. Plugins + /// that don't declare this never pay the assembly or payload cost. + #[serde(default)] + pub wants_full_metadata: bool, /// Can announce new releases (chapters/volumes) for tracked series. /// When present, the plugin may invoke the `releases/*` reverse-RPC /// methods. The capability struct declares the data the plugin needs diff --git a/crates/codex-services/src/plugin/sync.rs b/crates/codex-services/src/plugin/sync.rs index ec0bd4a7..1dedd2f5 100644 --- a/crates/codex-services/src/plugin/sync.rs +++ b/crates/codex-services/src/plugin/sync.rs @@ -111,6 +111,16 @@ pub struct SyncEntry { /// push; empty on pulled entries. #[serde(default)] pub library_name: String, + /// Series genres (top-level taxonomy). Populated on push only when the user + /// enables `sendGenres` for a `wantsFullMetadata` plugin; empty otherwise and + /// on pulled entries. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub genres: Vec, + /// Series tags (top-level taxonomy). Populated on push only when the user + /// enables `sendTags` for a `wantsFullMetadata` plugin; empty otherwise and + /// on pulled entries. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, /// Bibliographic metadata, attached on push only when the plugin declares /// `wantsFullMetadata` and the user enables the `sendMetadata` toggle. Lets /// rule-based plugins act on summary/authors/age-rating/etc. Empty on pulled @@ -394,6 +404,8 @@ mod tests { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }; let json = serde_json::to_value(&entry).unwrap(); assert_eq!(json["externalId"], "12345"); @@ -514,6 +526,8 @@ mod tests { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }, SyncEntry { external_id: "2".to_string(), @@ -529,6 +543,8 @@ mod tests { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }, ], }; @@ -659,6 +675,8 @@ mod tests { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }], next_cursor: Some("page2".to_string()), has_more: true, @@ -756,6 +774,8 @@ mod tests { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }; let json = serde_json::to_value(&entry).unwrap(); assert_eq!(json["title"], "Berserk"); @@ -778,6 +798,8 @@ mod tests { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }; let json = serde_json::to_value(&entry).unwrap(); assert!(!json.as_object().unwrap().contains_key("title")); @@ -821,6 +843,8 @@ mod tests { library_name: "Manga".to_string(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }; let json = serde_json::to_value(&entry).unwrap(); assert_eq!(json["libraryId"], "11111111-1111-1111-1111-111111111111"); diff --git a/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs b/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs index dfcf7ddb..74dc1905 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs @@ -17,11 +17,18 @@ use uuid::Uuid; use crate::handlers::TaskHandler; use crate::types::TaskResult; use codex_db::entities::tasks; -use codex_db::repositories::{PluginsRepository, UserPluginDataRepository, UserPluginsRepository}; +use codex_db::entities::user_plugins::{ + CODEX_CONFIG_NAMESPACE, SEND_CUSTOM_METADATA_KEY, SEND_METADATA_KEY, +}; +use codex_db::repositories::{ + PluginsRepository, SeriesRepository, UserPluginDataRepository, UserPluginsRepository, +}; use codex_events::EventBroadcaster; use codex_services::SettingsService; use codex_services::plugin::PluginManager; -use codex_services::plugin::library::build_user_library; +use codex_services::plugin::library::{ + EngagementOptions, build_series_engagements, build_user_library, +}; use codex_services::plugin::protocol::{ PluginManifest, UserLibraryEntry, UserReadingStatus, methods, }; @@ -36,9 +43,6 @@ const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; // Codex Recommendation Settings // ============================================================================= -/// JSON key for the Codex-reserved namespace in user plugin config. -const CODEX_CONFIG_NAMESPACE: &str = "_codex"; - /// Codex recommendation settings — server-interpreted preferences that control /// seed curation and result limits. Stored in `config._codex` on the user plugin. /// The plugin never reads these; they control server-side behavior. @@ -52,6 +56,12 @@ struct CodexRecommendationSettings { /// Stored in config as 0-10 (display scale), converted by multiplying by 10. /// Default: 0 (no threshold). drop_threshold: i32, + /// Attach the bibliographic `metadata` block to seed entries. Default: false. + /// (genres/tags are always sent on recommendation entries, so there are no + /// separate tag/genre toggles here.) + send_metadata: bool, + /// Attach user-defined `custom_metadata` to seed entries. Default: false. + send_custom_metadata: bool, } impl CodexRecommendationSettings { @@ -82,10 +92,21 @@ impl CodexRecommendationSettings { .map(|v| v.clamp(0, 100)) .unwrap_or(0); + let send_metadata = codex + .get(SEND_METADATA_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let send_custom_metadata = codex + .get(SEND_CUSTOM_METADATA_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + Self { max_recommendations, max_seeds, drop_threshold, + send_metadata, + send_custom_metadata, } } } @@ -146,6 +167,50 @@ fn curate_seeds( .collect() } +/// Attach opt-in enrichment to the curated seeds. +/// +/// `send_metadata` adds the bibliographic `metadata` block; `send_custom_metadata` +/// adds the user-defined `custom_metadata`. Only the seeds (≤ `max_seeds`) are +/// enriched, not the whole library, so the extra fetch/parse stays bounded. +/// genres/tags are already present on every recommendation entry, so they are not +/// gated here. +async fn attach_seed_metadata( + db: &DatabaseConnection, + user_id: Uuid, + seeds: &mut [UserLibraryEntry], + send_metadata: bool, + send_custom_metadata: bool, +) { + if (!send_metadata && !send_custom_metadata) || seeds.is_empty() { + return; + } + + let series_ids: Vec = seeds + .iter() + .filter_map(|s| Uuid::parse_str(&s.series_id).ok()) + .collect(); + let series = SeriesRepository::get_by_ids(db, &series_ids) + .await + .unwrap_or_default(); + let engagements = build_series_engagements(db, user_id, &series, EngagementOptions::default()) + .await + .unwrap_or_default(); + + for seed in seeds.iter_mut() { + let Ok(series_id) = Uuid::parse_str(&seed.series_id) else { + continue; + }; + if let Some(e) = engagements.get(&series_id) { + if send_metadata { + seed.metadata = e.series_metadata_block(); + } + if send_custom_metadata { + seed.custom_metadata = e.custom_metadata_value(); + } + } + } +} + /// Handler for user plugin recommendation refresh tasks pub struct UserPluginRecommendationsHandler { plugin_manager: Arc, @@ -374,7 +439,7 @@ impl TaskHandler for UserPluginRecommendationsHandler { ); // Curate seeds from library: rated entries first, then recent reads - let seeds = curate_seeds(&library, &rec_settings); + let mut seeds = curate_seeds(&library, &rec_settings); debug!( "Task {}: Curated {} seeds from {} library entries (threshold={}, max_seeds={})", @@ -385,6 +450,24 @@ impl TaskHandler for UserPluginRecommendationsHandler { rec_settings.max_seeds ); + // Attach opt-in enrichment to the seeds when the plugin declares the + // capability and the user enabled the toggle. (genres/tags already ride + // on every recommendation entry, so only the two new toggles apply.) + let wants_full_metadata = plugin_model + .as_ref() + .and_then(|p| p.manifest.as_ref()) + .and_then(|m| serde_json::from_value::(m.clone()).ok()) + .map(|m| m.capabilities.wants_full_metadata) + .unwrap_or(false); + attach_seed_metadata( + db, + user_id, + &mut seeds, + wants_full_metadata && rec_settings.send_metadata, + wants_full_metadata && rec_settings.send_custom_metadata, + ) + .await; + // Call recommendations/get with curated seeds (not the full library) let request = RecommendationRequest { library: seeds, diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs index 12b94e71..a2996b79 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs @@ -269,11 +269,26 @@ impl TaskHandler for UserPluginSyncHandler { }; // Resolve the external ID source from the plugin manifest - let external_id_source = handle - .manifest() - .await + let manifest = handle.manifest().await; + let external_id_source = manifest + .as_ref() .and_then(|m| m.capabilities.external_id_source.clone()); + // Effective per-field metadata-enrichment flags: the plugin must + // declare `wantsFullMetadata` AND the user must opt in per field. + // Off-by-default, so connections that don't opt in push today's + // minimal payload unchanged. + let wants_full_metadata = manifest + .as_ref() + .map(|m| m.capabilities.wants_full_metadata) + .unwrap_or(false); + let metadata_flags = push::MetadataFlags { + tags: wants_full_metadata && codex_settings.send_tags, + genres: wants_full_metadata && codex_settings.send_genres, + metadata: wants_full_metadata && codex_settings.send_metadata, + custom_metadata: wants_full_metadata && codex_settings.send_custom_metadata, + }; + if let Some(ref source) = external_id_source { debug!( "Task {}: Plugin declares externalIdSource: {}", @@ -365,6 +380,7 @@ impl TaskHandler for UserPluginSyncHandler { task.id, &codex_settings, &allowed_library_ids, + metadata_flags, ) .await } else { diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs index 45c2c33f..5e032535 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs @@ -17,6 +17,24 @@ use codex_services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; use super::settings::CodexSyncSettings; +/// Effective per-field metadata-enrichment flags for a push (capability AND the +/// user's `_codex.send*` toggle, computed by the caller). Each gates one piece of +/// optional data attached to every push entry. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct MetadataFlags { + pub tags: bool, + pub genres: bool, + pub metadata: bool, + pub custom_metadata: bool, +} + +impl MetadataFlags { + /// Whether any taxonomy (genres/tags) must be fetched for this push. + fn needs_taxonomy(&self) -> bool { + self.tags || self.genres + } +} + /// Whether a series in `library_id` is in scope for a plugin allowed to act on /// `allowed_library_ids`. An empty allowed set means "all libraries". fn library_in_scope(allowed_library_ids: &[Uuid], library_id: Uuid) -> bool { @@ -50,6 +68,7 @@ fn project_sync_entry( external_id: String, title: Option, settings: &CodexSyncSettings, + flags: MetadataFlags, ) -> Option { // Skip series with no progress at all. if !e.has_any_progress() { @@ -131,9 +150,27 @@ fn project_sync_entry( title, library_id: e.library_id.to_string(), library_name: e.library_name.clone(), - // Enrichment is wired in a later phase; entries carry no metadata yet. - metadata: None, - custom_metadata: None, + // Per-field enrichment, each gated by its effective flag. + genres: if flags.genres { + e.genres.clone() + } else { + Vec::new() + }, + tags: if flags.tags { + e.tags.clone() + } else { + Vec::new() + }, + metadata: if flags.metadata { + e.series_metadata_block() + } else { + None + }, + custom_metadata: if flags.custom_metadata { + e.custom_metadata_value() + } else { + None + }, }) } @@ -153,6 +190,7 @@ pub(crate) async fn build_push_entries( task_id: Uuid, settings: &CodexSyncSettings, allowed_library_ids: &[Uuid], + flags: MetadataFlags, ) -> Vec { // 1. Get all series that have external IDs for this source. let external_ids = @@ -182,17 +220,19 @@ pub(crate) async fn build_push_entries( // build the shared engagement aggregates in one batched pass. let candidate_series_ids: Vec = external_ids.iter().map(|e| e.series_id).collect(); let series = scoped_series(db, &candidate_series_ids, allowed_library_ids).await; - let engagements = - match build_series_engagements(db, user_id, &series, EngagementOptions::default()).await { - Ok(map) => map, - Err(e) => { - warn!( - "Task {}: Failed to build series engagements for push: {}", - task_id, e - ); - return vec![]; - } - }; + let opts = EngagementOptions { + include_taxonomy: flags.needs_taxonomy(), + }; + let engagements = match build_series_engagements(db, user_id, &series, opts).await { + Ok(map) => map, + Err(e) => { + warn!( + "Task {}: Failed to build series engagements for push: {}", + task_id, e + ); + return vec![]; + } + }; // Series we matched by external ID (and that are in scope) — used to exclude // them from the search-fallback pass below. @@ -206,7 +246,9 @@ pub(crate) async fn build_push_entries( }; // Matched entries carry a metadata-only title (no series-name fallback). let title = e.metadata.as_ref().map(|m| m.title.clone()); - if let Some(entry) = project_sync_entry(e, ext_id.external_id.clone(), title, settings) { + if let Some(entry) = + project_sync_entry(e, ext_id.external_id.clone(), title, settings, flags) + { entries.push(entry); } } @@ -228,6 +270,7 @@ pub(crate) async fn build_push_entries( settings, &matched_series_ids, allowed_library_ids, + flags, ) .await; @@ -253,6 +296,7 @@ async fn build_unmatched_entries( settings: &CodexSyncSettings, matched_series_ids: &HashSet, allowed_library_ids: &[Uuid], + flags: MetadataFlags, ) -> Vec { // 1. Get all reading progress for this user, then map books → series. let all_progress = match ReadProgressRepository::get_by_user(db, user_id).await { @@ -297,17 +341,19 @@ async fn build_unmatched_entries( // 2. Resolve series rows, drop out-of-scope ones, build engagements. let unmatched_ids_vec: Vec = unmatched_series_ids.into_iter().collect(); let series = scoped_series(db, &unmatched_ids_vec, allowed_library_ids).await; - let engagements = - match build_series_engagements(db, user_id, &series, EngagementOptions::default()).await { - Ok(map) => map, - Err(e) => { - warn!( - "Task {}: Failed to build series engagements for unmatched series: {}", - task_id, e - ); - return vec![]; - } - }; + let opts = EngagementOptions { + include_taxonomy: flags.needs_taxonomy(), + }; + let engagements = match build_series_engagements(db, user_id, &series, opts).await { + Ok(map) => map, + Err(e) => { + warn!( + "Task {}: Failed to build series engagements for unmatched series: {}", + task_id, e + ); + return vec![]; + } + }; // 3. Project each unmatched, in-scope series with metadata into a SyncEntry. let mut entries = Vec::new(); @@ -320,7 +366,7 @@ async fn build_unmatched_entries( Some(m) => m.title.clone(), None => continue, }; - if let Some(entry) = project_sync_entry(e, String::new(), Some(title), settings) { + if let Some(entry) = project_sync_entry(e, String::new(), Some(title), settings, flags) { entries.push(entry); } } diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/settings.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/settings.rs index 5cad01f2..b5803f52 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/settings.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/settings.rs @@ -8,7 +8,10 @@ /// `autoSync`). The plugin itself never reads this namespace — it controls /// server behavior. Defined once in `codex-db` so the entity accessor /// (`user_plugins::Model::auto_sync_enabled`) and this parser stay in sync. -pub(crate) use codex_db::entities::user_plugins::CODEX_CONFIG_NAMESPACE; +pub(crate) use codex_db::entities::user_plugins::{ + CODEX_CONFIG_NAMESPACE, SEND_CUSTOM_METADATA_KEY, SEND_GENRES_KEY, SEND_METADATA_KEY, + SEND_TAGS_KEY, +}; /// Codex generic sync settings — server-interpreted preferences that control /// which entries to build and send to the plugin. Stored in the user plugin @@ -30,6 +33,14 @@ pub(crate) struct CodexSyncSettings { /// When enabled, entries with `external_id: ""` and `title` populated are /// sent so the plugin can search the external service by title. Default: false. pub search_fallback: bool, + /// Attach series `tags` to push entries (top-level). Default: false. + pub send_tags: bool, + /// Attach series `genres` to push entries (top-level). Default: false. + pub send_genres: bool, + /// Attach the bibliographic `metadata` block to push entries. Default: false. + pub send_metadata: bool, + /// Attach user-defined `custom_metadata` to push entries. Default: false. + pub send_custom_metadata: bool, } impl CodexSyncSettings { @@ -73,6 +84,22 @@ impl CodexSyncSettings { .get("searchFallback") .and_then(|v| v.as_bool()) .unwrap_or(false), + send_tags: codex + .get(SEND_TAGS_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false), + send_genres: codex + .get(SEND_GENRES_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false), + send_metadata: codex + .get(SEND_METADATA_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false), + send_custom_metadata: codex + .get(SEND_CUSTOM_METADATA_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false), } } } diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs index 6aed63f7..a14718e1 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs @@ -3,8 +3,9 @@ use chrono::Utc; use codex_db::ScanningStrategy; use codex_db::entities::{books, users}; use codex_db::repositories::{ - BookRepository, LibraryRepository, ReadProgressRepository, SeriesExternalIdRepository, - SeriesMetadataRepository, SeriesRepository, UserRepository, UserSeriesRatingRepository, + BookRepository, GenreRepository, LibraryRepository, ReadProgressRepository, + SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, TagRepository, + UserRepository, UserSeriesRatingRepository, }; use codex_db::test_helpers::create_test_db; use codex_services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; @@ -270,6 +271,8 @@ async fn test_match_and_apply_no_source() { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -332,6 +335,8 @@ async fn test_match_and_apply_with_matches() { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }, SyncEntry { external_id: "99999".to_string(), // no match @@ -347,6 +352,8 @@ async fn test_match_and_apply_with_matches() { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }, ]; @@ -420,6 +427,8 @@ async fn test_match_and_apply_pulled_entries_applies_progress() { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -521,6 +530,8 @@ async fn test_match_and_apply_skips_already_read() { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -554,6 +565,8 @@ fn pulled_completed_entry(external_id: &str) -> SyncEntry { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), } } @@ -606,6 +619,7 @@ async fn test_build_push_entries_respects_library_scope() { Uuid::new_v4(), &default_codex_settings(), &[allowed], + push::MetadataFlags::default(), ) .await; @@ -621,6 +635,7 @@ async fn test_build_push_entries_respects_library_scope() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; assert_eq!(entries_all.len(), 2); @@ -733,6 +748,104 @@ async fn test_pull_skips_out_of_scope_library() { ); } +#[tokio::test] +async fn test_build_push_entries_metadata_flags() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let library = LibraryRepository::create(conn, "Lib", "/p", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, library.id, "Flagged Manga", None) + .await + .unwrap(); + let book = create_test_book(conn, series.id, library.id, 1, 100).await; + let user = create_test_user(conn).await; + ReadProgressRepository::mark_as_read(conn, user.id, book.id, 100) + .await + .unwrap(); + SeriesExternalIdRepository::create(conn, series.id, "api:anilist", "555", None, None) + .await + .unwrap(); + + // Enrichment source data on the series. + GenreRepository::set_genres_for_series( + conn, + series.id, + vec!["Action".to_string(), "Dark Fantasy".to_string()], + ) + .await + .unwrap(); + TagRepository::set_tags_for_series(conn, series.id, vec!["Gore".to_string()]) + .await + .unwrap(); + SeriesMetadataRepository::update_summary(conn, series.id, Some("A grim tale".to_string())) + .await + .unwrap(); + + let source = "api:anilist"; + + // All flags off → today's minimal payload (no enrichment). + let off = push::build_push_entries( + conn, + user.id, + source, + Uuid::new_v4(), + &default_codex_settings(), + &[], + push::MetadataFlags::default(), + ) + .await; + assert_eq!(off.len(), 1); + assert!(off[0].genres.is_empty()); + assert!(off[0].tags.is_empty()); + assert!(off[0].metadata.is_none()); + + // All flags on → each enrichment field is attached. + let flags = push::MetadataFlags { + tags: true, + genres: true, + metadata: true, + custom_metadata: true, + }; + let on = push::build_push_entries( + conn, + user.id, + source, + Uuid::new_v4(), + &default_codex_settings(), + &[], + flags, + ) + .await; + assert_eq!(on.len(), 1); + assert_eq!(on[0].genres.len(), 2); + assert!(on[0].genres.iter().any(|g| g == "Action")); + assert!(on[0].genres.iter().any(|g| g == "Dark Fantasy")); + assert_eq!(on[0].tags, vec!["Gore".to_string()]); + let meta = on[0].metadata.as_ref().expect("metadata block attached"); + assert_eq!(meta.summary.as_deref(), Some("A grim tale")); + + // Only one flag on → only that field attaches. + let genres_only = push::MetadataFlags { + genres: true, + ..Default::default() + }; + let entries = push::build_push_entries( + conn, + user.id, + source, + Uuid::new_v4(), + &default_codex_settings(), + &[], + genres_only, + ) + .await; + assert_eq!(entries[0].genres.len(), 2); + assert!(entries[0].tags.is_empty()); + assert!(entries[0].metadata.is_none()); +} + /// Default Codex sync settings for tests (matches production defaults) fn default_codex_settings() -> CodexSyncSettings { CodexSyncSettings { @@ -741,6 +854,10 @@ fn default_codex_settings() -> CodexSyncSettings { count_partial_progress: false, sync_ratings: true, search_fallback: false, + send_tags: false, + send_genres: false, + send_metadata: false, + send_custom_metadata: false, } } @@ -797,6 +914,7 @@ async fn test_build_push_entries_with_progress() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -863,6 +981,7 @@ async fn test_build_push_entries_all_completed() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -916,6 +1035,7 @@ async fn test_build_push_entries_skips_no_progress() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -1107,6 +1227,7 @@ async fn test_build_push_entries_skip_completed_series() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -1166,6 +1287,7 @@ async fn test_build_push_entries_skip_in_progress_series() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -1234,6 +1356,7 @@ async fn test_build_push_entries_count_in_progress_volumes() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; assert_eq!(entries.len(), 1); @@ -1253,6 +1376,7 @@ async fn test_build_push_entries_count_in_progress_volumes() { Uuid::new_v4(), &settings_with_partial, &[], + push::MetadataFlags::default(), ) .await; assert_eq!(entries.len(), 1); @@ -1305,6 +1429,8 @@ async fn test_apply_pulled_entry_uses_volumes() { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }; // Build pre-fetched maps for apply_pulled_entry (via match_and_apply which calls it) @@ -1429,6 +1555,7 @@ async fn test_build_push_entries_includes_rating() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -1491,6 +1618,7 @@ async fn test_build_push_entries_no_rating_when_disabled() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -1547,6 +1675,7 @@ async fn test_build_push_entries_no_rating_for_unrated() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -1607,6 +1736,8 @@ async fn test_apply_pulled_rating_no_existing() { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (matched, _applied) = pull::match_and_apply_pulled_entries( @@ -1701,6 +1832,8 @@ async fn test_apply_pulled_rating_existing_not_overwritten() { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (_matched, _applied) = pull::match_and_apply_pulled_entries( @@ -1780,6 +1913,8 @@ async fn test_apply_pulled_rating_disabled() { library_name: String::new(), metadata: None, custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (_matched, _applied) = pull::match_and_apply_pulled_entries( @@ -1852,6 +1987,7 @@ async fn test_build_push_entries_populates_latest_updated_at() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -1920,6 +2056,7 @@ async fn test_build_push_entries_populates_total_volumes() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -1982,6 +2119,7 @@ async fn test_build_push_entries_always_sends_volumes() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -2085,6 +2223,7 @@ async fn test_build_push_entries_includes_unmatched_with_search_fallback() { Uuid::new_v4(), &settings_no_fallback, &[], + push::MetadataFlags::default(), ) .await; assert_eq!( @@ -2106,6 +2245,7 @@ async fn test_build_push_entries_includes_unmatched_with_search_fallback() { Uuid::new_v4(), &settings_with_fallback, &[], + push::MetadataFlags::default(), ) .await; assert_eq!( @@ -2169,6 +2309,7 @@ async fn test_build_push_entries_unmatched_skips_no_metadata() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -2225,6 +2366,7 @@ async fn test_build_push_entries_populates_title_for_matched() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; diff --git a/docs/api/openapi.json b/docs/api/openapi.json index c2f26ca6..ef98d08b 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -15265,6 +15265,59 @@ } } }, + "/api/v1/user/plugins/{plugin_id}/metadata-settings": { + "patch": { + "tags": [ + "User Plugins" + ], + "summary": "Set a connection's metadata-enrichment opt-ins (tags/genres/metadata/custom).", + "description": "Writes the host-only `config._codex.send*` flags (the plugin never reads\nthem). Each is a partial update: only provided fields change, and other\n`_codex` keys are preserved. Only allowed for plugins whose manifest declares\nthe `wantsFullMetadata` capability.", + "operationId": "set_metadata_settings", + "parameters": [ + { + "name": "plugin_id", + "in": "path", + "description": "Plugin ID to set metadata settings for", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetMetadataSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Metadata settings updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPluginDto" + } + } + } + }, + "400": { + "description": "Plugin does not consume full metadata" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "Plugin not enabled for this user" + } + } + } + }, "/api/v1/user/plugins/{plugin_id}/oauth/start": { "post": { "tags": [ @@ -38763,6 +38816,40 @@ } } }, + "SetMetadataSettingsRequest": { + "type": "object", + "description": "Request to set a connection's metadata-enrichment opt-ins.\n\nEach field is optional; only the provided ones are updated (partial update),\nand other `_codex` settings are preserved. Only meaningful for plugins that\ndeclare the `wantsFullMetadata` capability.", + "properties": { + "sendCustomMetadata": { + "type": [ + "boolean", + "null" + ], + "description": "Send user-defined custom metadata to the plugin." + }, + "sendGenres": { + "type": [ + "boolean", + "null" + ], + "description": "Send series genres (top-level) to the plugin." + }, + "sendMetadata": { + "type": [ + "boolean", + "null" + ], + "description": "Send the bibliographic metadata block to the plugin." + }, + "sendTags": { + "type": [ + "boolean", + "null" + ], + "description": "Send series tags (top-level) to the plugin." + } + } + }, "SetPreferenceRequest": { "type": "object", "description": "Request to set a single preference value", @@ -42337,7 +42424,8 @@ "description": "Plugin capabilities for display (user plugin context)", "required": [ "readSync", - "userRecommendationProvider" + "userRecommendationProvider", + "wantsFullMetadata" ], "properties": { "readSync": { @@ -42347,6 +42435,10 @@ "userRecommendationProvider": { "type": "boolean", "description": "Can provide recommendations" + }, + "wantsFullMetadata": { + "type": "boolean", + "description": "Consumes enriched series data; gates whether the `_codex.send*` metadata\ntoggles are shown on the connection." } } }, @@ -42366,6 +42458,10 @@ "oauthConfigured", "config", "autoSync", + "sendTags", + "sendGenres", + "sendMetadata", + "sendCustomMetadata", "capabilities", "createdAt" ], @@ -42468,6 +42564,19 @@ "type": "boolean", "description": "Whether this plugin requires OAuth authentication" }, + "sendCustomMetadata": { + "type": "boolean" + }, + "sendGenres": { + "type": "boolean" + }, + "sendMetadata": { + "type": "boolean" + }, + "sendTags": { + "type": "boolean", + "description": "Per-field metadata-enrichment opt-ins (host-side, from `config._codex.send*`).\nOnly meaningful when the plugin declares `wantsFullMetadata`; all default false." + }, "userConfigSchema": { "oneOf": [ { diff --git a/tests/api/user_plugins.rs b/tests/api/user_plugins.rs index db0e1502..67fd5546 100644 --- a/tests/api/user_plugins.rs +++ b/tests/api/user_plugins.rs @@ -160,6 +160,32 @@ async fn create_sync_plugin( plugin.id } +/// Create a sync plugin that also declares the `wantsFullMetadata` capability, +/// so the metadata-enrichment toggles apply. Returns its ID. +async fn create_metadata_plugin( + db: &sea_orm::DatabaseConnection, + name: &str, + display_name: &str, +) -> uuid::Uuid { + let plugin_id = create_sync_plugin(db, name, display_name).await; + let manifest = json!({ + "name": name, + "displayName": display_name, + "version": "1.0.0", + "protocolVersion": "1.0", + "pluginType": "user", + "capabilities": { + "userReadSync": true, + "wantsFullMetadata": true + }, + "userDescription": "Sync with metadata enrichment" + }); + PluginsRepository::update_manifest(db, plugin_id, Some(manifest)) + .await + .unwrap(); + plugin_id +} + /// Create a system-type plugin (admin operation) and return its ID async fn create_system_type_plugin(db: &sea_orm::DatabaseConnection, name: &str) -> uuid::Uuid { let plugin = PluginsRepository::create( @@ -1689,3 +1715,97 @@ async fn test_set_sync_mode_requires_enabled_connection() { make_json_request(app, request).await; assert_eq!(status, StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn test_set_metadata_settings_partial_update_and_independent() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_user_and_token(&db, &state, "metauser").await; + let plugin_id = create_metadata_plugin(&db, "meta-plugin", "Meta Plugin").await; + enable_sync_plugin_for_user(&state, &token, plugin_id).await; + + // Enable two of the four toggles. + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/metadata-settings", plugin_id), + &json!({ "sendTags": true, "sendCustomMetadata": true }), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + let dto = dto.expect("metadata-settings body"); + assert!(dto.send_tags); + assert!(dto.send_custom_metadata); + assert!(!dto.send_genres); + assert!(!dto.send_metadata); + assert_eq!(dto.config["_codex"]["sendTags"], json!(true)); + + // Partial update: turning sendTags off must not touch sendCustomMetadata. + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/metadata-settings", plugin_id), + &json!({ "sendTags": false }), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + let dto = dto.expect("metadata-settings body"); + assert!(!dto.send_tags); + assert!( + dto.send_custom_metadata, + "untouched toggle must be preserved" + ); +} + +#[tokio::test] +async fn test_set_metadata_settings_rejected_for_non_capable_plugin() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_user_and_token(&db, &state, "metauser2").await; + // Plain sync plugin without wantsFullMetadata. + let plugin_id = create_sync_plugin(&db, "plain-sync", "Plain Sync").await; + enable_sync_plugin_for_user(&state, &token, plugin_id).await; + + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/metadata-settings", plugin_id), + &json!({ "sendTags": true }), + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_set_metadata_settings_preserves_other_codex_settings() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_user_and_token(&db, &state, "metauser3").await; + let plugin_id = create_metadata_plugin(&db, "meta-preserve", "Meta Preserve").await; + enable_sync_plugin_for_user(&state, &token, plugin_id).await; + + // First opt into auto sync (writes config._codex.autoSync). + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/sync-mode", plugin_id), + &json!({ "auto": true }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + + // Now set a metadata toggle; autoSync must survive. + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/user/plugins/{}/metadata-settings", plugin_id), + &json!({ "sendMetadata": true }), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + let dto = dto.expect("metadata-settings body"); + assert!(dto.send_metadata); + assert!(dto.auto_sync, "autoSync sibling must be preserved"); + assert_eq!(dto.config["_codex"]["autoSync"], json!(true)); +} diff --git a/web/openapi.json b/web/openapi.json index c2f26ca6..ef98d08b 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -15265,6 +15265,59 @@ } } }, + "/api/v1/user/plugins/{plugin_id}/metadata-settings": { + "patch": { + "tags": [ + "User Plugins" + ], + "summary": "Set a connection's metadata-enrichment opt-ins (tags/genres/metadata/custom).", + "description": "Writes the host-only `config._codex.send*` flags (the plugin never reads\nthem). Each is a partial update: only provided fields change, and other\n`_codex` keys are preserved. Only allowed for plugins whose manifest declares\nthe `wantsFullMetadata` capability.", + "operationId": "set_metadata_settings", + "parameters": [ + { + "name": "plugin_id", + "in": "path", + "description": "Plugin ID to set metadata settings for", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetMetadataSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Metadata settings updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPluginDto" + } + } + } + }, + "400": { + "description": "Plugin does not consume full metadata" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "Plugin not enabled for this user" + } + } + } + }, "/api/v1/user/plugins/{plugin_id}/oauth/start": { "post": { "tags": [ @@ -38763,6 +38816,40 @@ } } }, + "SetMetadataSettingsRequest": { + "type": "object", + "description": "Request to set a connection's metadata-enrichment opt-ins.\n\nEach field is optional; only the provided ones are updated (partial update),\nand other `_codex` settings are preserved. Only meaningful for plugins that\ndeclare the `wantsFullMetadata` capability.", + "properties": { + "sendCustomMetadata": { + "type": [ + "boolean", + "null" + ], + "description": "Send user-defined custom metadata to the plugin." + }, + "sendGenres": { + "type": [ + "boolean", + "null" + ], + "description": "Send series genres (top-level) to the plugin." + }, + "sendMetadata": { + "type": [ + "boolean", + "null" + ], + "description": "Send the bibliographic metadata block to the plugin." + }, + "sendTags": { + "type": [ + "boolean", + "null" + ], + "description": "Send series tags (top-level) to the plugin." + } + } + }, "SetPreferenceRequest": { "type": "object", "description": "Request to set a single preference value", @@ -42337,7 +42424,8 @@ "description": "Plugin capabilities for display (user plugin context)", "required": [ "readSync", - "userRecommendationProvider" + "userRecommendationProvider", + "wantsFullMetadata" ], "properties": { "readSync": { @@ -42347,6 +42435,10 @@ "userRecommendationProvider": { "type": "boolean", "description": "Can provide recommendations" + }, + "wantsFullMetadata": { + "type": "boolean", + "description": "Consumes enriched series data; gates whether the `_codex.send*` metadata\ntoggles are shown on the connection." } } }, @@ -42366,6 +42458,10 @@ "oauthConfigured", "config", "autoSync", + "sendTags", + "sendGenres", + "sendMetadata", + "sendCustomMetadata", "capabilities", "createdAt" ], @@ -42468,6 +42564,19 @@ "type": "boolean", "description": "Whether this plugin requires OAuth authentication" }, + "sendCustomMetadata": { + "type": "boolean" + }, + "sendGenres": { + "type": "boolean" + }, + "sendMetadata": { + "type": "boolean" + }, + "sendTags": { + "type": "boolean", + "description": "Per-field metadata-enrichment opt-ins (host-side, from `config._codex.send*`).\nOnly meaningful when the plugin declares `wantsFullMetadata`; all default false." + }, "userConfigSchema": { "oneOf": [ { diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index dc70964c..013abb81 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -5205,6 +5205,29 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/user/plugins/{plugin_id}/metadata-settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Set a connection's metadata-enrichment opt-ins (tags/genres/metadata/custom). + * @description Writes the host-only `config._codex.send*` flags (the plugin never reads + * them). Each is a partial update: only provided fields change, and other + * `_codex` keys are preserved. Only allowed for plugins whose manifest declares + * the `wantsFullMetadata` capability. + */ + patch: operations["set_metadata_settings"]; + trace?: never; + }; "/api/v1/user/plugins/{plugin_id}/oauth/start": { parameters: { query?: never; @@ -17778,6 +17801,23 @@ export interface components { */ updatedAt: string; }; + /** + * @description Request to set a connection's metadata-enrichment opt-ins. + * + * Each field is optional; only the provided ones are updated (partial update), + * and other `_codex` settings are preserved. Only meaningful for plugins that + * declare the `wantsFullMetadata` capability. + */ + SetMetadataSettingsRequest: { + /** @description Send user-defined custom metadata to the plugin. */ + sendCustomMetadata?: boolean | null; + /** @description Send series genres (top-level) to the plugin. */ + sendGenres?: boolean | null; + /** @description Send the bibliographic metadata block to the plugin. */ + sendMetadata?: boolean | null; + /** @description Send series tags (top-level) to the plugin. */ + sendTags?: boolean | null; + }; /** @description Request to set a single preference value */ SetPreferenceRequest: { /** @description The value to set */ @@ -19588,6 +19628,11 @@ export interface components { readSync: boolean; /** @description Can provide recommendations */ userRecommendationProvider: boolean; + /** + * @description Consumes enriched series data; gates whether the `_codex.send*` metadata + * toggles are shown on the connection. + */ + wantsFullMetadata: boolean; }; /** @description User plugin instance status */ UserPluginDto: { @@ -19650,6 +19695,14 @@ export interface components { pluginType: string; /** @description Whether this plugin requires OAuth authentication */ requiresOauth: boolean; + sendCustomMetadata: boolean; + sendGenres: boolean; + sendMetadata: boolean; + /** + * @description Per-field metadata-enrichment opt-ins (host-side, from `config._codex.send*`). + * Only meaningful when the plugin declares `wantsFullMetadata`; all default false. + */ + sendTags: boolean; userConfigSchema?: null | components["schemas"]["ConfigSchemaDto"]; /** @description User-facing setup instructions for the plugin */ userSetupInstructions?: string | null; @@ -31366,6 +31419,54 @@ export interface operations { }; }; }; + set_metadata_settings: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Plugin ID to set metadata settings for */ + plugin_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetMetadataSettingsRequest"]; + }; + }; + responses: { + /** @description Metadata settings updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPluginDto"]; + }; + }; + /** @description Plugin does not consume full metadata */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Plugin not enabled for this user */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; oauth_start: { parameters: { query?: never; From b2951bb6ae4ea0037c32f65919d81a989f67e6e6 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 20:34:49 -0700 Subject: [PATCH 09/27] feat(plugins): add metadata-enrichment toggles to the connection settings UI Surface the four per-field metadata opt-ins on a plugin connection. The settings modal gains a "Metadata Enrichment" section, shown only when the plugin declares the wantsFullMetadata capability, with switches for sending tags, genres, the bibliographic metadata block, and custom metadata. Tags and genres appear only for sync plugins (recommendation entries already carry them); descriptions call out that metadata (summaries) is the heavy option and that custom metadata can expose private fields. Toggles persist through the existing config-update path, preserving other _codex settings. Mock fixtures advertise the capability so the mock UI shows the section, and the plugin sync docs describe the capability and trade-offs. Adds component tests for the capability gating and initial state. --- docs/docs/plugins/anilist-sync.md | 18 +++ .../plugins/UserPluginSettingsModal.test.tsx | 103 ++++++++++++++++++ .../plugins/UserPluginSettingsModal.tsx | 97 ++++++++++++++++- web/src/mocks/handlers/userPlugins.ts | 13 +++ 4 files changed, 227 insertions(+), 4 deletions(-) diff --git a/docs/docs/plugins/anilist-sync.md b/docs/docs/plugins/anilist-sync.md index a36fc052..66427fed 100644 --- a/docs/docs/plugins/anilist-sync.md +++ b/docs/docs/plugins/anilist-sync.md @@ -140,6 +140,24 @@ These settings control which entries Codex sends to the plugin. They apply to al These settings are stored in the user plugin config under the `_codex` namespace (e.g., `_codex.includeCompleted`). The server reads them to filter which entries to build and send — this is the server's only role. The plugin never reads these settings. +### Metadata Enrichment + +Some plugins can act on richer series data than reading progress alone — for example, a rule-based sync plugin that only syncs series that aren't tagged a certain way. When a plugin declares the `wantsFullMetadata` capability, a **Metadata Enrichment** section appears on your connection (in **Settings** > **Integrations**), with four independent opt-ins. All are **off by default**, so nothing extra is sent unless you turn it on. + +| Option | Default | Sent data | +| ----------------------- | ------- | ------------------------------------------------------------------------------------------ | +| **Send tags** | Off | Each series' tags (small). Lets the plugin apply tag-based rules. | +| **Send genres** | Off | Each series' genres (small). | +| **Send metadata** | Off | Summary, authors, publisher, age rating, language, reading direction. The **heaviest** one. | +| **Send custom metadata**| Off | Your user-defined custom metadata fields. | + +Each toggle is separate so you control exactly how much data leaves your server: + +- **Tags and genres are cheap** — enable just these for tag/genre rules without shipping anything bulky. On a large library, **Send metadata** can add a lot (summaries are the big field), so leave it off unless the plugin needs it. +- **Send custom metadata is a privacy decision.** Custom metadata is a free-form, user-defined field that may hold private annotations. Only enable it for plugins you trust to receive that data. + +These are stored under the `_codex` namespace too (`_codex.sendTags`, `_codex.sendGenres`, `_codex.sendMetadata`, `_codex.sendCustomMetadata`) and are read only by the server when building entries. The tags/genres toggles only affect sync; recommendation plugins always receive genres and tags as part of their taste signal. + ### Plugin-Specific Settings These settings are specific to the AniList plugin and control how it interprets the data from Codex. Configure them in **Settings** > **Integrations** > **Plugin Settings**: diff --git a/web/src/components/plugins/UserPluginSettingsModal.test.tsx b/web/src/components/plugins/UserPluginSettingsModal.test.tsx index 037ca3d0..68efa4f3 100644 --- a/web/src/components/plugins/UserPluginSettingsModal.test.tsx +++ b/web/src/components/plugins/UserPluginSettingsModal.test.tsx @@ -228,6 +228,109 @@ describe("UserPluginSettingsModal", () => { expect(switches[3]).not.toBeChecked(); // syncRatings = false }); + it("shows all four metadata-enrichment toggles for a sync plugin with wantsFullMetadata", () => { + const plugin = makePlugin({ + capabilities: { + readSync: true, + userRecommendationProvider: false, + wantsFullMetadata: true, + }, + }); + + renderWithProviders( + , + ); + + expect(screen.getByText("Metadata Enrichment")).toBeInTheDocument(); + expect(screen.getByText("Send tags")).toBeInTheDocument(); + expect(screen.getByText("Send genres")).toBeInTheDocument(); + expect(screen.getByText("Send metadata")).toBeInTheDocument(); + expect(screen.getByText("Send custom metadata")).toBeInTheDocument(); + }); + + it("hides metadata-enrichment toggles when the plugin lacks wantsFullMetadata", () => { + const plugin = makePlugin({ + capabilities: { + readSync: true, + userRecommendationProvider: false, + wantsFullMetadata: false, + }, + }); + + renderWithProviders( + , + ); + + expect(screen.queryByText("Metadata Enrichment")).not.toBeInTheDocument(); + expect(screen.queryByText("Send tags")).not.toBeInTheDocument(); + }); + + it("hides tags/genres for a recommendation-only metadata plugin (they only apply to sync)", () => { + const plugin = makePlugin({ + capabilities: { + readSync: false, + userRecommendationProvider: true, + wantsFullMetadata: true, + }, + }); + + renderWithProviders( + , + ); + + expect(screen.getByText("Metadata Enrichment")).toBeInTheDocument(); + expect(screen.getByText("Send metadata")).toBeInTheDocument(); + expect(screen.getByText("Send custom metadata")).toBeInTheDocument(); + expect(screen.queryByText("Send tags")).not.toBeInTheDocument(); + expect(screen.queryByText("Send genres")).not.toBeInTheDocument(); + }); + + it("initialises metadata toggles from the _codex namespace", () => { + const plugin = makePlugin({ + config: { + _codex: { + sendTags: true, + sendGenres: false, + sendMetadata: true, + sendCustomMetadata: false, + }, + }, + capabilities: { + readSync: true, + userRecommendationProvider: false, + wantsFullMetadata: true, + }, + }); + + renderWithProviders( + , + ); + + // Switch order: the four sync settings (0-3) then the four metadata + // enrichment toggles (sendTags, sendGenres, sendMetadata, sendCustomMetadata). + const switches = screen.getAllByRole("switch"); + expect(switches[4]).toBeChecked(); // sendTags = true + expect(switches[5]).not.toBeChecked(); // sendGenres = false + expect(switches[6]).toBeChecked(); // sendMetadata = true + expect(switches[7]).not.toBeChecked(); // sendCustomMetadata = false + }); + it("shows Plugin Settings divider when plugin has config fields", () => { const plugin = makePlugin({ capabilities: { readSync: true, userRecommendationProvider: false }, diff --git a/web/src/components/plugins/UserPluginSettingsModal.tsx b/web/src/components/plugins/UserPluginSettingsModal.tsx index 1cd7ec5f..4dedfc17 100644 --- a/web/src/components/plugins/UserPluginSettingsModal.tsx +++ b/web/src/components/plugins/UserPluginSettingsModal.tsx @@ -56,6 +56,7 @@ function UserPluginSettingsContent({ const queryClient = useQueryClient(); const isSyncPlugin = plugin.capabilities?.readSync === true; const isRecPlugin = plugin.capabilities?.userRecommendationProvider === true; + const wantsFullMetadata = plugin.capabilities?.wantsFullMetadata === true; const configFields: ConfigField[] = (plugin.userConfigSchema?.fields as ConfigField[] | undefined) ?? []; const currentConfig = (plugin.config ?? {}) as Record; @@ -102,6 +103,25 @@ function UserPluginSettingsContent({ : 0; } + // Metadata-enrichment opt-ins (stored in config._codex.send*), shown only when + // the plugin declares the wantsFullMetadata capability. All default false. + if (wantsFullMetadata) { + initialValues._codex_sendTags = + typeof codexConfig.sendTags === "boolean" ? codexConfig.sendTags : false; + initialValues._codex_sendGenres = + typeof codexConfig.sendGenres === "boolean" + ? codexConfig.sendGenres + : false; + initialValues._codex_sendMetadata = + typeof codexConfig.sendMetadata === "boolean" + ? codexConfig.sendMetadata + : false; + initialValues._codex_sendCustomMetadata = + typeof codexConfig.sendCustomMetadata === "boolean" + ? codexConfig.sendCustomMetadata + : false; + } + for (const field of configFields) { initialValues[field.key] = currentConfig[field.key] ?? field.default ?? ""; } @@ -139,7 +159,22 @@ function UserPluginSettingsContent({ Object.assign(codex, recValues); } - if (isSyncPlugin || isRecPlugin) { + if (wantsFullMetadata) { + // tags/genres only meaningfully apply to sync (recommendation entries + // always carry them); metadata/custom apply to both. + Object.assign(codex, { + sendMetadata: !!form.values._codex_sendMetadata, + sendCustomMetadata: !!form.values._codex_sendCustomMetadata, + }); + if (isSyncPlugin) { + Object.assign(codex, { + sendTags: !!form.values._codex_sendTags, + sendGenres: !!form.values._codex_sendGenres, + }); + } + } + + if (isSyncPlugin || isRecPlugin || wantsFullMetadata) { config._codex = codex; } @@ -170,7 +205,8 @@ function UserPluginSettingsContent({ }, }); - const hasFields = isSyncPlugin || isRecPlugin || configFields.length > 0; + const hasFields = + isSyncPlugin || isRecPlugin || wantsFullMetadata || configFields.length > 0; return ( @@ -282,10 +318,63 @@ function UserPluginSettingsContent({ )} - {(isSyncPlugin || isRecPlugin) && configFields.length > 0 && ( - + {wantsFullMetadata && ( + <> + + + Send extra series data to this plugin. Everything here is off by + default; enable only what the plugin needs. + + {isSyncPlugin && ( + <> + + form.setFieldValue("_codex_sendTags", e.currentTarget.checked) + } + /> + + form.setFieldValue( + "_codex_sendGenres", + e.currentTarget.checked, + ) + } + /> + + )} + + form.setFieldValue("_codex_sendMetadata", e.currentTarget.checked) + } + /> + + form.setFieldValue( + "_codex_sendCustomMetadata", + e.currentTarget.checked, + ) + } + /> + )} + {(isSyncPlugin || isRecPlugin || wantsFullMetadata) && + configFields.length > 0 && ( + + )} + {configFields.map((field) => { const props = form.getInputProps(field.key); switch (field.type) { diff --git a/web/src/mocks/handlers/userPlugins.ts b/web/src/mocks/handlers/userPlugins.ts index 80fc4c66..a47b9c26 100644 --- a/web/src/mocks/handlers/userPlugins.ts +++ b/web/src/mocks/handlers/userPlugins.ts @@ -46,9 +46,15 @@ const anilistPlugin: UserPluginDto = { config: {}, userConfigSchema: null, userSetupInstructions: null, + autoSync: false, + sendTags: false, + sendGenres: false, + sendMetadata: false, + sendCustomMetadata: false, capabilities: { readSync: true, userRecommendationProvider: true, + wantsFullMetadata: true, }, createdAt: "2026-01-15T00:00:00Z", }; @@ -64,6 +70,7 @@ const mangabakaAvailable: AvailablePluginDto = { capabilities: { readSync: false, userRecommendationProvider: false, + wantsFullMetadata: false, }, }; @@ -130,9 +137,15 @@ export const userPluginsHandlers = [ config: {}, userConfigSchema: null, userSetupInstructions: null, + autoSync: false, + sendTags: false, + sendGenres: false, + sendMetadata: false, + sendCustomMetadata: false, capabilities: { readSync: false, userRecommendationProvider: false, + wantsFullMetadata: false, }, createdAt: new Date().toISOString(), }; From 17d0c86cb27a0c16f74648467e9e6dd516418dbe Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 20:48:53 -0700 Subject: [PATCH 10/27] feat(plugins): add detailed sync progress fields to the protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend SyncProgress with maxVolume/maxChapter — the highest read volume and chapter derived from Codex's per-book number detection — alongside a readBooks per-book breakdown, backed by a new SyncBookProgress struct carrying detected volume/chapter and page position. Unlike the existing `volumes` count (relative books-read), maxVolume and maxChapter stay accurate for libraries that don't start at volume 1 or have gaps, giving sync plugins the data to report absolute progress. readBooks exposes the raw per-book detail so authors of custom sync targets can map progress however their service expects. All new fields are additive and optional, omitted from the wire when unset, so existing plugins and the `volumes` field are unaffected. This lands the protocol contract only; computing and attaching the values in the push path is wired up separately. Includes serialization, round-trip, and backward-compatibility tests. --- crates/codex-services/src/plugin/sync.rs | 164 +++++++++++++++++- .../src/handlers/user_plugin_sync/push.rs | 3 + .../src/handlers/user_plugin_sync/tests.rs | 6 + 3 files changed, 171 insertions(+), 2 deletions(-) diff --git a/crates/codex-services/src/plugin/sync.rs b/crates/codex-services/src/plugin/sync.rs index 1dedd2f5..7f48753c 100644 --- a/crates/codex-services/src/plugin/sync.rs +++ b/crates/codex-services/src/plugin/sync.rs @@ -136,13 +136,18 @@ pub struct SyncEntry { } /// Reading progress details -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SyncProgress { /// Number of chapters read #[serde(default, skip_serializing_if = "Option::is_none")] pub chapters: Option, - /// Number of volumes read + /// Number of volumes read. + /// + /// This is the **relative** count of books the user has read in the series + /// (each file = 1 book), not an absolute volume number. Retained for + /// backward compatibility; consumers that want accurate progress for + /// libraries with gaps should prefer `max_volume`/`max_chapter`. #[serde(default, skip_serializing_if = "Option::is_none")] pub volumes: Option, /// Number of pages read (for single-volume works) @@ -154,6 +159,53 @@ pub struct SyncProgress { /// Total number of volumes in the series (if known) #[serde(default, skip_serializing_if = "Option::is_none")] pub total_volumes: Option, + + /// Highest **read** volume number, derived from Codex's per-book volume + /// detection. Unlike `volumes` (a count), this is the absolute highest + /// volume the user has reached, so it stays correct for libraries that do + /// not start at volume 1 or have gaps. Computed over the same set of books + /// that feeds `volumes` (completed always; in-progress when the user's + /// `countPartialProgress` setting is on). `None` when no counted book has a + /// detected volume number. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_volume: Option, + /// Highest **read** chapter number, derived from per-book chapter detection. + /// `f32` because chapters can be fractional (e.g. 47.5 for side chapters). + /// Same set/semantics as `max_volume`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_chapter: Option, + /// Per-book reading-progress breakdown, one entry per book that has reading + /// progress (completed or in-progress). Attached on push only when the + /// plugin declares the `wantsDetailedProgress` capability, so authors of + /// custom sync targets can map progress however their service expects. + /// `None` (and the key omitted) otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_books: Option>, +} + +/// Per-book reading progress, the unit of `SyncProgress::read_books`. +/// +/// Carries reading *position* (detected volume/chapter plus page progress), not +/// bibliographic metadata. All fields except `completed` are optional because +/// detection and page tracking may be absent for a given book. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncBookProgress { + /// Detected volume number for this book, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub volume: Option, + /// Detected chapter number for this book, if known (fractional allowed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chapter: Option, + /// Whether the user has finished this book. + pub completed: bool, + /// Current page within the book, if tracked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_page: Option, + /// Fractional progress within the book (0.0-1.0 or a percentage, as stored + /// in `read_progress.progress_percentage`), if tracked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub progress_percentage: Option, } // ============================================================================= @@ -393,6 +445,7 @@ mod tests { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: Some(8.5), started_at: Some("2026-01-15T00:00:00Z".to_string()), @@ -444,6 +497,7 @@ mod tests { pages: Some(3200), total_chapters: None, total_volumes: None, + ..Default::default() }; let json = serde_json::to_value(&progress).unwrap(); assert_eq!(json["chapters"], 100); @@ -459,6 +513,7 @@ mod tests { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }; let json = serde_json::to_value(&progress).unwrap(); assert_eq!(json["chapters"], 50); @@ -474,6 +529,7 @@ mod tests { pages: None, total_chapters: Some(200), total_volumes: Some(20), + ..Default::default() }; let json = serde_json::to_value(&progress).unwrap(); assert_eq!(json["chapters"], 42); @@ -498,6 +554,107 @@ mod tests { assert!(progress.pages.is_none()); } + #[test] + fn test_sync_progress_detailed_fields_omitted_when_none() { + // Backward compatibility: progress without the detailed fields must not + // emit the new keys, so existing plugins see byte-identical output. + let progress = SyncProgress { + volumes: Some(4), + ..Default::default() + }; + let json = serde_json::to_value(&progress).unwrap(); + let obj = json.as_object().unwrap(); + assert_eq!(json["volumes"], 4); + assert!(!obj.contains_key("maxVolume")); + assert!(!obj.contains_key("maxChapter")); + assert!(!obj.contains_key("readBooks")); + } + + #[test] + fn test_sync_progress_detailed_fields_serialization() { + let progress = SyncProgress { + volumes: Some(4), + max_volume: Some(8), + max_chapter: Some(123.5), + read_books: Some(vec![ + SyncBookProgress { + volume: Some(1), + chapter: None, + completed: true, + current_page: Some(200), + progress_percentage: Some(1.0), + }, + SyncBookProgress { + volume: None, + chapter: Some(47.5), + completed: false, + current_page: Some(10), + progress_percentage: Some(0.25), + }, + ]), + ..Default::default() + }; + let json = serde_json::to_value(&progress).unwrap(); + assert_eq!(json["volumes"], 4); + assert_eq!(json["maxVolume"], 8); + assert_eq!(json["maxChapter"], 123.5); + + let books = json["readBooks"].as_array().unwrap(); + assert_eq!(books.len(), 2); + assert_eq!(books[0]["volume"], 1); + assert_eq!(books[0]["completed"], true); + assert_eq!(books[0]["currentPage"], 200); + assert_eq!(books[0]["progressPercentage"], 1.0); + // Fractional chapter survives; absent volume key is omitted. + assert_eq!(books[1]["chapter"], 47.5); + assert_eq!(books[1]["completed"], false); + assert!(!books[1].as_object().unwrap().contains_key("volume")); + } + + #[test] + fn test_sync_progress_detailed_round_trip() { + let json = json!({ + "volumes": 4, + "maxVolume": 8, + "maxChapter": 123.5, + "readBooks": [ + {"volume": 1, "completed": true, "currentPage": 200}, + {"chapter": 47.5, "completed": false} + ] + }); + let progress: SyncProgress = serde_json::from_value(json).unwrap(); + assert_eq!(progress.volumes, Some(4)); + assert_eq!(progress.max_volume, Some(8)); + assert_eq!(progress.max_chapter, Some(123.5)); + + let books = progress.read_books.unwrap(); + assert_eq!(books.len(), 2); + assert_eq!(books[0].volume, Some(1)); + assert!(books[0].completed); + assert_eq!(books[0].current_page, Some(200)); + assert_eq!(books[1].chapter, Some(47.5)); + assert!(!books[1].completed); + assert!(books[1].volume.is_none()); + } + + #[test] + fn test_sync_book_progress_omits_none_fields() { + let book = SyncBookProgress { + volume: Some(2), + chapter: None, + completed: true, + current_page: None, + progress_percentage: None, + }; + let json = serde_json::to_value(&book).unwrap(); + let obj = json.as_object().unwrap(); + assert_eq!(json["volume"], 2); + assert_eq!(json["completed"], true); + assert!(!obj.contains_key("chapter")); + assert!(!obj.contains_key("currentPage")); + assert!(!obj.contains_key("progressPercentage")); + } + // ========================================================================= // Push Progress Tests // ========================================================================= @@ -515,6 +672,7 @@ mod tests { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: None, started_at: None, @@ -664,6 +822,7 @@ mod tests { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: Some(7.0), started_at: None, @@ -763,6 +922,7 @@ mod tests { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: None, started_at: None, diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs index 5e032535..195becd1 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs @@ -126,6 +126,9 @@ fn project_sync_entry( pages: None, total_chapters: total_chapter_count.map(|c| c as i32), total_volumes: total_volume_count, + // Detailed progress (max_volume/max_chapter/read_books) is wired in a + // later phase; left at defaults here. + ..Default::default() }; let (score, notes) = if settings.sync_ratings { diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs index a14718e1..5d285953 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs @@ -416,6 +416,7 @@ async fn test_match_and_apply_pulled_entries_applies_progress() { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: None, started_at: None, @@ -519,6 +520,7 @@ async fn test_match_and_apply_skips_already_read() { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: None, started_at: None, @@ -1418,6 +1420,7 @@ async fn test_apply_pulled_entry_uses_volumes() { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: None, started_at: None, @@ -1725,6 +1728,7 @@ async fn test_apply_pulled_rating_no_existing() { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: Some(75.0), started_at: None, @@ -1821,6 +1825,7 @@ async fn test_apply_pulled_rating_existing_not_overwritten() { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: Some(60.0), started_at: None, @@ -1902,6 +1907,7 @@ async fn test_apply_pulled_rating_disabled() { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: Some(80.0), started_at: None, From 648dcee5832ff65f8261d9c4460cf5ad874e9e1d Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 21:15:31 -0700 Subject: [PATCH 11/27] feat(plugins): add wantsDetailedProgress capability flag Declare a wantsDetailedProgress plugin capability that gates the per-book reading-progress breakdown (readBooks) on sync entries. The accurate maxVolume/maxChapter fields are always sent and stay ungated; only the heavier per-book detail is opt-in, so plugins that don't consume it pay no extra fetch or payload cost. Surface the capability on the user-plugin capabilities DTO so tooling can detect support, populated from the cached manifest alongside the existing capability flags, and regenerate the OpenAPI spec and TypeScript types to match. The flag is inert until the push path attaches the breakdown. Includes capability parse tests and a DTO serialization assertion. --- .../src/routes/v1/dto/user_plugins.rs | 7 ++++ .../src/routes/v1/handlers/user_plugins.rs | 8 +++++ crates/codex-models/src/plugin.rs | 32 +++++++++++++++++++ docs/api/openapi.json | 7 +++- web/openapi.json | 7 +++- web/src/types/api.generated.ts | 5 +++ 6 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/codex-api/src/routes/v1/dto/user_plugins.rs b/crates/codex-api/src/routes/v1/dto/user_plugins.rs index bc9ca470..0e4e0b48 100644 --- a/crates/codex-api/src/routes/v1/dto/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/dto/user_plugins.rs @@ -128,6 +128,9 @@ pub struct UserPluginCapabilitiesDto { /// Consumes enriched series data; gates whether the `_codex.send*` metadata /// toggles are shown on the connection. pub wants_full_metadata: bool, + /// Consumes the per-book reading-progress breakdown (`readBooks`); when set, + /// the host attaches per-book volume/chapter/page detail to sync entries. + pub wants_detailed_progress: bool, } /// Request to update user plugin configuration @@ -431,6 +434,7 @@ mod tests { read_sync: true, user_recommendation_provider: false, wants_full_metadata: false, + wants_detailed_progress: false, }, user_config_schema: None, last_sync_result: None, @@ -439,6 +443,7 @@ mod tests { let json = serde_json::to_value(&dto).unwrap(); assert_eq!(json["capabilities"]["readSync"], true); assert_eq!(json["capabilities"]["userRecommendationProvider"], false); + assert_eq!(json["capabilities"]["wantsDetailedProgress"], false); assert!(!json.as_object().unwrap().contains_key("userConfigSchema")); assert!(!json.as_object().unwrap().contains_key("lastSyncResult")); } @@ -485,6 +490,7 @@ mod tests { read_sync: true, user_recommendation_provider: false, wants_full_metadata: false, + wants_detailed_progress: false, }, user_config_schema: Some(schema), last_sync_result: None, @@ -534,6 +540,7 @@ mod tests { read_sync: true, user_recommendation_provider: false, wants_full_metadata: false, + wants_detailed_progress: false, }, user_config_schema: None, last_sync_result: Some(sync_result.clone()), diff --git a/crates/codex-api/src/routes/v1/handlers/user_plugins.rs b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs index e9cefc3b..d233df77 100644 --- a/crates/codex-api/src/routes/v1/handlers/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs @@ -123,6 +123,10 @@ async fn build_user_plugin_dto( .as_ref() .map(|m| m.capabilities.wants_full_metadata) .unwrap_or(false), + wants_detailed_progress: manifest + .as_ref() + .map(|m| m.capabilities.wants_detailed_progress) + .unwrap_or(false), }; let user_config_schema = manifest @@ -260,6 +264,10 @@ pub async fn list_user_plugins( .as_ref() .map(|m| m.capabilities.wants_full_metadata) .unwrap_or(false), + wants_detailed_progress: manifest + .as_ref() + .map(|m| m.capabilities.wants_detailed_progress) + .unwrap_or(false), }, } }) diff --git a/crates/codex-models/src/plugin.rs b/crates/codex-models/src/plugin.rs index 28cb6f0a..c9c546be 100644 --- a/crates/codex-models/src/plugin.rs +++ b/crates/codex-models/src/plugin.rs @@ -118,6 +118,14 @@ pub struct PluginCapabilities { /// that don't declare this never pay the assembly or payload cost. #[serde(default)] pub wants_full_metadata: bool, + /// Whether this plugin consumes the per-book reading-progress breakdown + /// (`SyncProgress.readBooks`) on the sync entries it receives. When set, the + /// host fetches per-book volume/chapter/page detail and attaches it to push + /// entries; plugins that don't declare it never pay the extra fetch or + /// payload cost. Only meaningful when `user_read_sync` is true. The accurate + /// `maxVolume`/`maxChapter` fields are always sent and are not gated by this. + #[serde(default)] + pub wants_detailed_progress: bool, /// Can announce new releases (chapters/volumes) for tracked series. /// When present, the plugin may invoke the `releases/*` reverse-RPC /// methods. The capability struct declares the data the plugin needs @@ -431,3 +439,27 @@ impl PluginScope { ] } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_capabilities_wants_detailed_progress_defaults_false() { + let caps: PluginCapabilities = serde_json::from_value(serde_json::json!({ + "userReadSync": true + })) + .unwrap(); + assert!(!caps.wants_detailed_progress); + } + + #[test] + fn test_capabilities_wants_detailed_progress_parses_true() { + let caps: PluginCapabilities = serde_json::from_value(serde_json::json!({ + "userReadSync": true, + "wantsDetailedProgress": true + })) + .unwrap(); + assert!(caps.wants_detailed_progress); + } +} diff --git a/docs/api/openapi.json b/docs/api/openapi.json index ef98d08b..a5220e74 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -42425,7 +42425,8 @@ "required": [ "readSync", "userRecommendationProvider", - "wantsFullMetadata" + "wantsFullMetadata", + "wantsDetailedProgress" ], "properties": { "readSync": { @@ -42436,6 +42437,10 @@ "type": "boolean", "description": "Can provide recommendations" }, + "wantsDetailedProgress": { + "type": "boolean", + "description": "Consumes the per-book reading-progress breakdown (`readBooks`); when set,\nthe host attaches per-book volume/chapter/page detail to sync entries." + }, "wantsFullMetadata": { "type": "boolean", "description": "Consumes enriched series data; gates whether the `_codex.send*` metadata\ntoggles are shown on the connection." diff --git a/web/openapi.json b/web/openapi.json index ef98d08b..a5220e74 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -42425,7 +42425,8 @@ "required": [ "readSync", "userRecommendationProvider", - "wantsFullMetadata" + "wantsFullMetadata", + "wantsDetailedProgress" ], "properties": { "readSync": { @@ -42436,6 +42437,10 @@ "type": "boolean", "description": "Can provide recommendations" }, + "wantsDetailedProgress": { + "type": "boolean", + "description": "Consumes the per-book reading-progress breakdown (`readBooks`); when set,\nthe host attaches per-book volume/chapter/page detail to sync entries." + }, "wantsFullMetadata": { "type": "boolean", "description": "Consumes enriched series data; gates whether the `_codex.send*` metadata\ntoggles are shown on the connection." diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index 013abb81..58621006 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -19628,6 +19628,11 @@ export interface components { readSync: boolean; /** @description Can provide recommendations */ userRecommendationProvider: boolean; + /** + * @description Consumes the per-book reading-progress breakdown (`readBooks`); when set, + * the host attaches per-book volume/chapter/page detail to sync entries. + */ + wantsDetailedProgress: boolean; /** * @description Consumes enriched series data; gates whether the `_codex.send*` metadata * toggles are shown on the connection. From d608e5e8e17d0eaf054f2c86e873fac3a7703253 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 21:43:37 -0700 Subject: [PATCH 12/27] feat(plugins): compute and attach detailed sync progress on push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the detailed-progress fields end to end. The shared engagement builder gains an opt-in per-book detail pass: when enabled it batch-fetches book metadata and folds each book's detected volume/chapter and page position into a per-book breakdown, leaving the recommendations path untouched. The sync push projection then derives the highest read volume and chapter from that breakdown — accurate for libraries that don't start at volume 1 or have gaps, unlike the existing relative count — and, for plugins that declare wantsDetailedProgress, attaches the full per-book breakdown. The matched and search-fallback paths share one projection so both carry the same data, and the extra metadata query runs only when a detail-consuming plugin is connected. Includes builder and push projection tests. --- crates/codex-services/src/plugin/library.rs | 164 ++++++++++- .../src/handlers/user_plugin_sync/mod.rs | 7 + .../src/handlers/user_plugin_sync/push.rs | 69 ++++- .../src/handlers/user_plugin_sync/tests.rs | 258 +++++++++++++++++- 4 files changed, 482 insertions(+), 16 deletions(-) diff --git a/crates/codex-services/src/plugin/library.rs b/crates/codex-services/src/plugin/library.rs index a4019fb5..52bcfcc4 100644 --- a/crates/codex-services/src/plugin/library.rs +++ b/crates/codex-services/src/plugin/library.rs @@ -22,9 +22,9 @@ use crate::plugin::protocol::{ }; use codex_db::entities::{SeriesStatus, series, series_metadata}; use codex_db::repositories::{ - AlternateTitleRepository, BookRepository, GenreRepository, LibraryRepository, - ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, - TagRepository, UserSeriesRatingRepository, + AlternateTitleRepository, BookMetadataRepository, BookRepository, GenreRepository, + LibraryRepository, ReadProgressRepository, SeriesExternalIdRepository, + SeriesMetadataRepository, SeriesRepository, TagRepository, UserSeriesRatingRepository, }; /// Resolve a set of library IDs to a `library_id -> library_name` map in a @@ -51,6 +51,29 @@ pub async fn library_names(db: &DatabaseConnection, library_ids: &[Uuid]) -> Has pub struct EngagementOptions { /// Also fetch genres, tags, alternate titles, and external IDs. pub include_taxonomy: bool, + /// Also fetch per-book metadata (volume/chapter) and populate + /// [`SeriesEngagement::read_books`] with one entry per book that has reading + /// progress. Costs one extra batched query, so only the sync push path + /// enables it, and only for plugins that consume the detail. + pub include_book_detail: bool, +} + +/// Per-book reading progress for one book in a series, the unit of +/// [`SeriesEngagement::read_books`]. Carries reading *position* (detected +/// volume/chapter plus page progress), not bibliographic metadata. Populated +/// only when [`EngagementOptions::include_book_detail`] is set. +#[derive(Debug, Clone, Default)] +pub struct SeriesBookProgress { + /// Detected volume number for this book, if known. + pub volume: Option, + /// Detected chapter number for this book, if known (fractional allowed). + pub chapter: Option, + /// Whether the user has finished this book. + pub completed: bool, + /// Current page within the book, if tracked. + pub current_page: Option, + /// Fractional progress within the book, if tracked. + pub progress_percentage: Option, } /// Per-series aggregate of a user's engagement, plus the library data needed to @@ -96,6 +119,11 @@ pub struct SeriesEngagement { pub user_rating: Option, /// User's personal notes, when set. pub user_notes: Option, + + /// Per-book reading-progress breakdown — populated only when + /// [`EngagementOptions::include_book_detail`]. One entry per book that has + /// reading progress (completed or in-progress). + pub read_books: Vec, } impl SeriesEngagement { @@ -190,6 +218,21 @@ pub async fn build_series_engagements( } }; + // Optional per-book metadata (volume/chapter) — only fetched when the caller + // wants the per-book progress breakdown. Degrade to empty on error so detail + // is simply absent rather than failing the whole build. + let book_metadata_map = if opts.include_book_detail { + match BookMetadataRepository::get_by_book_ids(db, &all_book_ids).await { + Ok(map) => map, + Err(e) => { + warn!("Failed to fetch book metadata for progress detail: {}", e); + HashMap::new() + } + } + } else { + HashMap::new() + }; + // Optional taxonomy — only fetched when the caller will use it. let (genres_map, tags_map, alt_titles_map, ext_ids_map) = if opts.include_taxonomy { ( @@ -217,6 +260,7 @@ pub async fn build_series_engagements( let mut earliest_started: Option> = None; let mut latest_read_at: Option> = None; let mut latest_completed_at: Option> = None; + let mut read_books: Vec = Vec::new(); if let Some(books) = books { for book in books { @@ -243,6 +287,17 @@ pub async fn build_series_engagements( Some(existing) => existing, None => progress.updated_at, }); + + if opts.include_book_detail { + let bm = book_metadata_map.get(&book.id); + read_books.push(SeriesBookProgress { + volume: bm.and_then(|m| m.volume), + chapter: bm.and_then(|m| m.chapter), + completed: progress.completed, + current_page: Some(progress.current_page), + progress_percentage: progress.progress_percentage, + }); + } } } } @@ -297,6 +352,7 @@ pub async fn build_series_engagements( external_ids, user_rating, user_notes, + read_books, }, ); } @@ -331,6 +387,8 @@ pub async fn build_user_library( &all_series, EngagementOptions { include_taxonomy: true, + // Recommendations don't use per-book progress detail. + include_book_detail: false, }, ) .await?; @@ -397,8 +455,8 @@ mod tests { use codex_db::ScanningStrategy; use codex_db::entities::{books, users}; use codex_db::repositories::{ - BookRepository, LibraryRepository, ReadProgressRepository, SeriesMetadataRepository, - SeriesRepository, UserRepository, + BookMetadataRepository, BookRepository, LibraryRepository, ReadProgressRepository, + SeriesMetadataRepository, SeriesRepository, UserRepository, }; use codex_db::test_helpers::create_test_db; @@ -432,6 +490,7 @@ mod tests { external_ids: vec![], user_rating: None, user_notes: None, + read_books: vec![], } } @@ -566,6 +625,101 @@ mod tests { assert!(e.external_ids.is_empty()); } + #[tokio::test] + async fn test_build_series_engagements_book_detail() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + let user_id = create_user(conn).await; + + let lib = LibraryRepository::create(conn, "Lib", "/l", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, lib.id, "Detailed Series", None) + .await + .unwrap(); + + // done: completed, volume 1. reading: in-progress, chapter 47.5, no + // volume. nometa: completed but no metadata row. untouched: no progress. + let done = create_book(conn, series.id, lib.id).await; + let reading = create_book(conn, series.id, lib.id).await; + let nometa = create_book(conn, series.id, lib.id).await; + let _untouched = create_book(conn, series.id, lib.id).await; + + BookMetadataRepository::create_with_title_and_number(conn, done.id, None, None) + .await + .unwrap(); + BookMetadataRepository::update_volume(conn, done.id, Some(1)) + .await + .unwrap(); + BookMetadataRepository::create_with_title_and_number(conn, reading.id, None, None) + .await + .unwrap(); + BookMetadataRepository::update_chapter(conn, reading.id, Some(47.5)) + .await + .unwrap(); + // nometa intentionally has no book_metadata row. + + ReadProgressRepository::upsert(conn, user_id, done.id, 50, true) + .await + .unwrap(); + ReadProgressRepository::upsert(conn, user_id, reading.id, 10, false) + .await + .unwrap(); + ReadProgressRepository::upsert(conn, user_id, nometa.id, 50, true) + .await + .unwrap(); + + // Without the flag, no per-book detail is fetched or populated. + let without = build_series_engagements( + conn, + user_id, + std::slice::from_ref(&series), + EngagementOptions::default(), + ) + .await + .unwrap(); + assert!(without.get(&series.id).unwrap().read_books.is_empty()); + + // With the flag: one entry per progress-bearing book (untouched excluded). + let with = build_series_engagements( + conn, + user_id, + std::slice::from_ref(&series), + EngagementOptions { + include_book_detail: true, + ..Default::default() + }, + ) + .await + .unwrap(); + let e = with.get(&series.id).unwrap(); + assert_eq!(e.read_books.len(), 3); + + let done_bp = e + .read_books + .iter() + .find(|b| b.volume == Some(1)) + .expect("volume-1 book present"); + assert!(done_bp.completed); + assert_eq!(done_bp.current_page, Some(50)); + + let reading_bp = e + .read_books + .iter() + .find(|b| b.chapter == Some(47.5)) + .expect("chapter-47.5 book present"); + assert!(!reading_bp.completed); + assert!(reading_bp.volume.is_none()); + + // The book with no metadata row still appears, with no detected numbers. + let no_numbers = e + .read_books + .iter() + .filter(|b| b.volume.is_none() && b.chapter.is_none()) + .count(); + assert_eq!(no_numbers, 1); + } + #[tokio::test] async fn test_build_series_engagements_empty_input() { let (db, _temp_dir) = create_test_db().await; diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs index a2996b79..2ba5035f 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs @@ -282,11 +282,18 @@ impl TaskHandler for UserPluginSyncHandler { .as_ref() .map(|m| m.capabilities.wants_full_metadata) .unwrap_or(false); + // Detailed per-book progress is gated by capability only (no user + // toggle): volume/chapter/page numbers are benign reading-position data. + let wants_detailed_progress = manifest + .as_ref() + .map(|m| m.capabilities.wants_detailed_progress) + .unwrap_or(false); let metadata_flags = push::MetadataFlags { tags: wants_full_metadata && codex_settings.send_tags, genres: wants_full_metadata && codex_settings.send_genres, metadata: wants_full_metadata && codex_settings.send_metadata, custom_metadata: wants_full_metadata && codex_settings.send_custom_metadata, + detailed_progress: wants_detailed_progress, }; if let Some(ref source) = external_id_source { diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs index 195becd1..08953262 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs @@ -11,21 +11,26 @@ use codex_db::repositories::{ BookRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesRepository, }; use codex_services::plugin::library::{ - EngagementOptions, SeriesEngagement, build_series_engagements, + EngagementOptions, SeriesBookProgress, SeriesEngagement, build_series_engagements, }; -use codex_services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; +use codex_services::plugin::sync::{SyncBookProgress, SyncEntry, SyncProgress, SyncReadingStatus}; use super::settings::CodexSyncSettings; -/// Effective per-field metadata-enrichment flags for a push (capability AND the -/// user's `_codex.send*` toggle, computed by the caller). Each gates one piece of -/// optional data attached to every push entry. +/// Effective per-field enrichment flags for a push, computed by the caller from +/// plugin capabilities (and, for the metadata fields, the user's `_codex.send*` +/// toggles). Each gates one piece of optional data attached to push entries. #[derive(Debug, Clone, Copy, Default)] pub(crate) struct MetadataFlags { pub tags: bool, pub genres: bool, pub metadata: bool, pub custom_metadata: bool, + /// Attach the per-book reading-progress breakdown (`readBooks`). Gated by the + /// plugin's `wantsDetailedProgress` capability only (no user toggle). Also + /// drives fetching per-book detail, which the accurate `maxVolume`/`maxChapter` + /// fields ride along on. + pub detailed_progress: bool, } impl MetadataFlags { @@ -117,6 +122,52 @@ fn project_sync_entry( SyncReadingStatus::Reading }; + // Detailed progress, derived from per-book volume/chapter detection. Present + // only when the engagement was built with per-book detail (i.e. the plugin + // declares `wantsDetailedProgress`); otherwise `read_books` is empty and these + // stay `None`. + // + // `max_volume`/`max_chapter` are the highest *read* numbers, folded over the + // same set of books that feeds `volumes`: completed always, plus in-progress + // when `count_partial_progress` is on. Unlike the `volumes` count, they stay + // accurate for libraries that don't start at volume 1 or have gaps. + let counted = |b: &SeriesBookProgress| b.completed || settings.count_partial_progress; + let max_volume = e + .read_books + .iter() + .filter(|b| counted(b)) + .filter_map(|b| b.volume) + .max(); + let max_chapter = e + .read_books + .iter() + .filter(|b| counted(b)) + .filter_map(|b| b.chapter) + .fold(None::, |acc, c| match acc { + Some(m) if m >= c => Some(m), + _ => Some(c), + }); + + // The full per-book breakdown is attached only for plugins that declare the + // capability. It reflects every book with progress (completed or in-progress), + // independent of `count_partial_progress` (a raw breakdown the plugin filters). + let read_books = if flags.detailed_progress { + Some( + e.read_books + .iter() + .map(|b| SyncBookProgress { + volume: b.volume, + chapter: b.chapter, + completed: b.completed, + current_page: b.current_page, + progress_percentage: b.progress_percentage, + }) + .collect(), + ) + } else { + None + }; + // Server always sends books-read as `volumes`. Codex tracks books (each file // = 1 volume), not chapters. `chapters` is left `None`; the plugin decides how // to map this to service-specific fields. @@ -126,9 +177,9 @@ fn project_sync_entry( pages: None, total_chapters: total_chapter_count.map(|c| c as i32), total_volumes: total_volume_count, - // Detailed progress (max_volume/max_chapter/read_books) is wired in a - // later phase; left at defaults here. - ..Default::default() + max_volume, + max_chapter, + read_books, }; let (score, notes) = if settings.sync_ratings { @@ -225,6 +276,7 @@ pub(crate) async fn build_push_entries( let series = scoped_series(db, &candidate_series_ids, allowed_library_ids).await; let opts = EngagementOptions { include_taxonomy: flags.needs_taxonomy(), + include_book_detail: flags.detailed_progress, }; let engagements = match build_series_engagements(db, user_id, &series, opts).await { Ok(map) => map, @@ -346,6 +398,7 @@ async fn build_unmatched_entries( let series = scoped_series(db, &unmatched_ids_vec, allowed_library_ids).await; let opts = EngagementOptions { include_taxonomy: flags.needs_taxonomy(), + include_book_detail: flags.detailed_progress, }; let engagements = match build_series_engagements(db, user_id, &series, opts).await { Ok(map) => map, diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs index 5d285953..1a0639f4 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs @@ -3,9 +3,9 @@ use chrono::Utc; use codex_db::ScanningStrategy; use codex_db::entities::{books, users}; use codex_db::repositories::{ - BookRepository, GenreRepository, LibraryRepository, ReadProgressRepository, - SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, TagRepository, - UserRepository, UserSeriesRatingRepository, + BookMetadataRepository, BookRepository, GenreRepository, LibraryRepository, + ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, + TagRepository, UserRepository, UserSeriesRatingRepository, }; use codex_db::test_helpers::create_test_db; use codex_services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; @@ -809,6 +809,7 @@ async fn test_build_push_entries_metadata_flags() { genres: true, metadata: true, custom_metadata: true, + ..Default::default() }; let on = push::build_push_entries( conn, @@ -1386,6 +1387,257 @@ async fn test_build_push_entries_count_in_progress_volumes() { assert!(entries[0].progress.as_ref().unwrap().chapters.is_none()); } +/// Set the detected volume on a book (creating its metadata row first). +async fn set_book_volume(db: &sea_orm::DatabaseConnection, book_id: Uuid, volume: i32) { + BookMetadataRepository::create_with_title_and_number(db, book_id, None, None) + .await + .unwrap(); + BookMetadataRepository::update_volume(db, book_id, Some(volume)) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_build_push_entries_detailed_progress_gating_and_max() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let library = LibraryRepository::create(conn, "Lib", "/p", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, library.id, "Gapped", None) + .await + .unwrap(); + let user = create_test_user(conn).await; + + // Library holds volumes 5-8 (a gap from 1), all read. The relative count is + // 4, but the highest read volume is 8. + for (i, vol) in (5..=8).enumerate() { + let book = create_test_book(conn, series.id, library.id, i, 100).await; + set_book_volume(conn, book.id, vol).await; + ReadProgressRepository::mark_as_read(conn, user.id, book.id, 100) + .await + .unwrap(); + } + + SeriesExternalIdRepository::create(conn, series.id, "api:anilist", "808", None, None) + .await + .unwrap(); + + let settings = default_codex_settings(); + + // Without the capability: relative count only, no detail. + let plain = push::build_push_entries( + conn, + user.id, + "api:anilist", + Uuid::new_v4(), + &settings, + &[], + push::MetadataFlags::default(), + ) + .await; + assert_eq!(plain.len(), 1); + let p = plain[0].progress.as_ref().unwrap(); + assert_eq!(p.volumes, Some(4)); + assert!(p.max_volume.is_none()); + assert!(p.read_books.is_none()); + + // With the capability: accurate max plus the per-book breakdown. + let detailed = push::build_push_entries( + conn, + user.id, + "api:anilist", + Uuid::new_v4(), + &settings, + &[], + push::MetadataFlags { + detailed_progress: true, + ..Default::default() + }, + ) + .await; + assert_eq!(detailed.len(), 1); + let p = detailed[0].progress.as_ref().unwrap(); + assert_eq!(p.volumes, Some(4)); + assert_eq!(p.max_volume, Some(8)); + assert!(p.max_chapter.is_none()); + let books = p.read_books.as_ref().expect("readBooks attached"); + assert_eq!(books.len(), 4); + assert!(books.iter().all(|b| b.completed)); +} + +#[tokio::test] +async fn test_build_push_entries_max_volume_respects_count_partial_progress() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let library = LibraryRepository::create(conn, "Lib", "/p", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, library.id, "Partial", None) + .await + .unwrap(); + let user = create_test_user(conn).await; + + // Volume 1 completed, volume 2 in-progress. + let b1 = create_test_book(conn, series.id, library.id, 1, 100).await; + set_book_volume(conn, b1.id, 1).await; + ReadProgressRepository::mark_as_read(conn, user.id, b1.id, 100) + .await + .unwrap(); + let b2 = create_test_book(conn, series.id, library.id, 2, 100).await; + set_book_volume(conn, b2.id, 2).await; + ReadProgressRepository::upsert(conn, user.id, b2.id, 50, false) + .await + .unwrap(); + + SeriesExternalIdRepository::create(conn, series.id, "api:anilist", "12", None, None) + .await + .unwrap(); + + let detailed = push::MetadataFlags { + detailed_progress: true, + ..Default::default() + }; + + // Default settings: in-progress excluded from the count and the max. + let entries = push::build_push_entries( + conn, + user.id, + "api:anilist", + Uuid::new_v4(), + &default_codex_settings(), + &[], + detailed, + ) + .await; + let p = entries[0].progress.as_ref().unwrap(); + assert_eq!(p.max_volume, Some(1)); + // The raw breakdown still lists both books regardless of the count setting. + assert_eq!(p.read_books.as_ref().unwrap().len(), 2); + + // count_partial_progress on: in-progress volume 2 now counts → max is 2. + let with_partial = CodexSyncSettings { + count_partial_progress: true, + ..default_codex_settings() + }; + let entries = push::build_push_entries( + conn, + user.id, + "api:anilist", + Uuid::new_v4(), + &with_partial, + &[], + detailed, + ) + .await; + assert_eq!(entries[0].progress.as_ref().unwrap().max_volume, Some(2)); +} + +#[tokio::test] +async fn test_build_push_entries_max_chapter() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let library = LibraryRepository::create(conn, "Lib", "/p", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, library.id, "Chapters", None) + .await + .unwrap(); + let user = create_test_user(conn).await; + + // Two completed chapter-based books: 10 and a fractional 47.5. + let c1 = create_test_book(conn, series.id, library.id, 1, 30).await; + BookMetadataRepository::create_with_title_and_number(conn, c1.id, None, None) + .await + .unwrap(); + BookMetadataRepository::update_chapter(conn, c1.id, Some(10.0)) + .await + .unwrap(); + ReadProgressRepository::mark_as_read(conn, user.id, c1.id, 30) + .await + .unwrap(); + let c2 = create_test_book(conn, series.id, library.id, 2, 30).await; + BookMetadataRepository::create_with_title_and_number(conn, c2.id, None, None) + .await + .unwrap(); + BookMetadataRepository::update_chapter(conn, c2.id, Some(47.5)) + .await + .unwrap(); + ReadProgressRepository::mark_as_read(conn, user.id, c2.id, 30) + .await + .unwrap(); + + SeriesExternalIdRepository::create(conn, series.id, "api:anilist", "475", None, None) + .await + .unwrap(); + + let entries = push::build_push_entries( + conn, + user.id, + "api:anilist", + Uuid::new_v4(), + &default_codex_settings(), + &[], + push::MetadataFlags { + detailed_progress: true, + ..Default::default() + }, + ) + .await; + let p = entries[0].progress.as_ref().unwrap(); + assert_eq!(p.max_chapter, Some(47.5)); + assert!(p.max_volume.is_none()); +} + +#[tokio::test] +async fn test_build_push_entries_unmatched_includes_detail() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let library = LibraryRepository::create(conn, "Lib", "/p", ScanningStrategy::Default) + .await + .unwrap(); + // Series has progress + a detected volume but NO external ID for the source, + // so it goes through the search-fallback (unmatched) path. + let series = SeriesRepository::create(conn, library.id, "Unmatched Series", None) + .await + .unwrap(); + let user = create_test_user(conn).await; + + let book = create_test_book(conn, series.id, library.id, 1, 100).await; + set_book_volume(conn, book.id, 3).await; + ReadProgressRepository::mark_as_read(conn, user.id, book.id, 100) + .await + .unwrap(); + + let settings = CodexSyncSettings { + search_fallback: true, + ..default_codex_settings() + }; + let entries = push::build_push_entries( + conn, + user.id, + "api:anilist", + Uuid::new_v4(), + &settings, + &[], + push::MetadataFlags { + detailed_progress: true, + ..Default::default() + }, + ) + .await; + // The unmatched entry carries an empty external ID and a title to search by. + assert_eq!(entries.len(), 1); + assert!(entries[0].external_id.is_empty()); + let p = entries[0].progress.as_ref().unwrap(); + assert_eq!(p.max_volume, Some(3)); + assert_eq!(p.read_books.as_ref().unwrap().len(), 1); +} + #[tokio::test] async fn test_apply_pulled_entry_uses_volumes() { let (db, _temp_dir) = create_test_db().await; From c5c157be5634ae4512e4c4714e17b5ad04b724de Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 6 Jun 2026 22:03:05 -0700 Subject: [PATCH 13/27] feat(plugins): consume accurate sync progress and add detailed-progress SDK types Make the highest read volume/chapter always flow on the sync push path so every sync plugin gets accurate progress for libraries with gaps, with no opt-in; only the heavier per-book breakdown stays gated behind the wantsDetailedProgress capability. Previously the accurate numbers rode the same gate as the breakdown, which would have forced the AniList plugin to receive (and ignore) the full breakdown just to read two numbers. Mirror maxVolume/maxChapter/readBooks and SyncBookProgress into the TypeScript SDK, and update the AniList plugin to prefer maxVolume/maxChapter (flooring fractional chapters) over the relative books-read count, falling back to the count for older hosts. Document the accuracy behaviour, the gapped-library example, and the capability/payload trade-off. Includes plugin mapping tests. --- .../src/handlers/user_plugin_sync/push.rs | 10 ++- .../src/handlers/user_plugin_sync/tests.rs | 13 +-- docs/docs/plugins/anilist-sync.md | 8 +- docs/docs/plugins/index.md | 4 +- plugins/sdk-typescript/src/types/index.ts | 1 + plugins/sdk-typescript/src/types/sync.ts | 45 +++++++++- plugins/sync-anilist/src/index.test.ts | 89 +++++++++++++++++++ plugins/sync-anilist/src/index.ts | 34 ++++--- 8 files changed, 181 insertions(+), 23 deletions(-) diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs index 08953262..d3de99b8 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs @@ -276,7 +276,10 @@ pub(crate) async fn build_push_entries( let series = scoped_series(db, &candidate_series_ids, allowed_library_ids).await; let opts = EngagementOptions { include_taxonomy: flags.needs_taxonomy(), - include_book_detail: flags.detailed_progress, + // Always fetch per-book detail for the sync push: the accurate + // `max_volume`/`max_chapter` (Tier 1) are computed from it for every + // entry. The capability only gates the heavier `read_books` array. + include_book_detail: true, }; let engagements = match build_series_engagements(db, user_id, &series, opts).await { Ok(map) => map, @@ -398,7 +401,10 @@ async fn build_unmatched_entries( let series = scoped_series(db, &unmatched_ids_vec, allowed_library_ids).await; let opts = EngagementOptions { include_taxonomy: flags.needs_taxonomy(), - include_book_detail: flags.detailed_progress, + // Always fetch per-book detail for the sync push: the accurate + // `max_volume`/`max_chapter` (Tier 1) are computed from it for every + // entry. The capability only gates the heavier `read_books` array. + include_book_detail: true, }; let engagements = match build_series_engagements(db, user_id, &series, opts).await { Ok(map) => map, diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs index 1a0639f4..a41586c9 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs @@ -1426,7 +1426,8 @@ async fn test_build_push_entries_detailed_progress_gating_and_max() { let settings = default_codex_settings(); - // Without the capability: relative count only, no detail. + // Without the capability: accurate max numbers are still sent (Tier 1 is + // always on for push), but the heavy per-book breakdown is withheld. let plain = push::build_push_entries( conn, user.id, @@ -1440,7 +1441,7 @@ async fn test_build_push_entries_detailed_progress_gating_and_max() { assert_eq!(plain.len(), 1); let p = plain[0].progress.as_ref().unwrap(); assert_eq!(p.volumes, Some(4)); - assert!(p.max_volume.is_none()); + assert_eq!(p.max_volume, Some(8)); assert!(p.read_books.is_none()); // With the capability: accurate max plus the per-book breakdown. @@ -1574,6 +1575,7 @@ async fn test_build_push_entries_max_chapter() { .await .unwrap(); + // Default flags (no capability): Tier 1 max_chapter still flows. let entries = push::build_push_entries( conn, user.id, @@ -1581,15 +1583,14 @@ async fn test_build_push_entries_max_chapter() { Uuid::new_v4(), &default_codex_settings(), &[], - push::MetadataFlags { - detailed_progress: true, - ..Default::default() - }, + push::MetadataFlags::default(), ) .await; let p = entries[0].progress.as_ref().unwrap(); assert_eq!(p.max_chapter, Some(47.5)); assert!(p.max_volume.is_none()); + // The breakdown is still gated by the capability. + assert!(p.read_books.is_none()); } #[tokio::test] diff --git a/docs/docs/plugins/anilist-sync.md b/docs/docs/plugins/anilist-sync.md index 66427fed..c5b6577c 100644 --- a/docs/docs/plugins/anilist-sync.md +++ b/docs/docs/plugins/anilist-sync.md @@ -170,10 +170,12 @@ These settings are specific to the AniList plugin and control how it interprets ### Progress Unit: Volumes vs Chapters -AniList tracks both volume and chapter progress separately. Codex always sends books-read as `volumes` in the sync data. The plugin then maps this to the correct AniList field based on your `progressUnit` setting: +AniList tracks both volume and chapter progress separately. Codex sends the **highest read volume and chapter** for each series, derived from the volume/chapter numbers detected on your books, and the plugin maps the relevant one to AniList based on your `progressUnit` setting: -- **Volumes** (default) — sends progress as `progressVolumes`, which is the natural mapping for manga volumes. AniList displays this as "Read Vol. X". -- **Chapters** — sends progress as `progress` (chapter count). Only use this if your Codex books represent individual chapters rather than collected volumes. AniList displays this as "Read Ch. X". +- **Volumes** (default) — sends the highest read volume as `progressVolumes`, the natural mapping for manga volumes. AniList displays this as "Read Vol. X". +- **Chapters** — sends the highest read chapter as `progress`. Fractional chapters (e.g. a `47.5` side chapter) are floored to the last whole chapter. Only use this if your Codex books represent individual chapters rather than collected volumes. AniList displays this as "Read Ch. X". + +Because this uses the **highest detected number** rather than a count of files, progress stays accurate for libraries that don't start at volume 1 or have gaps — e.g. owning and reading volumes 5–8 reports volume **8**, not 4. When a book has no detected volume/chapter number, the plugin falls back to the relative count of books read. :::warning Using "chapters" when your books are volumes can create misleading activity on your AniList profile (e.g., showing "Read chapter 3" when you actually read volume 3, which may contain chapters 20–30). diff --git a/docs/docs/plugins/index.md b/docs/docs/plugins/index.md index b4c33a53..90c28ddc 100644 --- a/docs/docs/plugins/index.md +++ b/docs/docs/plugins/index.md @@ -176,11 +176,13 @@ The data sent to external services depends on the plugin type: | Plugin Type | Data Sent | Destination | |-------------|-----------|-------------| | **Metadata** | Series titles, ISBNs, search queries | Metadata provider API (e.g., Open Library) | -| **Sync** | Series titles, reading progress (books read), scores, dates, reading status | Tracking service API (e.g., AniList) | +| **Sync** | Series titles, reading progress (books read plus the highest read volume/chapter), scores, dates, reading status | Tracking service API (e.g., AniList) | | **Recommendations** | Library series titles (used as "seed" entries) | Recommendation service API (e.g., AniList) | Codex never sends file contents, file paths, or raw images to external services. +Sync plugins always receive the relative books-read count and the highest read volume/chapter (derived from your books' detected numbers). A plugin can additionally declare the `wantsDetailedProgress` capability to receive a per-book breakdown (each book's detected volume/chapter, completion, and page position). Codex only assembles and sends that heavier per-book payload for plugins that declare the capability, so connections to plugins that don't are unaffected. + ### What Data Is Stored Locally - **OAuth tokens**: Encrypted at rest in the Codex database (AES-256-GCM) diff --git a/plugins/sdk-typescript/src/types/index.ts b/plugins/sdk-typescript/src/types/index.ts index 2a5c459a..9a4b543a 100644 --- a/plugins/sdk-typescript/src/types/index.ts +++ b/plugins/sdk-typescript/src/types/index.ts @@ -93,6 +93,7 @@ export * from "./rpc.js"; // From sync - sync protocol types (these match Rust exactly) export type { ExternalUserInfo, + SyncBookProgress, SyncEntry, SyncEntryResult, SyncEntryResultStatus, diff --git a/plugins/sdk-typescript/src/types/sync.ts b/plugins/sdk-typescript/src/types/sync.ts index 4d348b62..0dada77b 100644 --- a/plugins/sdk-typescript/src/types/sync.ts +++ b/plugins/sdk-typescript/src/types/sync.ts @@ -79,7 +79,11 @@ export interface ExternalUserInfo { export interface SyncProgress { /** Number of chapters read */ chapters?: number; - /** Number of volumes read */ + /** + * Number of volumes read — the *relative* count of books read in the series + * (each file = 1 book), not an absolute volume number. Prefer `maxVolume` / + * `maxChapter` for accurate progress on libraries with gaps. + */ volumes?: number; /** Number of pages read (for single-volume works) */ pages?: number; @@ -87,6 +91,45 @@ export interface SyncProgress { totalChapters?: number; /** Total number of volumes in the series (if known) */ totalVolumes?: number; + /** + * Highest **read** volume number, derived from Codex's per-book volume + * detection. Unlike `volumes` (a count), this is the absolute highest volume + * reached, so it stays correct for libraries that don't start at volume 1 or + * have gaps. Present only when the plugin declares the `wantsDetailedProgress` + * capability. + */ + maxVolume?: number; + /** + * Highest **read** chapter number (may be fractional, e.g. 47.5). Same source + * and gating as `maxVolume`. + */ + maxChapter?: number; + /** + * Per-book reading-progress breakdown, one entry per book that has reading + * progress (completed or in-progress). Present only when the plugin declares + * the `wantsDetailedProgress` capability — lets a plugin map progress however + * its service expects (ranges, custom units, etc.). + */ + readBooks?: SyncBookProgress[]; +} + +/** + * Per-book reading progress, the unit of {@link SyncProgress.readBooks}. + * + * Carries reading *position* (detected volume/chapter plus page progress), not + * bibliographic metadata. + */ +export interface SyncBookProgress { + /** Detected volume number for this book, if known. */ + volume?: number; + /** Detected chapter number for this book, if known (may be fractional). */ + chapter?: number; + /** Whether the user has finished this book. */ + completed: boolean; + /** Current page within the book, if tracked. */ + currentPage?: number; + /** Fractional progress within the book, if tracked. */ + progressPercentage?: number; } // ============================================================================= diff --git a/plugins/sync-anilist/src/index.test.ts b/plugins/sync-anilist/src/index.test.ts index 1b57a4bb..9579e64e 100644 --- a/plugins/sync-anilist/src/index.test.ts +++ b/plugins/sync-anilist/src/index.test.ts @@ -6,6 +6,7 @@ import { setClient, setHiddenFromStatusLists, setPrivateMode, + setProgressUnit, setSearchFallback, setViewerId, } from "./index.js"; @@ -452,3 +453,91 @@ describe("pushProgress visibility params", () => { expect(args.hiddenFromStatusLists).toBe(true); }); }); + +// ============================================================================= +// pushProgress accurate progress mapping +// ============================================================================= + +describe("pushProgress accurate progress mapping", () => { + function makeMockClient() { + return { + getViewer: vi.fn(), + getMangaList: vi.fn().mockResolvedValue({ + pageInfo: { total: 0, currentPage: 1, lastPage: 1, hasNextPage: false }, + entries: [], + }), + saveEntry: vi.fn().mockResolvedValue({ + id: 1, + mediaId: 42, + status: "CURRENT", + score: 0, + progress: 0, + progressVolumes: 0, + }), + searchManga: vi.fn().mockResolvedValue(null), + } as unknown as AniListClient; + } + + let mockClient: ReturnType; + + beforeEach(() => { + mockClient = makeMockClient(); + setClient(mockClient); + setViewerId(1); + }); + + afterEach(() => { + setClient(null); + setViewerId(null); + setProgressUnit("volumes"); // restore default + }); + + function savedArgs() { + return (mockClient.saveEntry as ReturnType).mock.calls[0][0]; + } + + it("prefers maxVolume over the relative volumes count (volumes unit)", async () => { + // Gapped library: owns vol 5-8, all read. Relative count is 4, but the + // highest read volume is 8. + await provider.pushProgress({ + entries: [ + { + externalId: "42", + status: "reading", + progress: { volumes: 4, maxVolume: 8 }, + }, + ], + }); + expect(savedArgs().progressVolumes).toBe(8); + }); + + it("falls back to the relative count when maxVolume is absent (older host)", async () => { + await provider.pushProgress({ + entries: [{ externalId: "42", status: "reading", progress: { volumes: 4 } }], + }); + expect(savedArgs().progressVolumes).toBe(4); + }); + + it("prefers maxChapter and floors fractional chapters (chapters unit)", async () => { + setProgressUnit("chapters"); + await provider.pushProgress({ + entries: [ + { + externalId: "42", + status: "reading", + progress: { volumes: 3, maxChapter: 47.5 }, + }, + ], + }); + // 47.5 floors to 47 completed chapters; mapped to AniList `progress`. + expect(savedArgs().progress).toBe(47); + }); + + it("falls back to the relative count for the chapters unit when maxChapter is absent", async () => { + setProgressUnit("chapters"); + await provider.pushProgress({ + entries: [{ externalId: "42", status: "reading", progress: { volumes: 6 } }], + }); + expect(savedArgs().progress).toBe(6); + }); +}); diff --git a/plugins/sync-anilist/src/index.ts b/plugins/sync-anilist/src/index.ts index 410a4158..608f9791 100644 --- a/plugins/sync-anilist/src/index.ts +++ b/plugins/sync-anilist/src/index.ts @@ -67,6 +67,11 @@ export function setSearchFallback(enabled: boolean): void { searchFallback = enabled; } +/** Set the progressUnit (exported for testing) */ +export function setProgressUnit(unit: "volumes" | "chapters"): void { + progressUnit = unit; +} + /** Set the privateMode flag (exported for testing) */ export function setPrivateMode(enabled: boolean): void { privateMode = enabled; @@ -217,16 +222,25 @@ export const provider: SyncProvider = { hiddenFromStatusLists, }; - // Map progress using the configured progressUnit. - // Server always sends books-read as `volumes`. Based on - // progressUnit, we map to AniList's `progress` (chapters) - // or `progressVolumes` (volumes) field. - const count = entry.progress?.volumes ?? entry.progress?.chapters; - if (count !== undefined) { - if (progressUnit === "chapters") { - saveParams.progress = count; - } else { - saveParams.progressVolumes = count; + // Map progress to the configured AniList field. Prefer the accurate + // highest-read number (maxVolume/maxChapter), derived from per-book + // detection, which stays correct for libraries that don't start at + // volume 1 or have gaps. Fall back to the relative books-read count + // (the server sends it as `volumes`) for older hosts that don't send + // the accurate numbers. + if (progressUnit === "chapters") { + const chapter = + entry.progress?.maxChapter ?? entry.progress?.volumes ?? entry.progress?.chapters; + if (chapter !== undefined) { + // AniList chapter progress is an integer; floor fractional chapters + // (e.g. a 47.5 side-chapter reports 47 chapters completed). + saveParams.progress = Math.floor(chapter); + } + } else { + const volume = + entry.progress?.maxVolume ?? entry.progress?.volumes ?? entry.progress?.chapters; + if (volume !== undefined) { + saveParams.progressVolumes = volume; } } From 0ab62c4bc5394ab7d0f1db4aaff78c26ba44b51b Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sun, 7 Jun 2026 09:42:10 -0700 Subject: [PATCH 14/27] fix(plugins): add wantsDetailedProgress to mock plugin capabilities The UserPluginCapabilitiesDto gained a required wantsDetailedProgress field, but the MSW mock handlers for user plugins were not updated, breaking the frontend type-check and build. Add the field to all three capability objects in the mock handlers (anilist enabled plugin, mangabaka available plugin, and the dynamically created enable handler) so the mocks satisfy the generated type and the build passes. --- web/src/mocks/handlers/userPlugins.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/mocks/handlers/userPlugins.ts b/web/src/mocks/handlers/userPlugins.ts index a47b9c26..36927dfe 100644 --- a/web/src/mocks/handlers/userPlugins.ts +++ b/web/src/mocks/handlers/userPlugins.ts @@ -54,6 +54,7 @@ const anilistPlugin: UserPluginDto = { capabilities: { readSync: true, userRecommendationProvider: true, + wantsDetailedProgress: true, wantsFullMetadata: true, }, createdAt: "2026-01-15T00:00:00Z", @@ -70,6 +71,7 @@ const mangabakaAvailable: AvailablePluginDto = { capabilities: { readSync: false, userRecommendationProvider: false, + wantsDetailedProgress: false, wantsFullMetadata: false, }, }; @@ -145,6 +147,7 @@ export const userPluginsHandlers = [ capabilities: { readSync: false, userRecommendationProvider: false, + wantsDetailedProgress: false, wantsFullMetadata: false, }, createdAt: new Date().toISOString(), From 31aad2763fedab5d4226350ea5b534ea69d67a58 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sun, 7 Jun 2026 10:11:39 -0700 Subject: [PATCH 15/27] feat(plugins): record echo plugin payloads to files for debugging Add a file-based payload recorder to the echo (debug) plugins, starting with metadata-echo. On each call it writes the request and its matching response to paired JSON files under the plugin's host-provided data directory, so the host->plugin protocol traffic can be inspected without trawling server logs. - Files share a sortable basename (yyyy-MM-dd-HH-mm-ss-{id}-{method}, UTC) and differ only by a -request.json / -response.json suffix. - Each file is a JSON envelope holding the payload plus a snapshot of the active config. Credentials are never written; secret-like config keys are redacted. - Recording is bounded (oldest files pruned), falls back to a temp dir when no data directory is provided, and is best-effort so a disk error never breaks an RPC response. Toggled via recordPayloads / maxPayloadFiles admin config. Surface two fields the host already supports but the TypeScript SDK omitted: InitializeParams.dataDir (the scoped writable file-storage directory) and PluginCapabilities.wantsDetailedProgress (opt in to per-book sync progress). Add unit tests for the recorder. --- plugins/metadata-echo/src/index.ts | 59 +++-- plugins/metadata-echo/src/manifest.ts | 20 ++ plugins/metadata-echo/src/recorder.test.ts | 184 ++++++++++++++ plugins/metadata-echo/src/recorder.ts | 238 +++++++++++++++++++ plugins/sdk-typescript/src/server.ts | 11 + plugins/sdk-typescript/src/types/manifest.ts | 10 + 6 files changed, 508 insertions(+), 14 deletions(-) create mode 100644 plugins/metadata-echo/src/recorder.test.ts create mode 100644 plugins/metadata-echo/src/recorder.ts diff --git a/plugins/metadata-echo/src/index.ts b/plugins/metadata-echo/src/index.ts index 0f8d6d81..9346e262 100644 --- a/plugins/metadata-echo/src/index.ts +++ b/plugins/metadata-echo/src/index.ts @@ -24,7 +24,8 @@ import { type PluginBookMetadata, type PluginSeriesMetadata, } from "@ashdev/codex-plugin-sdk"; -import { DEFAULT_MAX_RESULTS, manifest } from "./manifest.js"; +import { DEFAULT_MAX_PAYLOAD_FILES, DEFAULT_MAX_RESULTS, manifest } from "./manifest.js"; +import { PayloadRecorder, redactConfig } from "./recorder.js"; const logger = createLogger({ name: "echo", level: "debug" }); @@ -33,6 +34,15 @@ const config = { maxResults: DEFAULT_MAX_RESULTS, }; +// Payload recorder (set during initialization) +let recorder: PayloadRecorder | null = null; + +/** Record a request/response pair (best-effort) and return the response. */ +async function rec(method: string, params: unknown, response: T): Promise { + await recorder?.record(method, params, response); + return response; +} + // Encode the original query into the externalId so that get() can recover it // later. The protocol only passes externalId to get(), so this is how the // echo plugin preserves the user-facing title across calls. @@ -81,9 +91,9 @@ function generateEchoResults(query: string, maxResults: number) { const provider: MetadataProvider = { async search(params: MetadataSearchParams): Promise { // Echo back the query as search results, respecting maxResults config - return { + return rec("metadata/series/search", params, { results: generateEchoResults(params.query, config.maxResults), - }; + }); }, async get(params: MetadataGetParams): Promise { @@ -93,7 +103,7 @@ const provider: MetadataProvider = { const title = query ?? `Echo Series: ${baseId}`; // Return metadata based on the external ID with all fields populated for testing - return { + return rec("metadata/series/get", params, { externalId: params.externalId, externalUrl: `https://echo.example.com/series/${baseId}`, title, @@ -177,13 +187,13 @@ const provider: MetadataProvider = { linkType: "purchase", }, ], - }; + }); }, async match(params: MetadataMatchParams): Promise { // Return a match based on the title const normalizedTitle = params.title.toLowerCase().replace(/\s+/g, "-"); - return { + return rec("metadata/series/match", params, { match: { externalId: `match-${normalizedTitle}`, title: params.title, @@ -205,7 +215,7 @@ const provider: MetadataProvider = { relevanceScore: 0.6, }, ], - }; + }); }, }; @@ -245,9 +255,9 @@ function generateBookEchoResults(params: BookSearchParams, maxResults: number) { const bookProvider: BookMetadataProvider = { async search(params: BookSearchParams): Promise { // Echo back the ISBN or query as search results - return { + return rec("metadata/book/search", params, { results: generateBookEchoResults(params, config.maxResults), - }; + }); }, async get(params: MetadataGetParams): Promise { @@ -257,7 +267,7 @@ const bookProvider: BookMetadataProvider = { const title = query ?? `Echo Book: ${baseId}`; // Return book metadata based on the external ID with all fields populated for testing - return { + return rec("metadata/book/get", params, { externalId: params.externalId, externalUrl: `https://echo.example.com/book/${baseId}`, title, @@ -347,7 +357,7 @@ const bookProvider: BookMetadataProvider = { linkType: "purchase", }, ], - }; + }); }, async match(params: BookMatchParams): Promise { @@ -356,7 +366,7 @@ const bookProvider: BookMetadataProvider = { const normalizedId = identifier.toLowerCase().replace(/[\s-]/g, ""); const isIsbnMatch = !!params.isbn; - return { + return rec("metadata/book/match", params, { match: { externalId: `match-book-${normalizedId}`, title: params.title, @@ -382,7 +392,7 @@ const bookProvider: BookMetadataProvider = { relevanceScore: 0.5, }, ], - }; + }); }, }; @@ -401,7 +411,28 @@ createMetadataPlugin({ if (maxResults !== undefined) { config.maxResults = Math.min(Math.max(1, maxResults), 20); // Clamp 1-20 } - logger.info(`Echo plugin initialized (maxResults: ${config.maxResults})`); + + // Set up payload recording (on by default for this debug plugin) + const recordPayloads = params.adminConfig?.recordPayloads !== false; + const maxPayloadFiles = + typeof params.adminConfig?.maxPayloadFiles === "number" + ? params.adminConfig.maxPayloadFiles + : DEFAULT_MAX_PAYLOAD_FILES; + recorder = new PayloadRecorder({ + pluginName: manifest.name, + dataDir: params.dataDir, + enabled: recordPayloads, + maxFiles: maxPayloadFiles, + configSnapshot: redactConfig({ + adminConfig: params.adminConfig, + userConfig: params.userConfig, + }), + logger, + }); + + logger.info( + `Echo plugin initialized (maxResults: ${config.maxResults}, recordPayloads: ${recordPayloads})`, + ); }, }); diff --git a/plugins/metadata-echo/src/manifest.ts b/plugins/metadata-echo/src/manifest.ts index 95436dbb..fe6bcf81 100644 --- a/plugins/metadata-echo/src/manifest.ts +++ b/plugins/metadata-echo/src/manifest.ts @@ -3,6 +3,7 @@ import packageJson from "../package.json" with { type: "json" }; // Default config values export const DEFAULT_MAX_RESULTS = 5; +export const DEFAULT_MAX_PAYLOAD_FILES = 500; export const manifest = { name: "metadata-echo", @@ -29,6 +30,25 @@ export const manifest = { default: DEFAULT_MAX_RESULTS, example: 10, }, + { + key: "recordPayloads", + label: "Record Payloads", + description: + "Write each request and its response to JSON files under the plugin's data directory for debugging.", + type: "boolean" as const, + required: false, + default: true, + }, + { + key: "maxPayloadFiles", + label: "Max Payload Files", + description: + "Maximum number of recorded payload files to keep; oldest are pruned. Only used when payload recording is enabled.", + type: "number" as const, + required: false, + default: DEFAULT_MAX_PAYLOAD_FILES, + example: 500, + }, ], }, } as const satisfies PluginManifest & { diff --git a/plugins/metadata-echo/src/recorder.test.ts b/plugins/metadata-echo/src/recorder.test.ts new file mode 100644 index 00000000..25a8d2ce --- /dev/null +++ b/plugins/metadata-echo/src/recorder.test.ts @@ -0,0 +1,184 @@ +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { PayloadRecorder, type RecorderLogger, redactConfig } from "./recorder.js"; + +function makeLogger(): RecorderLogger & { warnings: string[] } { + const warnings: string[] = []; + return { + warnings, + info: () => {}, + debug: () => {}, + warn: (m: string) => warnings.push(m), + }; +} + +const fixedDate = new Date("2026-06-07T08:09:05.123Z"); + +describe("redactConfig", () => { + it("redacts secret-like keys but keeps the rest", () => { + const out = redactConfig({ + adminConfig: { recordPayloads: true, apiKey: "abc", clientSecret: "xyz" }, + userConfig: { progressUnit: "volumes", access_token: "tok", nested: { password: "p" } }, + }) as { + adminConfig: Record; + userConfig: Record; + }; + + expect(out.adminConfig.recordPayloads).toBe(true); + expect(out.adminConfig.apiKey).toBe("[REDACTED]"); + expect(out.adminConfig.clientSecret).toBe("[REDACTED]"); + expect(out.userConfig.progressUnit).toBe("volumes"); + expect(out.userConfig.access_token).toBe("[REDACTED]"); + expect((out.userConfig.nested as Record).password).toBe("[REDACTED]"); + }); + + it("defaults missing config sections to empty objects", () => { + expect(redactConfig({})).toEqual({ adminConfig: {}, userConfig: {} }); + }); +}); + +describe("PayloadRecorder", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "rec-test-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("writes paired request/response files with a sortable basename", async () => { + const recorder = new PayloadRecorder({ + pluginName: "metadata-echo", + dataDir: dir, + configSnapshot: { adminConfig: {}, userConfig: {} }, + logger: makeLogger(), + now: () => fixedDate, + }); + + await recorder.record("metadata/search", { query: "naruto" }, { results: [] }); + + const files = (await readdir(join(dir, "payloads"))).sort(); + expect(files).toEqual([ + "2026-06-07-08-09-05-0001-metadata_search-request.json", + "2026-06-07-08-09-05-0001-metadata_search-response.json", + ]); + }); + + it("embeds payload + config in the envelope and pairs by id", async () => { + const snapshot = { adminConfig: { maxResults: 5 }, userConfig: {} }; + const recorder = new PayloadRecorder({ + pluginName: "metadata-echo", + dataDir: dir, + configSnapshot: snapshot, + logger: makeLogger(), + now: () => fixedDate, + }); + + await recorder.record("sync/pushProgress", { entries: [{ externalId: "1" }] }, { success: [] }); + + const reqRaw = await readFile( + join(dir, "payloads", "2026-06-07-08-09-05-0001-sync_pushProgress-request.json"), + "utf8", + ); + const resRaw = await readFile( + join(dir, "payloads", "2026-06-07-08-09-05-0001-sync_pushProgress-response.json"), + "utf8", + ); + const req = JSON.parse(reqRaw); + const res = JSON.parse(resRaw); + + expect(req.direction).toBe("request"); + expect(req.method).toBe("sync/pushProgress"); + expect(req.id).toBe(1); + expect(req.config).toEqual(snapshot); + expect(req.payload).toEqual({ entries: [{ externalId: "1" }] }); + expect(req.timestamp).toBe("2026-06-07T08:09:05.123Z"); + + expect(res.direction).toBe("response"); + expect(res.id).toBe(1); + expect(res.payload).toEqual({ success: [] }); + }); + + it("increments the id per call", async () => { + const recorder = new PayloadRecorder({ + pluginName: "p", + dataDir: dir, + configSnapshot: {}, + logger: makeLogger(), + now: () => fixedDate, + }); + + await recorder.record("a/b", {}, {}); + await recorder.record("a/b", {}, {}); + + const files = (await readdir(join(dir, "payloads"))).sort(); + expect(files).toContain("2026-06-07-08-09-05-0001-a_b-request.json"); + expect(files).toContain("2026-06-07-08-09-05-0002-a_b-request.json"); + }); + + it("prunes oldest files beyond maxFiles", async () => { + const recorder = new PayloadRecorder({ + pluginName: "p", + dataDir: dir, + configSnapshot: {}, + logger: makeLogger(), + maxFiles: 2, // keeps only the newest single call (2 files) + now: () => fixedDate, + }); + + await recorder.record("m", {}, {}); // id 0001 + await recorder.record("m", {}, {}); // id 0002 + + const files = (await readdir(join(dir, "payloads"))).sort(); + expect(files).toEqual([ + "2026-06-07-08-09-05-0002-m-request.json", + "2026-06-07-08-09-05-0002-m-response.json", + ]); + }); + + it("writes nothing when disabled", async () => { + const recorder = new PayloadRecorder({ + pluginName: "p", + dataDir: dir, + configSnapshot: {}, + logger: makeLogger(), + enabled: false, + now: () => fixedDate, + }); + + await recorder.record("m", {}, {}); + await expect(readdir(join(dir, "payloads"))).rejects.toThrow(); // dir never created + }); + + it("falls back to the temp dir and warns when no dataDir is given", () => { + const logger = makeLogger(); + const recorder = new PayloadRecorder({ + pluginName: "metadata-echo", + configSnapshot: {}, + logger, + now: () => fixedDate, + }); + expect(recorder.directory).toBe(join(tmpdir(), "codex-metadata-echo", "payloads")); + }); + + it("never throws when the directory cannot be created", async () => { + const logger = makeLogger(); + // A path under an existing file cannot be turned into a directory (ENOTDIR). + const blocker = join(dir, "blocker"); + await writeFile(blocker, "not a dir", "utf8"); + const recorder = new PayloadRecorder({ + pluginName: "p", + dataDir: blocker, + configSnapshot: {}, + logger, + now: () => fixedDate, + }); + + await expect(recorder.record("m", {}, {})).resolves.toBeUndefined(); + expect(logger.warnings.length).toBeGreaterThan(0); + }); +}); diff --git a/plugins/metadata-echo/src/recorder.ts b/plugins/metadata-echo/src/recorder.ts new file mode 100644 index 00000000..7692d81a --- /dev/null +++ b/plugins/metadata-echo/src/recorder.ts @@ -0,0 +1,238 @@ +/** + * Payload Recorder - debug helper for echo plugins. + * + * Writes every request and its matching response to paired JSON files under the + * plugin's host-provided file-storage directory (`InitializeParams.dataDir`), so + * the host -> plugin protocol traffic can be inspected without trawling the + * server logs. This is intended for the echo (test/debug) plugins only. + * + * Files for one call share a sortable basename and differ only by suffix: + * + * {yyyy-MM-dd-HH-mm-ss}-{id}-{method}-request.json + * {yyyy-MM-dd-HH-mm-ss}-{id}-{method}-response.json + * + * Timestamps are UTC + 24-hour + zero-padded, so lexical sort == chronological + * sort regardless of the server's timezone. `{id}` is a zero-padded monotonic + * per-process counter that breaks ties within the same second and pairs the two + * files. Each file is a JSON envelope holding the payload plus a snapshot of the + * active config (credentials redacted). + * + * All filesystem I/O is best-effort: a failure is logged and swallowed so a disk + * problem never breaks an RPC response. + * + * NOTE: this module is intentionally duplicated across the echo plugins to keep + * the published SDK surface small. Keep the copies in sync. + */ + +import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Minimal logger contract (compatible with the SDK `Logger`). */ +export interface RecorderLogger { + info(message: string): void; + warn(message: string): void; + debug(message: string): void; +} + +export interface PayloadRecorderOptions { + /** Plugin name, used for the fallback directory and the file envelope. */ + pluginName: string; + /** Host-provided file-storage directory (`InitializeParams.dataDir`). */ + dataDir?: string; + /** Whether recording is on (default: true). */ + enabled?: boolean; + /** Maximum number of files to keep; oldest are pruned (default: 500). */ + maxFiles?: number; + /** Active config snapshot to embed in each file (should be pre-redacted). */ + configSnapshot: unknown; + /** Logger for diagnostics. */ + logger: RecorderLogger; + /** Clock injection for tests (default: `() => new Date()`). */ + now?: () => Date; +} + +/** Keys whose values are dropped from on-disk config snapshots. */ +const SECRET_KEY_RE = /token|secret|password|api[-_]?key|credential/i; +const REDACTED = "[REDACTED]"; + +const DEFAULT_MAX_FILES = 500; + +function pad(value: number, width: number): string { + return String(value).padStart(width, "0"); +} + +/** Format a date as `yyyy-MM-dd-HH-mm-ss` in UTC. */ +function utcStamp(date: Date): string { + const y = date.getUTCFullYear(); + const mo = pad(date.getUTCMonth() + 1, 2); + const d = pad(date.getUTCDate(), 2); + const h = pad(date.getUTCHours(), 2); + const mi = pad(date.getUTCMinutes(), 2); + const s = pad(date.getUTCSeconds(), 2); + return `${y}-${mo}-${d}-${h}-${mi}-${s}`; +} + +/** Replace non-alphanumeric runs with `_` so a method is filename-safe. */ +function sanitizeMethod(method: string): string { + return method.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, ""); +} + +/** + * Recursively copy a config object, replacing values under secret-like keys + * with `[REDACTED]`. Arrays and primitives are returned as-is. + */ +function redactObject(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(redactObject); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + out[key] = SECRET_KEY_RE.test(key) ? REDACTED : redactObject(val); + } + return out; + } + return value; +} + +/** + * Build a redacted config snapshot from initialize params. Credentials are + * never included (they are passed separately, not via config); secret-like + * config keys are additionally redacted as a defensive measure. + */ +export function redactConfig(input: { + adminConfig?: Record; + userConfig?: Record; +}): { adminConfig: unknown; userConfig: unknown } { + return { + adminConfig: redactObject(input.adminConfig ?? {}), + userConfig: redactObject(input.userConfig ?? {}), + }; +} + +interface Envelope { + timestamp: string; + plugin: string; + method: string; + direction: "request" | "response"; + id: number; + config: unknown; + payload: unknown; +} + +export class PayloadRecorder { + private readonly pluginName: string; + private readonly enabled: boolean; + private readonly maxFiles: number; + private readonly configSnapshot: unknown; + private readonly logger: RecorderLogger; + private readonly now: () => Date; + private readonly dir: string; + private readonly usingFallback: boolean; + private seq = 0; + private ready: Promise | null = null; + + constructor(opts: PayloadRecorderOptions) { + this.pluginName = opts.pluginName; + this.enabled = opts.enabled ?? true; + this.maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES; + this.configSnapshot = opts.configSnapshot; + this.logger = opts.logger; + this.now = opts.now ?? (() => new Date()); + + if (opts.dataDir) { + this.dir = join(opts.dataDir, "payloads"); + this.usingFallback = false; + } else { + this.dir = join(tmpdir(), `codex-${opts.pluginName}`, "payloads"); + this.usingFallback = true; + } + } + + /** Absolute directory payloads are written to (for logging/tests). */ + get directory(): string { + return this.dir; + } + + /** Lazily create the payloads directory once; returns false if unusable. */ + private ensureDir(): Promise { + if (!this.ready) { + this.ready = mkdir(this.dir, { recursive: true }) + .then(() => { + if (this.usingFallback) { + this.logger.warn(`No dataDir provided; recording payloads under temp dir ${this.dir}`); + } else { + this.logger.debug(`Recording payloads to ${this.dir}`); + } + return true; + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "unknown error"; + this.logger.warn(`Failed to create payload dir ${this.dir}: ${msg}`); + return false; + }); + } + return this.ready; + } + + /** + * Record a request and its response under one shared, sortable basename. + * Best-effort: never throws. + */ + async record(method: string, request: unknown, response: unknown): Promise { + if (!this.enabled) return; + if (!(await this.ensureDir())) return; + + const id = ++this.seq; + const date = this.now(); + const base = `${utcStamp(date)}-${pad(id, 4)}-${sanitizeMethod(method)}`; + const iso = date.toISOString(); + + await this.writeFile(`${base}-request.json`, { + timestamp: iso, + plugin: this.pluginName, + method, + direction: "request", + id, + config: this.configSnapshot, + payload: request, + }); + await this.writeFile(`${base}-response.json`, { + timestamp: iso, + plugin: this.pluginName, + method, + direction: "response", + id, + config: this.configSnapshot, + payload: response, + }); + + await this.prune(); + } + + private async writeFile(name: string, envelope: Envelope): Promise { + try { + await writeFile(join(this.dir, name), JSON.stringify(envelope, null, 2), "utf8"); + } catch (err) { + const msg = err instanceof Error ? err.message : "unknown error"; + this.logger.warn(`Failed to write payload file ${name}: ${msg}`); + } + } + + /** Keep at most `maxFiles` files, deleting the oldest (lexical == chrono). */ + private async prune(): Promise { + if (this.maxFiles <= 0) return; + try { + const files = (await readdir(this.dir)).filter((f) => f.endsWith(".json")).sort(); + const excess = files.length - this.maxFiles; + if (excess <= 0) return; + for (const file of files.slice(0, excess)) { + await rm(join(this.dir, file), { force: true }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "unknown error"; + this.logger.warn(`Failed to prune payload dir ${this.dir}: ${msg}`); + } + } +} diff --git a/plugins/sdk-typescript/src/server.ts b/plugins/sdk-typescript/src/server.ts index f278091a..b384a866 100644 --- a/plugins/sdk-typescript/src/server.ts +++ b/plugins/sdk-typescript/src/server.ts @@ -157,6 +157,17 @@ export interface InitializeParams { userConfig?: Record; /** Plugin credentials (API keys, tokens, etc.) */ credentials?: Record; + /** + * Scoped, writable data directory for this plugin's file storage. + * + * The host creates it at `{plugins_dir}/{plugin_name}/` and passes the + * absolute path here. Unlike {@link storage} (a small DB-backed key-value + * store with per-connection quotas), this is a real filesystem directory + * with no quotas — use it for larger file-based storage (SQLite databases, + * caches, debug dumps). Absent if the host has no plugin file storage + * configured. + */ + dataDir?: string; /** * Per-user key-value storage client. * diff --git a/plugins/sdk-typescript/src/types/manifest.ts b/plugins/sdk-typescript/src/types/manifest.ts index d388db68..18c16769 100644 --- a/plugins/sdk-typescript/src/types/manifest.ts +++ b/plugins/sdk-typescript/src/types/manifest.ts @@ -76,6 +76,16 @@ export interface PluginCapabilities { metadataProvider?: MetadataContentType[]; /** Can sync reading progress with external service (per-user) */ userReadSync?: boolean; + /** + * Request the per-book detailed progress payload on sync push. + * + * When `true`, the host populates `SyncProgress.maxVolume` / `maxChapter` + * and the per-book `SyncProgress.readBooks` breakdown on pushed entries. + * The host only assembles this heavier payload for plugins that opt in, so + * plugins that don't declare it are unaffected. Only meaningful when + * `userReadSync` is true. + */ + wantsDetailedProgress?: boolean; /** * External ID source used to match sync entries to Codex series. * When set, pulled sync entries are matched to series via the From e4dd46674ee1519a7562e389d5a438f76745ee0b Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sun, 7 Jun 2026 10:30:01 -0700 Subject: [PATCH 16/27] feat(plugins): add sync-echo debug plugin A test/debug sync plugin that talks to no external service. It accepts any push (echoing every entry back as alternating created/updated, never failing), returns deterministic, fully-populated entries on pull (respecting the request limit), and reports canned status. Declares wantsDetailedProgress so it receives the per-book detailed progress payload, and records every request and response to files via the shared payload recorder, making it easy to inspect what the host sends over the sync protocol without trawling server logs. Includes unit tests for the provider and the recording integration. --- plugins/sync-echo/.gitignore | 11 + plugins/sync-echo/README.md | 62 + plugins/sync-echo/package-lock.json | 2016 +++++++++++++++++++++++++++ plugins/sync-echo/package.json | 51 + plugins/sync-echo/src/index.test.ts | 107 ++ plugins/sync-echo/src/index.ts | 190 +++ plugins/sync-echo/src/manifest.ts | 59 + plugins/sync-echo/src/recorder.ts | 238 ++++ plugins/sync-echo/tsconfig.json | 24 + plugins/sync-echo/vitest.config.ts | 14 + 10 files changed, 2772 insertions(+) create mode 100644 plugins/sync-echo/.gitignore create mode 100644 plugins/sync-echo/README.md create mode 100644 plugins/sync-echo/package-lock.json create mode 100644 plugins/sync-echo/package.json create mode 100644 plugins/sync-echo/src/index.test.ts create mode 100644 plugins/sync-echo/src/index.ts create mode 100644 plugins/sync-echo/src/manifest.ts create mode 100644 plugins/sync-echo/src/recorder.ts create mode 100644 plugins/sync-echo/tsconfig.json create mode 100644 plugins/sync-echo/vitest.config.ts diff --git a/plugins/sync-echo/.gitignore b/plugins/sync-echo/.gitignore new file mode 100644 index 00000000..e389b224 --- /dev/null +++ b/plugins/sync-echo/.gitignore @@ -0,0 +1,11 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# TypeScript +*.tsbuildinfo + +# OS files +.DS_Store diff --git a/plugins/sync-echo/README.md b/plugins/sync-echo/README.md new file mode 100644 index 00000000..b1510173 --- /dev/null +++ b/plugins/sync-echo/README.md @@ -0,0 +1,62 @@ +# @ashdev/codex-plugin-sync-echo + +A minimal test/debug **sync** plugin for the Codex plugin system. It talks to no +external service: it accepts any push, returns deterministic reading entries on +pull, and records every request and response to files for inspection. + +## Purpose + +1. **Protocol validation** - a reference implementation of the sync provider + protocol (`getUserInfo`, `pushProgress`, `pullProgress`, `status`). +2. **Debugging** - declares `wantsDetailedProgress`, so it receives the per-book + detailed progress payload, and writes every request/response to JSON files so + you can see exactly what the host sends without trawling server logs. + +## Behavior + +- **`sync/getUserInfo`** - returns a fixed fake identity (`echo_user`). +- **`sync/pushProgress`** - logs the inbound entries and echoes them all back as + successes, alternating `created` / `updated`; nothing is ever rejected. +- **`sync/pullProgress`** - returns `pullCount` deterministic entries with every + `SyncEntry` / `SyncProgress` field populated (status, chapters/volumes, pages, + `maxVolume`/`maxChapter`, per-book `readBooks`, score, dates, notes). Respects + the request `limit` (capped at `pullCount`). +- **`sync/status`** - returns canned counts. + +## Payload recording + +When `recordPayloads` is enabled (default), each call writes two JSON files to +`{dataDir}/payloads/` (the plugin's host-provided data directory): + +``` +yyyy-MM-dd-HH-mm-ss-{id}-{method}-request.json +yyyy-MM-dd-HH-mm-ss-{id}-{method}-response.json +``` + +Timestamps are UTC, so the files sort chronologically. Each file is a JSON +envelope holding the payload plus a snapshot of the active config. **Credentials +are never written**; secret-like config keys are redacted. The number of files +is bounded by `maxPayloadFiles` (oldest pruned). If no data directory is +available, files fall back to the OS temp dir. + +## Configuration + +| Key | Default | Description | +| ----------------- | ------- | ------------------------------------------------------------------ | +| `pullCount` | `3` | How many deterministic entries `pullProgress` returns (1-50). | +| `recordPayloads` | `true` | Write request/response files for debugging. | +| `maxPayloadFiles` | `500` | Maximum recorded files to keep; oldest are pruned. | + +## Development + +```bash +npm install +npm run build # bundle to dist/index.js +npm run typecheck +npm run lint +npm test +``` + +## License + +MIT diff --git a/plugins/sync-echo/package-lock.json b/plugins/sync-echo/package-lock.json new file mode 100644 index 00000000..24486644 --- /dev/null +++ b/plugins/sync-echo/package-lock.json @@ -0,0 +1,2016 @@ +{ + "name": "@ashdev/codex-plugin-sync-echo", + "version": "1.35.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ashdev/codex-plugin-sync-echo", + "version": "1.35.0", + "license": "MIT", + "dependencies": { + "@ashdev/codex-plugin-sdk": "file:../sdk-typescript" + }, + "bin": { + "codex-plugin-sync-echo": "dist/index.js" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/node": "^22.0.0", + "esbuild": "^0.27.3", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "../sdk-typescript": { + "name": "@ashdev/codex-plugin-sdk", + "version": "1.35.0", + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/node": "^22.0.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@ashdev/codex-plugin-sdk": { + "resolved": "../sdk-typescript", + "link": true + }, + "node_modules/@biomejs/biome": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", + "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.16", + "@biomejs/cli-darwin-x64": "2.4.16", + "@biomejs/cli-linux-arm64": "2.4.16", + "@biomejs/cli-linux-arm64-musl": "2.4.16", + "@biomejs/cli-linux-x64": "2.4.16", + "@biomejs/cli-linux-x64-musl": "2.4.16", + "@biomejs/cli-win32-arm64": "2.4.16", + "@biomejs/cli-win32-x64": "2.4.16" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz", + "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz", + "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz", + "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz", + "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz", + "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz", + "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz", + "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz", + "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz", + "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/plugins/sync-echo/package.json b/plugins/sync-echo/package.json new file mode 100644 index 00000000..328a05d3 --- /dev/null +++ b/plugins/sync-echo/package.json @@ -0,0 +1,51 @@ +{ + "name": "@ashdev/codex-plugin-sync-echo", + "version": "1.35.0", + "description": "Echo sync plugin for testing and debugging the Codex plugin sync protocol", + "main": "dist/index.js", + "bin": "dist/index.js", + "type": "module", + "files": [ + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/AshDevFr/codex.git", + "directory": "plugins/sync-echo" + }, + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'", + "dev": "npm run build -- --watch", + "clean": "rm -rf dist", + "start": "node dist/index.js", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "prepublishOnly": "npm run lint && npm run build" + }, + "keywords": [ + "codex", + "plugin", + "sync", + "echo", + "testing" + ], + "author": "Codex", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + }, + "dependencies": { + "@ashdev/codex-plugin-sdk": "file:../sdk-typescript" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/node": "^22.0.0", + "esbuild": "^0.27.3", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/plugins/sync-echo/src/index.test.ts b/plugins/sync-echo/src/index.test.ts new file mode 100644 index 00000000..8995f6c8 --- /dev/null +++ b/plugins/sync-echo/src/index.test.ts @@ -0,0 +1,107 @@ +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SyncPushRequest } from "@ashdev/codex-plugin-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { provider, setPullCount, setRecorder } from "./index.js"; +import { PayloadRecorder } from "./recorder.js"; + +afterEach(() => { + setRecorder(null); + setPullCount(3); +}); + +describe("getUserInfo", () => { + it("returns a deterministic fake identity", async () => { + const info = await provider.getUserInfo(); + expect(info.externalId).toBe("echo-user-1"); + expect(info.username).toBe("echo_user"); + }); +}); + +describe("pushProgress", () => { + it("echoes every entry as success, alternating created/updated, never failing", async () => { + const req: SyncPushRequest = { + entries: [ + { externalId: "a", status: "reading" }, + { externalId: "b", status: "completed" }, + { externalId: "c", status: "dropped" }, + ], + }; + const res = await provider.pushProgress(req); + + expect(res.failed).toEqual([]); + expect(res.success.map((s) => s.externalId)).toEqual(["a", "b", "c"]); + expect(res.success.map((s) => s.status)).toEqual(["created", "updated", "created"]); + }); +}); + +describe("pullProgress", () => { + it("returns pullCount fully-populated entries by default", async () => { + setPullCount(3); + const res = await provider.pullProgress({}); + + expect(res.hasMore).toBe(false); + expect(res.entries).toHaveLength(3); + + const first = res.entries[0]; + expect(first.externalId).toBe("1000"); + expect(first.status).toBe("reading"); + expect(first.score).toBeDefined(); + expect(first.startedAt).toBeDefined(); + expect(first.completedAt).toBeDefined(); + expect(first.notes).toBeDefined(); + expect(first.latestUpdatedAt).toBeDefined(); + expect(first.title).toBe("Echo Series 1"); + // Detailed progress is populated (this plugin opts into wantsDetailedProgress). + expect(first.progress?.maxVolume).toBe(1); + expect(first.progress?.maxChapter).toBe(1.5); + expect(first.progress?.readBooks).toHaveLength(1); + }); + + it("respects an explicit limit, capped at pullCount", async () => { + setPullCount(5); + expect((await provider.pullProgress({ limit: 2 })).entries).toHaveLength(2); + expect((await provider.pullProgress({ limit: 99 })).entries).toHaveLength(5); + }); +}); + +describe("status", () => { + it("returns canned status counts", async () => { + setPullCount(7); + const status = await provider.status(); + expect(status.externalCount).toBe(7); + expect(status.pendingPush).toBe(0); + expect(status.conflicts).toBe(0); + }); +}); + +describe("payload recording", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "sync-echo-test-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("writes paired request/response files when a recorder is set", async () => { + setRecorder( + new PayloadRecorder({ + pluginName: "sync-echo", + dataDir: dir, + configSnapshot: { adminConfig: {}, userConfig: {} }, + logger: { info: () => {}, warn: () => {}, debug: () => {} }, + }), + ); + + await provider.pushProgress({ entries: [{ externalId: "a", status: "reading" }] }); + + const files = (await readdir(join(dir, "payloads"))).sort(); + expect(files).toHaveLength(2); + expect(files.some((f) => f.endsWith("-sync_pushProgress-request.json"))).toBe(true); + expect(files.some((f) => f.endsWith("-sync_pushProgress-response.json"))).toBe(true); + }); +}); diff --git a/plugins/sync-echo/src/index.ts b/plugins/sync-echo/src/index.ts new file mode 100644 index 00000000..99c366a9 --- /dev/null +++ b/plugins/sync-echo/src/index.ts @@ -0,0 +1,190 @@ +/** + * Echo Sync Plugin for Codex + * + * A minimal test/debug sync plugin. It does not talk to any external service: + * it accepts any push (echoing every entry back as created/updated), returns + * deterministic, fully-populated entries on pull, and reports canned status. + * + * Its main purpose is debugging the host -> plugin sync protocol: it declares + * `wantsDetailedProgress` so it receives the per-book detailed progress payload, + * and records every request and its response to JSON files under the plugin's + * data directory (see `recorder.ts`). + */ + +import { + createLogger, + createSyncPlugin, + type ExternalUserInfo, + type InitializeParams, + type SyncEntry, + type SyncEntryResult, + type SyncProvider, + type SyncPullRequest, + type SyncPullResponse, + type SyncPushRequest, + type SyncPushResponse, + type SyncStatusResponse, +} from "@ashdev/codex-plugin-sdk"; +import { DEFAULT_MAX_PAYLOAD_FILES, DEFAULT_PULL_COUNT, manifest } from "./manifest.js"; +import { PayloadRecorder, redactConfig } from "./recorder.js"; + +const logger = createLogger({ name: "sync-echo", level: "debug" }); + +// Plugin configuration (set during initialization) +const config = { + pullCount: DEFAULT_PULL_COUNT, +}; + +// Payload recorder (set during initialization) +let recorder: PayloadRecorder | null = null; + +/** Record a request/response pair (best-effort) and return the response. */ +async function rec(method: string, params: unknown, response: T): Promise { + await recorder?.record(method, params, response); + return response; +} + +/** Set the payload recorder (exported for testing) */ +export function setRecorder(r: PayloadRecorder | null): void { + recorder = r; +} + +/** Set the pull entry count (exported for testing) */ +export function setPullCount(count: number): void { + config.pullCount = count; +} + +const STATUSES: SyncEntry["status"][] = [ + "reading", + "completed", + "on_hold", + "plan_to_read", + "dropped", +]; + +/** + * Build a deterministic, fully-populated sync entry for the given index. Every + * optional field is set so the host's pull-ingest path is exercised end to end. + */ +function makeEntry(i: number): SyncEntry { + const day = String((i % 28) + 1).padStart(2, "0"); + return { + externalId: String(1000 + i), + status: STATUSES[i % STATUSES.length], + progress: { + chapters: i + 1, + volumes: i + 1, + pages: (i + 1) * 20, + totalChapters: 100, + totalVolumes: 12, + maxVolume: i + 1, + maxChapter: i + 1.5, + readBooks: [ + { + volume: i + 1, + chapter: i + 1.5, + completed: i % 2 === 0, + currentPage: (i + 1) * 10, + progressPercentage: 0.5, + }, + ], + }, + score: 70 + (i % 30), + startedAt: `2026-01-${day}T00:00:00.000Z`, + completedAt: `2026-02-${day}T00:00:00.000Z`, + notes: `Echo note for entry ${i}`, + latestUpdatedAt: `2026-03-${day}T00:00:00.000Z`, + title: `Echo Series ${i + 1}`, + }; +} + +/** Exported for testing */ +export const provider: SyncProvider = { + async getUserInfo(): Promise { + return rec("sync/getUserInfo", null, { + externalId: "echo-user-1", + username: "echo_user", + avatarUrl: "https://picsum.photos/100/100", + profileUrl: "https://echo.example.com/user/echo_user", + }); + }, + + async pushProgress(params: SyncPushRequest): Promise { + logger.info(`Push received: ${params.entries.length} entries`); + + // Echo every entry back as a success, alternating created/updated so both + // result statuses are exercised. Nothing is ever rejected. + const success: SyncEntryResult[] = params.entries.map((entry, i) => ({ + externalId: entry.externalId, + status: i % 2 === 0 ? "created" : "updated", + })); + + return rec("sync/pushProgress", params, { + success, + failed: [], + }); + }, + + async pullProgress(params: SyncPullRequest): Promise { + const requested = params.limit ?? config.pullCount; + const count = Math.min(Math.max(0, requested), config.pullCount); + const entries = Array.from({ length: count }, (_, i) => makeEntry(i)); + + logger.info(`Pull returning ${entries.length} deterministic entries`); + + return rec("sync/pullProgress", params, { + entries, + hasMore: false, + }); + }, + + async status(): Promise { + return rec("sync/status", null, { + lastSyncAt: "2026-03-01T00:00:00.000Z", + externalCount: config.pullCount, + pendingPush: 0, + pendingPull: 0, + conflicts: 0, + }); + }, +}; + +// ============================================================================= +// Plugin Initialization +// ============================================================================= + +createSyncPlugin({ + manifest, + provider, + logLevel: "debug", + onInitialize(params: InitializeParams) { + const pullCount = params.adminConfig?.pullCount; + if (typeof pullCount === "number") { + config.pullCount = Math.min(Math.max(1, Math.floor(pullCount)), 50); + } + + // Set up payload recording (on by default for this debug plugin) + const recordPayloads = params.adminConfig?.recordPayloads !== false; + const maxPayloadFiles = + typeof params.adminConfig?.maxPayloadFiles === "number" + ? params.adminConfig.maxPayloadFiles + : DEFAULT_MAX_PAYLOAD_FILES; + recorder = new PayloadRecorder({ + pluginName: manifest.name, + dataDir: params.dataDir, + enabled: recordPayloads, + maxFiles: maxPayloadFiles, + configSnapshot: redactConfig({ + adminConfig: params.adminConfig, + userConfig: params.userConfig, + }), + logger, + }); + + logger.info( + `Echo sync plugin initialized (pullCount: ${config.pullCount}, recordPayloads: ${recordPayloads})`, + ); + }, +}); + +logger.info("Echo sync plugin started"); diff --git a/plugins/sync-echo/src/manifest.ts b/plugins/sync-echo/src/manifest.ts new file mode 100644 index 00000000..fe91589d --- /dev/null +++ b/plugins/sync-echo/src/manifest.ts @@ -0,0 +1,59 @@ +import type { PluginManifest } from "@ashdev/codex-plugin-sdk"; +import packageJson from "../package.json" with { type: "json" }; + +// Default config values +export const DEFAULT_PULL_COUNT = 3; +export const DEFAULT_MAX_PAYLOAD_FILES = 500; + +export const manifest = { + name: "sync-echo", + displayName: "Echo Sync Plugin", + version: packageJson.version, + description: + "Test sync plugin that echoes back push payloads and returns deterministic pull entries. Records every request/response to files for debugging.", + author: "Codex", + homepage: "https://github.com/AshDevFr/codex", + protocolVersion: "1.0", + capabilities: { + userReadSync: true, + // Opt in to the per-book detailed progress payload so it can be inspected. + wantsDetailedProgress: true, + }, + configSchema: { + description: "Configuration options for the Echo sync test plugin", + fields: [ + { + key: "pullCount", + label: "Pull Entry Count", + description: "How many deterministic entries pullProgress should return (1-50).", + type: "number" as const, + required: false, + default: DEFAULT_PULL_COUNT, + example: 5, + }, + { + key: "recordPayloads", + label: "Record Payloads", + description: + "Write each request and its response to JSON files under the plugin's data directory for debugging.", + type: "boolean" as const, + required: false, + default: true, + }, + { + key: "maxPayloadFiles", + label: "Max Payload Files", + description: + "Maximum number of recorded payload files to keep; oldest are pruned. Only used when payload recording is enabled.", + type: "number" as const, + required: false, + default: DEFAULT_MAX_PAYLOAD_FILES, + example: 500, + }, + ], + }, + userDescription: + "A debug sync plugin: it accepts any push, returns canned reading entries on pull, and records all protocol traffic to files. No external account needed.", +} as const satisfies PluginManifest & { + capabilities: { userReadSync: true }; +}; diff --git a/plugins/sync-echo/src/recorder.ts b/plugins/sync-echo/src/recorder.ts new file mode 100644 index 00000000..7692d81a --- /dev/null +++ b/plugins/sync-echo/src/recorder.ts @@ -0,0 +1,238 @@ +/** + * Payload Recorder - debug helper for echo plugins. + * + * Writes every request and its matching response to paired JSON files under the + * plugin's host-provided file-storage directory (`InitializeParams.dataDir`), so + * the host -> plugin protocol traffic can be inspected without trawling the + * server logs. This is intended for the echo (test/debug) plugins only. + * + * Files for one call share a sortable basename and differ only by suffix: + * + * {yyyy-MM-dd-HH-mm-ss}-{id}-{method}-request.json + * {yyyy-MM-dd-HH-mm-ss}-{id}-{method}-response.json + * + * Timestamps are UTC + 24-hour + zero-padded, so lexical sort == chronological + * sort regardless of the server's timezone. `{id}` is a zero-padded monotonic + * per-process counter that breaks ties within the same second and pairs the two + * files. Each file is a JSON envelope holding the payload plus a snapshot of the + * active config (credentials redacted). + * + * All filesystem I/O is best-effort: a failure is logged and swallowed so a disk + * problem never breaks an RPC response. + * + * NOTE: this module is intentionally duplicated across the echo plugins to keep + * the published SDK surface small. Keep the copies in sync. + */ + +import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Minimal logger contract (compatible with the SDK `Logger`). */ +export interface RecorderLogger { + info(message: string): void; + warn(message: string): void; + debug(message: string): void; +} + +export interface PayloadRecorderOptions { + /** Plugin name, used for the fallback directory and the file envelope. */ + pluginName: string; + /** Host-provided file-storage directory (`InitializeParams.dataDir`). */ + dataDir?: string; + /** Whether recording is on (default: true). */ + enabled?: boolean; + /** Maximum number of files to keep; oldest are pruned (default: 500). */ + maxFiles?: number; + /** Active config snapshot to embed in each file (should be pre-redacted). */ + configSnapshot: unknown; + /** Logger for diagnostics. */ + logger: RecorderLogger; + /** Clock injection for tests (default: `() => new Date()`). */ + now?: () => Date; +} + +/** Keys whose values are dropped from on-disk config snapshots. */ +const SECRET_KEY_RE = /token|secret|password|api[-_]?key|credential/i; +const REDACTED = "[REDACTED]"; + +const DEFAULT_MAX_FILES = 500; + +function pad(value: number, width: number): string { + return String(value).padStart(width, "0"); +} + +/** Format a date as `yyyy-MM-dd-HH-mm-ss` in UTC. */ +function utcStamp(date: Date): string { + const y = date.getUTCFullYear(); + const mo = pad(date.getUTCMonth() + 1, 2); + const d = pad(date.getUTCDate(), 2); + const h = pad(date.getUTCHours(), 2); + const mi = pad(date.getUTCMinutes(), 2); + const s = pad(date.getUTCSeconds(), 2); + return `${y}-${mo}-${d}-${h}-${mi}-${s}`; +} + +/** Replace non-alphanumeric runs with `_` so a method is filename-safe. */ +function sanitizeMethod(method: string): string { + return method.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, ""); +} + +/** + * Recursively copy a config object, replacing values under secret-like keys + * with `[REDACTED]`. Arrays and primitives are returned as-is. + */ +function redactObject(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(redactObject); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + out[key] = SECRET_KEY_RE.test(key) ? REDACTED : redactObject(val); + } + return out; + } + return value; +} + +/** + * Build a redacted config snapshot from initialize params. Credentials are + * never included (they are passed separately, not via config); secret-like + * config keys are additionally redacted as a defensive measure. + */ +export function redactConfig(input: { + adminConfig?: Record; + userConfig?: Record; +}): { adminConfig: unknown; userConfig: unknown } { + return { + adminConfig: redactObject(input.adminConfig ?? {}), + userConfig: redactObject(input.userConfig ?? {}), + }; +} + +interface Envelope { + timestamp: string; + plugin: string; + method: string; + direction: "request" | "response"; + id: number; + config: unknown; + payload: unknown; +} + +export class PayloadRecorder { + private readonly pluginName: string; + private readonly enabled: boolean; + private readonly maxFiles: number; + private readonly configSnapshot: unknown; + private readonly logger: RecorderLogger; + private readonly now: () => Date; + private readonly dir: string; + private readonly usingFallback: boolean; + private seq = 0; + private ready: Promise | null = null; + + constructor(opts: PayloadRecorderOptions) { + this.pluginName = opts.pluginName; + this.enabled = opts.enabled ?? true; + this.maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES; + this.configSnapshot = opts.configSnapshot; + this.logger = opts.logger; + this.now = opts.now ?? (() => new Date()); + + if (opts.dataDir) { + this.dir = join(opts.dataDir, "payloads"); + this.usingFallback = false; + } else { + this.dir = join(tmpdir(), `codex-${opts.pluginName}`, "payloads"); + this.usingFallback = true; + } + } + + /** Absolute directory payloads are written to (for logging/tests). */ + get directory(): string { + return this.dir; + } + + /** Lazily create the payloads directory once; returns false if unusable. */ + private ensureDir(): Promise { + if (!this.ready) { + this.ready = mkdir(this.dir, { recursive: true }) + .then(() => { + if (this.usingFallback) { + this.logger.warn(`No dataDir provided; recording payloads under temp dir ${this.dir}`); + } else { + this.logger.debug(`Recording payloads to ${this.dir}`); + } + return true; + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "unknown error"; + this.logger.warn(`Failed to create payload dir ${this.dir}: ${msg}`); + return false; + }); + } + return this.ready; + } + + /** + * Record a request and its response under one shared, sortable basename. + * Best-effort: never throws. + */ + async record(method: string, request: unknown, response: unknown): Promise { + if (!this.enabled) return; + if (!(await this.ensureDir())) return; + + const id = ++this.seq; + const date = this.now(); + const base = `${utcStamp(date)}-${pad(id, 4)}-${sanitizeMethod(method)}`; + const iso = date.toISOString(); + + await this.writeFile(`${base}-request.json`, { + timestamp: iso, + plugin: this.pluginName, + method, + direction: "request", + id, + config: this.configSnapshot, + payload: request, + }); + await this.writeFile(`${base}-response.json`, { + timestamp: iso, + plugin: this.pluginName, + method, + direction: "response", + id, + config: this.configSnapshot, + payload: response, + }); + + await this.prune(); + } + + private async writeFile(name: string, envelope: Envelope): Promise { + try { + await writeFile(join(this.dir, name), JSON.stringify(envelope, null, 2), "utf8"); + } catch (err) { + const msg = err instanceof Error ? err.message : "unknown error"; + this.logger.warn(`Failed to write payload file ${name}: ${msg}`); + } + } + + /** Keep at most `maxFiles` files, deleting the oldest (lexical == chrono). */ + private async prune(): Promise { + if (this.maxFiles <= 0) return; + try { + const files = (await readdir(this.dir)).filter((f) => f.endsWith(".json")).sort(); + const excess = files.length - this.maxFiles; + if (excess <= 0) return; + for (const file of files.slice(0, excess)) { + await rm(join(this.dir, file), { force: true }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "unknown error"; + this.logger.warn(`Failed to prune payload dir ${this.dir}: ${msg}`); + } + } +} diff --git a/plugins/sync-echo/tsconfig.json b/plugins/sync-echo/tsconfig.json new file mode 100644 index 00000000..ef1ca5f9 --- /dev/null +++ b/plugins/sync-echo/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/plugins/sync-echo/vitest.config.ts b/plugins/sync-echo/vitest.config.ts new file mode 100644 index 00000000..ee073274 --- /dev/null +++ b/plugins/sync-echo/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: ["node_modules", "dist"], + }, + }, +}); From 1f178c777dad1d9e2ddc79dba00189c7aad2a3b8 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sun, 7 Jun 2026 11:16:45 -0700 Subject: [PATCH 17/27] feat(plugins): add recommendations-echo and wire up the echo plugins Add the recommendations-echo debug plugin: it echoes the user's library seeds back as fully-populated recommendations (respecting limit and excludeIds), implements updateProfile/dismiss/clear, and records every request/response to files via the shared payload recorder. Includes tests. Put the echo plugins on equal footing in the build and packaging setup: - CI: add sync-echo and recommendations-echo to the plugin test matrices in both ci.yml and build.yml, and to the publish matrix in build.yml. - docker-compose: mount, build, and watch both new plugins in the dev stack. - .dockerignore: exclude all plugins' build artifacts from the build context via globs. - docs: list the echo sync/recommendations plugins and document payload recording. Treat the echo plugins as debug-only rather than store offerings: remove the echo entry from the user-facing Official Plugins gallery and instead provision the echoes via the seed config (sample updated), where test/dev plugins belong. The Makefile needs no change; its plugin glob already discovers the new plugins. --- .dockerignore | 14 +- .github/workflows/build.yml | 4 + .github/workflows/ci.yml | 2 + config/seed-config.sample.yaml | 22 + docker-compose.yml | 12 +- docs/docs/plugins/index.md | 4 +- plugins/recommendations-echo/.gitignore | 11 + plugins/recommendations-echo/README.md | 60 + .../recommendations-echo/package-lock.json | 2016 +++++++++++++++++ plugins/recommendations-echo/package.json | 51 + .../recommendations-echo/src/index.test.ts | 114 + plugins/recommendations-echo/src/index.ts | 160 ++ plugins/recommendations-echo/src/manifest.ts | 49 + plugins/recommendations-echo/src/recorder.ts | 238 ++ plugins/recommendations-echo/tsconfig.json | 24 + plugins/recommendations-echo/vitest.config.ts | 14 + .../pages/settings/PluginsSettings.test.tsx | 11 +- .../settings/plugins/OfficialPlugins.tsx | 20 +- 18 files changed, 2793 insertions(+), 33 deletions(-) create mode 100644 plugins/recommendations-echo/.gitignore create mode 100644 plugins/recommendations-echo/README.md create mode 100644 plugins/recommendations-echo/package-lock.json create mode 100644 plugins/recommendations-echo/package.json create mode 100644 plugins/recommendations-echo/src/index.test.ts create mode 100644 plugins/recommendations-echo/src/index.ts create mode 100644 plugins/recommendations-echo/src/manifest.ts create mode 100644 plugins/recommendations-echo/src/recorder.ts create mode 100644 plugins/recommendations-echo/tsconfig.json create mode 100644 plugins/recommendations-echo/vitest.config.ts diff --git a/.dockerignore b/.dockerignore index 56fa7ebb..63466400 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,16 +21,10 @@ screenshots/.vite/ screenshots/fixtures/ screenshots/output/ -# Plugins build artifacts and dependencies -plugins/sdk-typescript/node_modules/ -plugins/sdk-typescript/dist/ -plugins/sdk-typescript/.vite/ -plugins/metadata-mangabaka/node_modules/ -plugins/metadata-mangabaka/dist/ -plugins/metadata-mangabaka/.vite/ -plugins/metadata-echo/node_modules/ -plugins/metadata-echo/dist/ -plugins/metadata-echo/.vite/ +# Plugins build artifacts and dependencies (all plugins; rebuilt inside the image) +plugins/*/node_modules/ +plugins/*/dist/ +plugins/*/.vite/ # Database files *.db diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5407eb89..9c10b073 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -148,6 +148,8 @@ jobs: plugin: - sdk-typescript - metadata-echo + - sync-echo + - recommendations-echo - metadata-mangabaka - metadata-openlibrary - recommendations-anilist @@ -576,6 +578,8 @@ jobs: matrix: plugin: - metadata-echo + - sync-echo + - recommendations-echo - metadata-mangabaka - metadata-openlibrary - recommendations-anilist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df4bfc1c..1a235725 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,8 @@ jobs: plugin: - sdk-typescript - metadata-echo + - sync-echo + - recommendations-echo - metadata-mangabaka - metadata-openlibrary - recommendations-anilist diff --git a/config/seed-config.sample.yaml b/config/seed-config.sample.yaml index 94925163..faa4356e 100644 --- a/config/seed-config.sample.yaml +++ b/config/seed-config.sample.yaml @@ -78,6 +78,28 @@ plugins: - "library:scan" credential_delivery: env + # Echo Sync - test/debug sync plugin (no credentials needed) + - name: sync-echo + display_name: Echo Sync Plugin + description: Test sync plugin that echoes push payloads and returns deterministic pull entries + plugin_type: user + command: node + args: ["/opt/codex/plugins/sync-echo/dist/index.js"] + permissions: [] + scopes: [] + credential_delivery: env + + # Echo Recommendations - test/debug recommendations plugin (no credentials needed) + - name: recommendations-echo + display_name: Echo Recommendations Plugin + description: Test recommendations plugin that echoes library seeds back as recommendations + plugin_type: user + command: node + args: ["/opt/codex/plugins/recommendations-echo/dist/index.js"] + permissions: [] + scopes: [] + credential_delivery: env + # Open Library - Book metadata provider (no credentials needed) - name: metadata-openlibrary display_name: Open Library diff --git a/docker-compose.yml b/docker-compose.yml index 3c704c1f..ed10d214 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,6 +92,8 @@ services: - ./plugins/metadata-mangabaka/dist:/opt/codex/plugins/metadata-mangabaka/dist:ro - ./plugins/metadata-echo/dist:/opt/codex/plugins/metadata-echo/dist:ro - ./plugins/metadata-openlibrary/dist:/opt/codex/plugins/metadata-openlibrary/dist:ro + - ./plugins/sync-echo/dist:/opt/codex/plugins/sync-echo/dist:ro + - ./plugins/recommendations-echo/dist:/opt/codex/plugins/recommendations-echo/dist:ro - ./plugins/recommendations-anilist/dist:/opt/codex/plugins/recommendations-anilist/dist:ro - ./plugins/sync-anilist/dist:/opt/codex/plugins/sync-anilist/dist:ro - ./plugins/release-mangaupdates/dist:/opt/codex/plugins/release-mangaupdates/dist:ro @@ -173,6 +175,8 @@ services: - ./plugins/metadata-mangabaka/dist:/opt/codex/plugins/metadata-mangabaka/dist:ro - ./plugins/metadata-echo/dist:/opt/codex/plugins/metadata-echo/dist:ro - ./plugins/metadata-openlibrary/dist:/opt/codex/plugins/metadata-openlibrary/dist:ro + - ./plugins/sync-echo/dist:/opt/codex/plugins/sync-echo/dist:ro + - ./plugins/recommendations-echo/dist:/opt/codex/plugins/recommendations-echo/dist:ro - ./plugins/recommendations-anilist/dist:/opt/codex/plugins/recommendations-anilist/dist:ro - ./plugins/sync-anilist/dist:/opt/codex/plugins/sync-anilist/dist:ro - ./plugins/release-mangaupdates/dist:/opt/codex/plugins/release-mangaupdates/dist:ro @@ -240,6 +244,8 @@ services: # Exclude node_modules to prevent platform-specific binary conflicts - /plugins/sdk-typescript/node_modules - /plugins/metadata-echo/node_modules + - /plugins/sync-echo/node_modules + - /plugins/recommendations-echo/node_modules - /plugins/metadata-mangabaka/node_modules - /plugins/metadata-openlibrary/node_modules - /plugins/recommendations-anilist/node_modules @@ -253,6 +259,8 @@ services: echo 'Installing plugin dependencies...' && cd /plugins/sdk-typescript && npm install && npm run build && cd /plugins/metadata-echo && npm install && npm run build && + cd /plugins/sync-echo && npm install && npm run build && + cd /plugins/recommendations-echo && npm install && npm run build && cd /plugins/metadata-mangabaka && npm install && npm run build && cd /plugins/metadata-openlibrary && npm install && npm run build && cd /plugins/recommendations-anilist && npm install && npm run build && @@ -261,9 +269,11 @@ services: cd /plugins/release-nyaa && npm install && npm run build && echo 'Initial build complete. Watching for changes...' && npm install -g concurrently && - concurrently --names 'sdk,echo,mangabaka,openlibrary,rec-anilist,sync-anilist,rel-mu,rel-nyaa' --prefix-colors 'blue,green,yellow,magenta,cyan,red,gray,white' \ + concurrently --names 'sdk,echo,sync-echo,rec-echo,mangabaka,openlibrary,rec-anilist,sync-anilist,rel-mu,rel-nyaa' --prefix-colors 'blue,green,greenBright,cyanBright,yellow,magenta,cyan,red,gray,white' \ "cd /plugins/sdk-typescript && npm run dev" \ "cd /plugins/metadata-echo && npm run dev" \ + "cd /plugins/sync-echo && npm run dev" \ + "cd /plugins/recommendations-echo && npm run dev" \ "cd /plugins/metadata-mangabaka && npm run dev" \ "cd /plugins/metadata-openlibrary && npm run dev" \ "cd /plugins/recommendations-anilist && npm run dev" \ diff --git a/docs/docs/plugins/index.md b/docs/docs/plugins/index.md index 90c28ddc..3cf0bbfd 100644 --- a/docs/docs/plugins/index.md +++ b/docs/docs/plugins/index.md @@ -20,12 +20,14 @@ Codex supports metadata plugins that can automatically fetch and enrich your lib | Plugin | Description | Source | |--------|-------------|--------| | [AniList Sync](./anilist-sync) | Sync manga reading progress between Codex and AniList | Free, requires AniList account | +| Sync Echo (built-in) | Development/testing plugin that echoes push payloads and returns deterministic pull entries; records all request/response payloads to files | Included with Codex | ### Recommendation Plugins | Plugin | Description | Source | |--------|-------------|--------| | [AniList Recommendations](./anilist-recommendations) | Personalized manga recommendations seeded by your highest-rated library entries | Free, requires AniList account | +| Recommendations Echo (built-in) | Development/testing plugin that echoes library seeds back as recommendations; records all request/response payloads to files | Included with Codex | ### Release Tracking Plugins @@ -248,7 +250,7 @@ The external service retains any data already synced to it (e.g., your AniList r Codex provides a TypeScript SDK for building metadata plugins. Plugins implement a JSON-RPC interface with methods for searching, matching, and retrieving metadata. -See the Echo plugin (`plugins/metadata-echo/`) as a reference implementation. +See the Echo plugins as reference implementations: `plugins/metadata-echo/` (metadata), `plugins/sync-echo/` (sync), and `plugins/recommendations-echo/` (recommendations). The Echo plugins are debug-only: each records every request and its response to JSON files under its data directory (`{dataDir}/payloads/`, paired `…-request.json` / `…-response.json` with a UTC-sortable name), embedding the active config with credentials redacted, to make protocol traffic easy to inspect. This is controlled per plugin via the `recordPayloads` / `maxPayloadFiles` config options. ### Protocol Versioning diff --git a/plugins/recommendations-echo/.gitignore b/plugins/recommendations-echo/.gitignore new file mode 100644 index 00000000..e389b224 --- /dev/null +++ b/plugins/recommendations-echo/.gitignore @@ -0,0 +1,11 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# TypeScript +*.tsbuildinfo + +# OS files +.DS_Store diff --git a/plugins/recommendations-echo/README.md b/plugins/recommendations-echo/README.md new file mode 100644 index 00000000..d0600bbb --- /dev/null +++ b/plugins/recommendations-echo/README.md @@ -0,0 +1,60 @@ +# @ashdev/codex-plugin-recommendations-echo + +A minimal test/debug **recommendations** plugin for the Codex plugin system. It +talks to no external service: it echoes your library seeds back as +recommendations and records every request and response to files for inspection. + +## Purpose + +1. **Protocol validation** - a reference implementation of the recommendation + provider protocol (`get`, `updateProfile`, `clear`, `dismiss`). +2. **Debugging** - writes every request/response to JSON files so you can see + exactly what the host sends (the library seeds, limit, excluded IDs) without + trawling server logs. + +## Behavior + +- **`recommendations/get`** - returns one fully-populated recommendation per + library seed, echoing the seed title into `reason` / `basedOn`. When the + library is empty, returns a few generic recommendations so the result is never + empty. Respects the request `limit` and skips any `excludeIds`. +- **`recommendations/updateProfile`** - reports the number of entries processed. +- **`recommendations/dismiss`** - acknowledges the dismissal. +- **`recommendations/clear`** - acknowledges the clear. + +## Payload recording + +When `recordPayloads` is enabled (default), each call writes two JSON files to +`{dataDir}/payloads/` (the plugin's host-provided data directory): + +``` +yyyy-MM-dd-HH-mm-ss-{id}-{method}-request.json +yyyy-MM-dd-HH-mm-ss-{id}-{method}-response.json +``` + +Timestamps are UTC, so the files sort chronologically. Each file is a JSON +envelope holding the payload plus a snapshot of the active config. **Credentials +are never written**; secret-like config keys are redacted. The number of files +is bounded by `maxPayloadFiles` (oldest pruned). If no data directory is +available, files fall back to the OS temp dir. + +## Configuration + +| Key | Default | Description | +| ----------------- | ------- | -------------------------------------------------- | +| `recordPayloads` | `true` | Write request/response files for debugging. | +| `maxPayloadFiles` | `500` | Maximum recorded files to keep; oldest are pruned. | + +## Development + +```bash +npm install +npm run build # bundle to dist/index.js +npm run typecheck +npm run lint +npm test +``` + +## License + +MIT diff --git a/plugins/recommendations-echo/package-lock.json b/plugins/recommendations-echo/package-lock.json new file mode 100644 index 00000000..e35c8308 --- /dev/null +++ b/plugins/recommendations-echo/package-lock.json @@ -0,0 +1,2016 @@ +{ + "name": "@ashdev/codex-plugin-recommendations-echo", + "version": "1.35.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ashdev/codex-plugin-recommendations-echo", + "version": "1.35.0", + "license": "MIT", + "dependencies": { + "@ashdev/codex-plugin-sdk": "file:../sdk-typescript" + }, + "bin": { + "codex-plugin-recommendations-echo": "dist/index.js" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/node": "^22.0.0", + "esbuild": "^0.27.3", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "../sdk-typescript": { + "name": "@ashdev/codex-plugin-sdk", + "version": "1.35.0", + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/node": "^22.0.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@ashdev/codex-plugin-sdk": { + "resolved": "../sdk-typescript", + "link": true + }, + "node_modules/@biomejs/biome": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", + "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.16", + "@biomejs/cli-darwin-x64": "2.4.16", + "@biomejs/cli-linux-arm64": "2.4.16", + "@biomejs/cli-linux-arm64-musl": "2.4.16", + "@biomejs/cli-linux-x64": "2.4.16", + "@biomejs/cli-linux-x64-musl": "2.4.16", + "@biomejs/cli-win32-arm64": "2.4.16", + "@biomejs/cli-win32-x64": "2.4.16" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz", + "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz", + "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz", + "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz", + "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz", + "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz", + "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz", + "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz", + "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz", + "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/plugins/recommendations-echo/package.json b/plugins/recommendations-echo/package.json new file mode 100644 index 00000000..d8035139 --- /dev/null +++ b/plugins/recommendations-echo/package.json @@ -0,0 +1,51 @@ +{ + "name": "@ashdev/codex-plugin-recommendations-echo", + "version": "1.35.0", + "description": "Echo recommendations plugin for testing and debugging the Codex plugin recommendations protocol", + "main": "dist/index.js", + "bin": "dist/index.js", + "type": "module", + "files": [ + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/AshDevFr/codex.git", + "directory": "plugins/recommendations-echo" + }, + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'", + "dev": "npm run build -- --watch", + "clean": "rm -rf dist", + "start": "node dist/index.js", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "prepublishOnly": "npm run lint && npm run build" + }, + "keywords": [ + "codex", + "plugin", + "recommendations", + "echo", + "testing" + ], + "author": "Codex", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + }, + "dependencies": { + "@ashdev/codex-plugin-sdk": "file:../sdk-typescript" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/node": "^22.0.0", + "esbuild": "^0.27.3", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/plugins/recommendations-echo/src/index.test.ts b/plugins/recommendations-echo/src/index.test.ts new file mode 100644 index 00000000..9cedd3e3 --- /dev/null +++ b/plugins/recommendations-echo/src/index.test.ts @@ -0,0 +1,114 @@ +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RecommendationRequest, UserLibraryEntry } from "@ashdev/codex-plugin-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { provider, setRecorder } from "./index.js"; +import { PayloadRecorder } from "./recorder.js"; + +afterEach(() => { + setRecorder(null); +}); + +function seed(title: string): UserLibraryEntry { + return { + seriesId: `series-${title}`, + title, + alternateTitles: [], + genres: [], + tags: [], + externalIds: [], + booksRead: 1, + booksOwned: 1, + }; +} + +function request(overrides: Partial = {}): RecommendationRequest { + return { library: [], excludeIds: [], ...overrides }; +} + +describe("get", () => { + it("echoes each library seed into a fully-populated recommendation", async () => { + const res = await provider.get(request({ library: [seed("Berserk"), seed("Vinland Saga")] })); + + expect(res.cached).toBe(false); + expect(typeof res.generatedAt).toBe("string"); + expect(res.recommendations).toHaveLength(2); + + const first = res.recommendations[0]; + expect(first.basedOn).toEqual(["Berserk"]); + expect(first.reason).toContain("Berserk"); + expect(first.externalId).toBe("echo-rec-1"); + // Fully-populated fields + expect(first.score).toBeGreaterThan(0); + expect(first.score).toBeLessThanOrEqual(1); + expect(first.genres.length).toBeGreaterThan(0); + expect(first.tags?.length).toBeGreaterThan(0); + expect(first.status).toBe("ongoing"); + expect(first.rating).toBeDefined(); + expect(first.popularity).toBeDefined(); + expect(first.inLibrary).toBe(false); + }); + + it("returns generic recommendations when the library is empty", async () => { + const res = await provider.get(request()); + expect(res.recommendations.length).toBeGreaterThan(0); + expect(res.recommendations[0].basedOn[0]).toMatch(/^Echo Seed/); + }); + + it("respects limit and excludeIds", async () => { + const library = [seed("A"), seed("B"), seed("C")]; + expect((await provider.get(request({ library, limit: 2 }))).recommendations).toHaveLength(2); + + const excluded = await provider.get(request({ library, excludeIds: ["echo-rec-1"] })); + expect(excluded.recommendations.map((r) => r.externalId)).not.toContain("echo-rec-1"); + expect(excluded.recommendations).toHaveLength(2); + }); +}); + +describe("dismiss / clear / updateProfile", () => { + it("dismiss returns dismissed: true", async () => { + expect( + await provider.dismiss?.({ externalId: "echo-rec-1", reason: "not_interested" }), + ).toEqual({ dismissed: true }); + }); + + it("clear returns cleared: true", async () => { + expect(await provider.clear?.()).toEqual({ cleared: true }); + }); + + it("updateProfile reports entries processed", async () => { + const res = await provider.updateProfile?.({ entries: [seed("A"), seed("B")] }); + expect(res).toEqual({ updated: true, entriesProcessed: 2 }); + }); +}); + +describe("payload recording", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "rec-echo-test-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("writes paired request/response files when a recorder is set", async () => { + setRecorder( + new PayloadRecorder({ + pluginName: "recommendations-echo", + dataDir: dir, + configSnapshot: { adminConfig: {}, userConfig: {} }, + logger: { info: () => {}, warn: () => {}, debug: () => {} }, + }), + ); + + await provider.get(request({ library: [seed("A")] })); + + const files = (await readdir(join(dir, "payloads"))).sort(); + expect(files).toHaveLength(2); + expect(files.some((f) => f.endsWith("-recommendations_get-request.json"))).toBe(true); + expect(files.some((f) => f.endsWith("-recommendations_get-response.json"))).toBe(true); + }); +}); diff --git a/plugins/recommendations-echo/src/index.ts b/plugins/recommendations-echo/src/index.ts new file mode 100644 index 00000000..8626d4a2 --- /dev/null +++ b/plugins/recommendations-echo/src/index.ts @@ -0,0 +1,160 @@ +/** + * Echo Recommendations Plugin for Codex + * + * A minimal test/debug recommendations plugin. It does not talk to any external + * service: it echoes the user's library seeds back as recommendations (and, when + * the library is empty, returns a few generic ones). Every recommendation has + * all fields populated so the host's ingest path is exercised end to end. + * + * Its main purpose is debugging the host -> plugin recommendations protocol: it + * records every request and its response to JSON files under the plugin's data + * directory (see `recorder.ts`). + */ + +import { + createLogger, + createRecommendationPlugin, + type InitializeParams, + type ProfileUpdateRequest, + type ProfileUpdateResponse, + type Recommendation, + type RecommendationClearResponse, + type RecommendationDismissRequest, + type RecommendationDismissResponse, + type RecommendationProvider, + type RecommendationRequest, + type RecommendationResponse, +} from "@ashdev/codex-plugin-sdk"; +import { DEFAULT_FALLBACK_COUNT, DEFAULT_MAX_PAYLOAD_FILES, manifest } from "./manifest.js"; +import { PayloadRecorder, redactConfig } from "./recorder.js"; + +const logger = createLogger({ name: "recommendations-echo", level: "debug" }); + +// Payload recorder (set during initialization) +let recorder: PayloadRecorder | null = null; + +/** Record a request/response pair (best-effort) and return the response. */ +async function rec(method: string, params: unknown, response: T): Promise { + await recorder?.record(method, params, response); + return response; +} + +/** Set the payload recorder (exported for testing) */ +export function setRecorder(r: PayloadRecorder | null): void { + recorder = r; +} + +/** + * Build a deterministic, fully-populated recommendation seeded by the given + * title. Every optional field is set so the host's ingest path is exercised. + */ +function makeRec(basedOnTitle: string, i: number): Recommendation { + const externalId = `echo-rec-${i + 1}`; + return { + externalId, + externalUrl: `https://echo.example.com/rec/${externalId}`, + title: `Echo Recommendation ${i + 1}`, + coverUrl: "https://picsum.photos/300/450", + summary: `Echo recommendation based on "${basedOnTitle}".`, + genres: ["Action", "Echo"], + tags: [ + { name: "echo", rank: 90, category: "Theme" }, + { name: "test", rank: 50, category: "Demographic" }, + ], + score: Math.max(0.1, 1 - i * 0.1), + reason: `Recommended because you read "${basedOnTitle}"`, + basedOn: [basedOnTitle], + inLibrary: false, + status: "ongoing", + format: "MANGA", + countryOfOrigin: "JP", + startYear: 2020 + (i % 5), + totalVolumeCount: 12, + totalChapterCount: 100, + rating: 80 + (i % 20), + popularity: 1000 + i, + }; +} + +/** Exported for testing */ +export const provider: RecommendationProvider = { + async get(params: RecommendationRequest): Promise { + const { library, limit, excludeIds = [] } = params; + const exclude = new Set(excludeIds); + + logger.info( + `Recommendations requested: ${library.length} seeds, limit=${limit ?? "none"}, exclude=${excludeIds.length}`, + ); + + // Echo each library seed back as a recommendation; if the library is empty, + // return a few generic ones so the response is never empty. + const seeds = + library.length > 0 + ? library.map((e) => e.title) + : Array.from({ length: DEFAULT_FALLBACK_COUNT }, (_, i) => `Echo Seed ${i + 1}`); + + const recommendations = seeds + .map((title, i) => makeRec(title, i)) + .filter((r) => !exclude.has(r.externalId)) + .slice(0, limit ?? seeds.length); + + return rec("recommendations/get", params, { + recommendations, + generatedAt: new Date().toISOString(), + cached: false, + }); + }, + + async updateProfile(params: ProfileUpdateRequest): Promise { + logger.info(`Profile update: ${params.entries.length} entries`); + return rec("recommendations/updateProfile", params, { + updated: true, + entriesProcessed: params.entries.length, + }); + }, + + async dismiss(params: RecommendationDismissRequest): Promise { + logger.info(`Dismiss ${params.externalId} (reason: ${params.reason ?? "none"})`); + return rec("recommendations/dismiss", params, { + dismissed: true, + }); + }, + + async clear(): Promise { + logger.info("Clear recommendations"); + return rec("recommendations/clear", null, { cleared: true }); + }, +}; + +// ============================================================================= +// Plugin Initialization +// ============================================================================= + +createRecommendationPlugin({ + manifest, + provider, + logLevel: "debug", + onInitialize(params: InitializeParams) { + // Set up payload recording (on by default for this debug plugin) + const recordPayloads = params.adminConfig?.recordPayloads !== false; + const maxPayloadFiles = + typeof params.adminConfig?.maxPayloadFiles === "number" + ? params.adminConfig.maxPayloadFiles + : DEFAULT_MAX_PAYLOAD_FILES; + recorder = new PayloadRecorder({ + pluginName: manifest.name, + dataDir: params.dataDir, + enabled: recordPayloads, + maxFiles: maxPayloadFiles, + configSnapshot: redactConfig({ + adminConfig: params.adminConfig, + userConfig: params.userConfig, + }), + logger, + }); + + logger.info(`Echo recommendations plugin initialized (recordPayloads: ${recordPayloads})`); + }, +}); + +logger.info("Echo recommendations plugin started"); diff --git a/plugins/recommendations-echo/src/manifest.ts b/plugins/recommendations-echo/src/manifest.ts new file mode 100644 index 00000000..3ff482aa --- /dev/null +++ b/plugins/recommendations-echo/src/manifest.ts @@ -0,0 +1,49 @@ +import type { PluginManifest } from "@ashdev/codex-plugin-sdk"; +import packageJson from "../package.json" with { type: "json" }; + +// Default config values +export const DEFAULT_MAX_PAYLOAD_FILES = 500; +// Number of generic recommendations returned when the library is empty. +export const DEFAULT_FALLBACK_COUNT = 3; + +export const manifest = { + name: "recommendations-echo", + displayName: "Echo Recommendations Plugin", + version: packageJson.version, + description: + "Test recommendations plugin that echoes library seeds back as recommendations. Records every request/response to files for debugging.", + author: "Codex", + homepage: "https://github.com/AshDevFr/codex", + protocolVersion: "1.1", + capabilities: { + userRecommendationProvider: true, + }, + configSchema: { + description: "Configuration options for the Echo recommendations test plugin", + fields: [ + { + key: "recordPayloads", + label: "Record Payloads", + description: + "Write each request and its response to JSON files under the plugin's data directory for debugging.", + type: "boolean" as const, + required: false, + default: true, + }, + { + key: "maxPayloadFiles", + label: "Max Payload Files", + description: + "Maximum number of recorded payload files to keep; oldest are pruned. Only used when payload recording is enabled.", + type: "number" as const, + required: false, + default: DEFAULT_MAX_PAYLOAD_FILES, + example: 500, + }, + ], + }, + userDescription: + "A debug recommendations plugin: it echoes your library seeds back as recommendations and records all protocol traffic to files. No external account needed.", +} as const satisfies PluginManifest & { + capabilities: { userRecommendationProvider: true }; +}; diff --git a/plugins/recommendations-echo/src/recorder.ts b/plugins/recommendations-echo/src/recorder.ts new file mode 100644 index 00000000..7692d81a --- /dev/null +++ b/plugins/recommendations-echo/src/recorder.ts @@ -0,0 +1,238 @@ +/** + * Payload Recorder - debug helper for echo plugins. + * + * Writes every request and its matching response to paired JSON files under the + * plugin's host-provided file-storage directory (`InitializeParams.dataDir`), so + * the host -> plugin protocol traffic can be inspected without trawling the + * server logs. This is intended for the echo (test/debug) plugins only. + * + * Files for one call share a sortable basename and differ only by suffix: + * + * {yyyy-MM-dd-HH-mm-ss}-{id}-{method}-request.json + * {yyyy-MM-dd-HH-mm-ss}-{id}-{method}-response.json + * + * Timestamps are UTC + 24-hour + zero-padded, so lexical sort == chronological + * sort regardless of the server's timezone. `{id}` is a zero-padded monotonic + * per-process counter that breaks ties within the same second and pairs the two + * files. Each file is a JSON envelope holding the payload plus a snapshot of the + * active config (credentials redacted). + * + * All filesystem I/O is best-effort: a failure is logged and swallowed so a disk + * problem never breaks an RPC response. + * + * NOTE: this module is intentionally duplicated across the echo plugins to keep + * the published SDK surface small. Keep the copies in sync. + */ + +import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Minimal logger contract (compatible with the SDK `Logger`). */ +export interface RecorderLogger { + info(message: string): void; + warn(message: string): void; + debug(message: string): void; +} + +export interface PayloadRecorderOptions { + /** Plugin name, used for the fallback directory and the file envelope. */ + pluginName: string; + /** Host-provided file-storage directory (`InitializeParams.dataDir`). */ + dataDir?: string; + /** Whether recording is on (default: true). */ + enabled?: boolean; + /** Maximum number of files to keep; oldest are pruned (default: 500). */ + maxFiles?: number; + /** Active config snapshot to embed in each file (should be pre-redacted). */ + configSnapshot: unknown; + /** Logger for diagnostics. */ + logger: RecorderLogger; + /** Clock injection for tests (default: `() => new Date()`). */ + now?: () => Date; +} + +/** Keys whose values are dropped from on-disk config snapshots. */ +const SECRET_KEY_RE = /token|secret|password|api[-_]?key|credential/i; +const REDACTED = "[REDACTED]"; + +const DEFAULT_MAX_FILES = 500; + +function pad(value: number, width: number): string { + return String(value).padStart(width, "0"); +} + +/** Format a date as `yyyy-MM-dd-HH-mm-ss` in UTC. */ +function utcStamp(date: Date): string { + const y = date.getUTCFullYear(); + const mo = pad(date.getUTCMonth() + 1, 2); + const d = pad(date.getUTCDate(), 2); + const h = pad(date.getUTCHours(), 2); + const mi = pad(date.getUTCMinutes(), 2); + const s = pad(date.getUTCSeconds(), 2); + return `${y}-${mo}-${d}-${h}-${mi}-${s}`; +} + +/** Replace non-alphanumeric runs with `_` so a method is filename-safe. */ +function sanitizeMethod(method: string): string { + return method.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, ""); +} + +/** + * Recursively copy a config object, replacing values under secret-like keys + * with `[REDACTED]`. Arrays and primitives are returned as-is. + */ +function redactObject(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(redactObject); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + out[key] = SECRET_KEY_RE.test(key) ? REDACTED : redactObject(val); + } + return out; + } + return value; +} + +/** + * Build a redacted config snapshot from initialize params. Credentials are + * never included (they are passed separately, not via config); secret-like + * config keys are additionally redacted as a defensive measure. + */ +export function redactConfig(input: { + adminConfig?: Record; + userConfig?: Record; +}): { adminConfig: unknown; userConfig: unknown } { + return { + adminConfig: redactObject(input.adminConfig ?? {}), + userConfig: redactObject(input.userConfig ?? {}), + }; +} + +interface Envelope { + timestamp: string; + plugin: string; + method: string; + direction: "request" | "response"; + id: number; + config: unknown; + payload: unknown; +} + +export class PayloadRecorder { + private readonly pluginName: string; + private readonly enabled: boolean; + private readonly maxFiles: number; + private readonly configSnapshot: unknown; + private readonly logger: RecorderLogger; + private readonly now: () => Date; + private readonly dir: string; + private readonly usingFallback: boolean; + private seq = 0; + private ready: Promise | null = null; + + constructor(opts: PayloadRecorderOptions) { + this.pluginName = opts.pluginName; + this.enabled = opts.enabled ?? true; + this.maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES; + this.configSnapshot = opts.configSnapshot; + this.logger = opts.logger; + this.now = opts.now ?? (() => new Date()); + + if (opts.dataDir) { + this.dir = join(opts.dataDir, "payloads"); + this.usingFallback = false; + } else { + this.dir = join(tmpdir(), `codex-${opts.pluginName}`, "payloads"); + this.usingFallback = true; + } + } + + /** Absolute directory payloads are written to (for logging/tests). */ + get directory(): string { + return this.dir; + } + + /** Lazily create the payloads directory once; returns false if unusable. */ + private ensureDir(): Promise { + if (!this.ready) { + this.ready = mkdir(this.dir, { recursive: true }) + .then(() => { + if (this.usingFallback) { + this.logger.warn(`No dataDir provided; recording payloads under temp dir ${this.dir}`); + } else { + this.logger.debug(`Recording payloads to ${this.dir}`); + } + return true; + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "unknown error"; + this.logger.warn(`Failed to create payload dir ${this.dir}: ${msg}`); + return false; + }); + } + return this.ready; + } + + /** + * Record a request and its response under one shared, sortable basename. + * Best-effort: never throws. + */ + async record(method: string, request: unknown, response: unknown): Promise { + if (!this.enabled) return; + if (!(await this.ensureDir())) return; + + const id = ++this.seq; + const date = this.now(); + const base = `${utcStamp(date)}-${pad(id, 4)}-${sanitizeMethod(method)}`; + const iso = date.toISOString(); + + await this.writeFile(`${base}-request.json`, { + timestamp: iso, + plugin: this.pluginName, + method, + direction: "request", + id, + config: this.configSnapshot, + payload: request, + }); + await this.writeFile(`${base}-response.json`, { + timestamp: iso, + plugin: this.pluginName, + method, + direction: "response", + id, + config: this.configSnapshot, + payload: response, + }); + + await this.prune(); + } + + private async writeFile(name: string, envelope: Envelope): Promise { + try { + await writeFile(join(this.dir, name), JSON.stringify(envelope, null, 2), "utf8"); + } catch (err) { + const msg = err instanceof Error ? err.message : "unknown error"; + this.logger.warn(`Failed to write payload file ${name}: ${msg}`); + } + } + + /** Keep at most `maxFiles` files, deleting the oldest (lexical == chrono). */ + private async prune(): Promise { + if (this.maxFiles <= 0) return; + try { + const files = (await readdir(this.dir)).filter((f) => f.endsWith(".json")).sort(); + const excess = files.length - this.maxFiles; + if (excess <= 0) return; + for (const file of files.slice(0, excess)) { + await rm(join(this.dir, file), { force: true }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "unknown error"; + this.logger.warn(`Failed to prune payload dir ${this.dir}: ${msg}`); + } + } +} diff --git a/plugins/recommendations-echo/tsconfig.json b/plugins/recommendations-echo/tsconfig.json new file mode 100644 index 00000000..ef1ca5f9 --- /dev/null +++ b/plugins/recommendations-echo/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/plugins/recommendations-echo/vitest.config.ts b/plugins/recommendations-echo/vitest.config.ts new file mode 100644 index 00000000..ee073274 --- /dev/null +++ b/plugins/recommendations-echo/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: ["node_modules", "dist"], + }, + }, +}); diff --git a/web/src/pages/settings/PluginsSettings.test.tsx b/web/src/pages/settings/PluginsSettings.test.tsx index b6960bd3..16833481 100644 --- a/web/src/pages/settings/PluginsSettings.test.tsx +++ b/web/src/pages/settings/PluginsSettings.test.tsx @@ -1751,7 +1751,8 @@ describe("PluginsSettings - Official Plugins section", () => { expect(screen.getByText("Mangabaka Metadata")).toBeInTheDocument(); expect(screen.getByText("AniList Sync")).toBeInTheDocument(); expect(screen.getByText("AniList Recommendations")).toBeInTheDocument(); - expect(screen.getByText("Echo Metadata")).toBeInTheDocument(); + // The echo plugins are debug-only and intentionally excluded from the gallery. + expect(screen.queryByText("Echo Metadata")).not.toBeInTheDocument(); }); it("shows type badges for each official plugin", async () => { @@ -1764,8 +1765,8 @@ describe("PluginsSettings - Official Plugins section", () => { // Each plugin type should appear as a badge expect(screen.getByText("Sync")).toBeInTheDocument(); expect(screen.getByText("Recommendations")).toBeInTheDocument(); - // "Metadata" appears for Echo, Mangabaka, and Open Library plugins - expect(screen.getAllByText("Metadata").length).toBeGreaterThanOrEqual(3); + // "Metadata" appears for the Mangabaka and Open Library plugins + expect(screen.getAllByText("Metadata").length).toBeGreaterThanOrEqual(2); // "Releases" appears for MangaUpdates and Nyaa plugins expect(screen.getAllByText("Releases").length).toBeGreaterThanOrEqual(2); }); @@ -1784,12 +1785,12 @@ describe("PluginsSettings - Official Plugins section", () => { await user.click(screen.getByText("Official Plugins")); await waitFor(() => { - // All 5 official plugins should show their names + // All official plugins should show their names (echo plugins are excluded) expect(screen.getByText("Open Library Metadata")).toBeInTheDocument(); expect(screen.getByText("Mangabaka Metadata")).toBeInTheDocument(); expect(screen.getByText("AniList Sync")).toBeInTheDocument(); expect(screen.getByText("AniList Recommendations")).toBeInTheDocument(); - expect(screen.getByText("Echo Metadata")).toBeInTheDocument(); + expect(screen.queryByText("Echo Metadata")).not.toBeInTheDocument(); }); // No "Installed" badges since nothing is installed diff --git a/web/src/pages/settings/plugins/OfficialPlugins.tsx b/web/src/pages/settings/plugins/OfficialPlugins.tsx index 123c52b8..3b1b33bd 100644 --- a/web/src/pages/settings/plugins/OfficialPlugins.tsx +++ b/web/src/pages/settings/plugins/OfficialPlugins.tsx @@ -55,22 +55,10 @@ export interface OfficialPlugin { } export const OFFICIAL_PLUGINS: OfficialPlugin[] = [ - { - name: "metadata-echo", - displayName: "Echo Metadata", - description: - "Development and testing plugin that echoes back sample metadata. Useful for verifying the plugin system is working correctly and for plugin development.", - type: "Metadata", - packageName: "@ashdev/codex-plugin-metadata-echo", - authInfo: "No authentication required", - author: "Codex Team", - scope: "system", - formDefaults: { - command: "npx", - args: "-y\n@ashdev/codex-plugin-metadata-echo", - credentialDelivery: "env", - }, - }, + // Note: the "echo" plugins (metadata-echo, sync-echo, recommendations-echo) + // are intentionally NOT listed here. They are debug/testing tools, not + // integrations end users would install from the gallery. They are provisioned + // via the seed config (config/seed-config.yaml) for dev/test deployments. { name: "metadata-mangabaka", displayName: "Mangabaka Metadata", From 489137dc72781a931de7ebc7cb4880c7409d0866 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sun, 7 Jun 2026 12:43:17 -0700 Subject: [PATCH 18/27] feat(plugins): support credential-less plugins and send per-user identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes that make no-auth user plugins (e.g. a debug echo or a NocoDB-backed sync) first-class. Per-user identity: the host now sends userId and userPluginId to every user plugin in the initialize params (absent for system plugins). A plugin that has no per-user credential — or authenticates with an admin-configured shared key — can't derive the user's identity from its credentials, so it needs this to scope data per user in its own backend. The identifier is sent regardless of whether the plugin requires auth. No-auth / shared-key support: a plugin is now "connected" when it has credentials OR requires no per-user authentication. requires_authentication() on the manifest (declares OAuth or required credentials) drives a new UserPlugin::is_connected(requires_auth), applied consistently to the user-plugin DTO (which also exposes requires_auth), the manual-sync guard, the sync-status DTO, and the auto-sync scheduler eligibility. Previously such plugins could never connect, so they could be neither synced manually nor scheduled. Frontend: the integrations card shows sync controls (Sync Now, Automatic sync) and an "Enabled" badge for a no-auth plugin, and hides Disconnect since there is no external account to unlink; auth plugins are unchanged. OpenAPI types regenerated. Tests added across the manifest, entity, scheduler, and card. --- .../src/routes/v1/dto/user_plugins.rs | 11 +++- .../src/routes/v1/handlers/user_plugins.rs | 24 ++++++-- crates/codex-db/src/entities/user_plugins.rs | 27 +++++++++ crates/codex-models/src/plugin.rs | 57 ++++++++++++++++++ crates/codex-scheduler/src/lib.rs | 57 +++++++++++++----- crates/codex-services/src/plugin/handle.rs | 10 ++++ crates/codex-services/src/plugin/manager.rs | 8 +++ crates/codex-services/src/plugin/protocol.rs | 32 ++++++++++ docker-compose.yml | 6 ++ docs/api/openapi.json | 7 ++- .../recommendations-echo/package-lock.json | 42 ------------- plugins/sdk-typescript/src/server.ts | 16 +++++ plugins/sync-echo/package-lock.json | 42 ------------- web/openapi.json | 7 ++- .../plugins/UserPluginCard.test.tsx | 60 +++++++++++++++++++ web/src/components/plugins/UserPluginCard.tsx | 27 +++++---- .../plugins/UserPluginSettingsModal.test.tsx | 1 + .../settings/IntegrationsSettings.test.tsx | 1 + web/src/pages/settings/plugins/PluginForm.tsx | 5 +- web/src/types/api.generated.ts | 12 +++- 20 files changed, 332 insertions(+), 120 deletions(-) diff --git a/crates/codex-api/src/routes/v1/dto/user_plugins.rs b/crates/codex-api/src/routes/v1/dto/user_plugins.rs index 0e4e0b48..fd67f468 100644 --- a/crates/codex-api/src/routes/v1/dto/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/dto/user_plugins.rs @@ -43,8 +43,14 @@ pub struct UserPluginDto { pub plugin_type: String, /// Whether the user has enabled this plugin pub enabled: bool, - /// Whether the plugin is connected (has valid credentials/OAuth) + /// Whether the plugin is connected and ready to operate. True when the + /// plugin has valid credentials/OAuth, or when it requires no per-user + /// authentication (credential-less or shared-key plugins). pub connected: bool, + /// Whether this plugin requires per-user authentication (OAuth or required + /// credentials). When false, the connect/disconnect flow is not applicable; + /// the plugin is usable as soon as it is enabled. + pub requires_auth: bool, /// Health status of this user's plugin instance pub health_status: String, /// External service username (if connected via OAuth) @@ -415,6 +421,7 @@ mod tests { plugin_type: "user".to_string(), enabled: true, connected: true, + requires_auth: true, health_status: "healthy".to_string(), external_username: None, external_avatar_url: None, @@ -471,6 +478,7 @@ mod tests { plugin_type: "user".to_string(), enabled: true, connected: true, + requires_auth: true, health_status: "healthy".to_string(), external_username: None, external_avatar_url: None, @@ -521,6 +529,7 @@ mod tests { plugin_type: "user".to_string(), enabled: true, connected: true, + requires_auth: true, health_status: "healthy".to_string(), external_username: None, external_avatar_url: None, diff --git a/crates/codex-api/src/routes/v1/handlers/user_plugins.rs b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs index d233df77..df889a0f 100644 --- a/crates/codex-api/src/routes/v1/handlers/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs @@ -109,6 +109,12 @@ async fn build_user_plugin_dto( ) -> UserPluginDto { let manifest = parse_manifest(plugin); let oauth_config = manifest.as_ref().and_then(|m| m.oauth.clone()); + // A plugin with no manifest is treated as requiring auth (conservative, + // matches prior behavior where `connected` == `is_authenticated`). + let requires_auth = manifest + .as_ref() + .map(|m| m.requires_authentication()) + .unwrap_or(true); let capabilities = UserPluginCapabilitiesDto { read_sync: manifest @@ -151,7 +157,8 @@ async fn build_user_plugin_dto( plugin_display_name: plugin.display_name.clone(), plugin_type: plugin.plugin_type.clone(), enabled: instance.enabled, - connected: instance.is_authenticated(), + connected: instance.is_connected(requires_auth), + requires_auth, health_status: instance.health_status.clone(), external_username: instance.external_username.clone(), external_avatar_url: instance.external_avatar_url.clone(), @@ -984,8 +991,13 @@ pub async fn trigger_sync( )); } - // Verify the plugin is connected (has credentials) - if !instance.is_authenticated() { + // Verify the plugin is connected. Plugins that require per-user auth need + // credentials/OAuth; credential-less or shared-key plugins are always ready. + let requires_auth = manifest + .as_ref() + .map(|m| m.requires_authentication()) + .unwrap_or(true); + if !instance.is_connected(requires_auth) { return Err(ApiError::BadRequest( "Plugin is not connected. Complete authentication before syncing.".to_string(), )); @@ -1065,6 +1077,10 @@ pub async fn get_sync_status( .map_err(|e| ApiError::Internal(format!("Failed to get plugin: {}", e)))? .ok_or_else(|| ApiError::Internal("Plugin definition not found".to_string()))?; + let requires_auth = parse_manifest(&plugin) + .map(|m| m.requires_authentication()) + .unwrap_or(true); + // Optionally query live sync state from the plugin process let (external_count, pending_push, pending_pull, conflicts, live_error) = if query.live { debug!( @@ -1136,7 +1152,7 @@ pub async fn get_sync_status( Ok(Json(SyncStatusDto { plugin_id: plugin.id, plugin_name: plugin.display_name.clone(), - connected: instance.is_authenticated(), + connected: instance.is_connected(requires_auth), last_sync_at: instance.last_sync_at, last_success_at: instance.last_success_at, last_failure_at: instance.last_failure_at, diff --git a/crates/codex-db/src/entities/user_plugins.rs b/crates/codex-db/src/entities/user_plugins.rs index 9e816deb..20e9ef3d 100644 --- a/crates/codex-db/src/entities/user_plugins.rs +++ b/crates/codex-db/src/entities/user_plugins.rs @@ -199,6 +199,18 @@ impl Model { self.has_oauth_tokens() || self.has_credentials() } + /// Whether this instance is "connected" and ready to operate. + /// + /// A plugin that requires per-user authentication is connected once it has + /// credentials/tokens. A plugin that needs no per-user auth (credential-less + /// echo/debug plugins, or plugins backed by an admin-configured shared key) + /// is connected as soon as it exists, since there is nothing for the user to + /// connect. `requires_auth` comes from the plugin manifest + /// (`PluginManifest::requires_authentication`). + pub fn is_connected(&self, requires_auth: bool) -> bool { + !requires_auth || self.is_authenticated() + } + /// Whether this connection has opted into scheduled (auto) syncs. /// /// Reads `config._codex.autoSync` (host-side only; never sent to the @@ -361,6 +373,21 @@ mod tests { assert!(model.is_authenticated()); } + #[test] + fn test_is_connected() { + let mut model = test_model(); + + // No-auth plugin: connected even without any credentials. + assert!(model.is_connected(false)); + + // Auth-required plugin without credentials: not connected. + assert!(!model.is_connected(true)); + + // Auth-required plugin with credentials: connected. + model.credentials = Some(vec![1, 2, 3]); + assert!(model.is_connected(true)); + } + #[test] fn test_health_status_type() { let mut model = test_model(); diff --git a/crates/codex-models/src/plugin.rs b/crates/codex-models/src/plugin.rs index c9c546be..e13487a2 100644 --- a/crates/codex-models/src/plugin.rs +++ b/crates/codex-models/src/plugin.rs @@ -80,6 +80,21 @@ pub struct PluginManifest { pub search_uri_template: Option, } +impl PluginManifest { + /// Whether this plugin requires *per-user* authentication. + /// + /// True when the plugin declares an OAuth flow or per-user required + /// credentials, i.e. the user must connect an account / supply a secret + /// before the plugin can act for them. False for credential-less plugins + /// and for plugins that rely solely on an admin-configured shared key + /// (which authenticates the plugin but does not identify the user). The + /// host treats a no-per-user-auth plugin as "connected" once enabled, since + /// there is nothing for the user to connect. + pub fn requires_authentication(&self) -> bool { + self.oauth.is_some() || !self.required_credentials.is_empty() + } +} + /// Content types that a metadata provider can support #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "lowercase")] @@ -462,4 +477,46 @@ mod tests { .unwrap(); assert!(caps.wants_detailed_progress); } + + fn manifest_from(extra: serde_json::Value) -> PluginManifest { + let mut base = serde_json::json!({ + "name": "p", + "displayName": "P", + "version": "1.0.0", + "protocolVersion": "1.0", + "capabilities": { "userReadSync": true } + }); + let obj = base.as_object_mut().unwrap(); + for (k, v) in extra.as_object().unwrap() { + obj.insert(k.clone(), v.clone()); + } + serde_json::from_value(base).unwrap() + } + + #[test] + fn test_requires_authentication_false_for_credentialless_plugin() { + let manifest = manifest_from(serde_json::json!({})); + assert!(!manifest.requires_authentication()); + } + + #[test] + fn test_requires_authentication_true_with_required_credentials() { + let manifest = manifest_from(serde_json::json!({ + "requiredCredentials": [ + { "key": "access_token", "label": "Token" } + ] + })); + assert!(manifest.requires_authentication()); + } + + #[test] + fn test_requires_authentication_true_with_oauth() { + let manifest = manifest_from(serde_json::json!({ + "oauth": { + "authorizationUrl": "https://example.com/auth", + "tokenUrl": "https://example.com/token" + } + })); + assert!(manifest.requires_authentication()); + } } diff --git a/crates/codex-scheduler/src/lib.rs b/crates/codex-scheduler/src/lib.rs index 514ceffa..1161ec60 100644 --- a/crates/codex-scheduler/src/lib.rs +++ b/crates/codex-scheduler/src/lib.rs @@ -897,15 +897,17 @@ pub async fn has_active_refresh_for_job(db: &DatabaseConnection, job_id: Uuid) - /// Whether a connection is eligible for an automatic (scheduled) sync. /// -/// Eligible only when the connection is enabled, authenticated, and the user -/// has opted into auto sync (`config._codex.autoSync`). `get_users_with_plugin` -/// already filters to enabled rows, but we re-check `enabled` so the predicate -/// is self-contained and correct in isolation. A connection whose token has -/// expired but is otherwise authenticated is still eligible here; the sync -/// handler is responsible for surfacing/refreshing auth (v1 does not refresh in -/// the cron path). -pub(crate) fn is_auto_sync_eligible(up: &user_plugins::Model) -> bool { - up.enabled && up.is_authenticated() && up.auto_sync_enabled() +/// Eligible only when the connection is enabled, connected, and the user has +/// opted into auto sync (`config._codex.autoSync`). "Connected" means +/// authenticated for plugins that require per-user auth, or simply present for +/// credential-less / shared-key plugins (`requires_auth == false`). +/// `get_users_with_plugin` already filters to enabled rows, but we re-check +/// `enabled` so the predicate is self-contained and correct in isolation. A +/// connection whose token has expired but is otherwise authenticated is still +/// eligible here; the sync handler is responsible for surfacing/refreshing auth +/// (v1 does not refresh in the cron path). +pub(crate) fn is_auto_sync_eligible(up: &user_plugins::Model, requires_auth: bool) -> bool { + up.enabled && up.is_connected(requires_auth) && up.auto_sync_enabled() } /// Outcome of one plugin-sync fan-out, used for the per-firing log line. @@ -935,8 +937,17 @@ pub(crate) async fn fan_out_plugin_sync( let connections = UserPluginsRepository::get_users_with_plugin(db, plugin_id).await?; let mut summary = FanOutSummary::default(); + // Whether this plugin needs per-user auth. Credential-less / shared-key + // plugins are eligible without per-user credentials. No manifest → assume + // auth is required (conservative). + let requires_auth = PluginsRepository::get_by_id(db, plugin_id) + .await? + .and_then(|p| p.cached_manifest()) + .map(|m| m.requires_authentication()) + .unwrap_or(true); + for up in &connections { - if !is_auto_sync_eligible(up) { + if !is_auto_sync_eligible(up, requires_auth) { summary.skipped_ineligible += 1; continue; } @@ -1081,22 +1092,38 @@ mod tests { #[test] fn is_auto_sync_eligible_matrix() { + // Auth-required plugin (requires_auth = true) assert!( - is_auto_sync_eligible(&make_up(true, true, true)), + is_auto_sync_eligible(&make_up(true, true, true), true), "enabled + authed + auto is eligible" ); assert!( - !is_auto_sync_eligible(&make_up(false, true, true)), + !is_auto_sync_eligible(&make_up(false, true, true), true), "disabled is ineligible" ); assert!( - !is_auto_sync_eligible(&make_up(true, false, true)), - "unauthenticated is ineligible" + !is_auto_sync_eligible(&make_up(true, false, true), true), + "unauthenticated is ineligible when auth is required" ); assert!( - !is_auto_sync_eligible(&make_up(true, true, false)), + !is_auto_sync_eligible(&make_up(true, true, false), true), "manual (opt-out) is ineligible" ); + + // No-auth plugin (requires_auth = false): eligible without credentials, + // but still gated by enabled + auto-sync opt-in. + assert!( + is_auto_sync_eligible(&make_up(true, false, true), false), + "credential-less plugin is eligible without auth" + ); + assert!( + !is_auto_sync_eligible(&make_up(true, false, false), false), + "credential-less plugin still needs auto-sync opt-in" + ); + assert!( + !is_auto_sync_eligible(&make_up(false, false, true), false), + "credential-less plugin still needs to be enabled" + ); } async fn create_sync_plugin(db: &DatabaseConnection, name: &str) -> Uuid { diff --git a/crates/codex-services/src/plugin/handle.rs b/crates/codex-services/src/plugin/handle.rs index d7c7a003..77929a98 100644 --- a/crates/codex-services/src/plugin/handle.rs +++ b/crates/codex-services/src/plugin/handle.rs @@ -82,6 +82,10 @@ pub struct PluginConfig { pub credentials: Option, /// Scoped data directory for this plugin's file storage pub data_dir: Option, + /// Codex user id this instance acts for (user plugins only; None for system plugins) + pub user_id: Option, + /// User-plugin connection id (user plugins only; matches storage scope) + pub user_plugin_id: Option, } impl std::fmt::Debug for PluginConfig { @@ -95,6 +99,8 @@ impl std::fmt::Debug for PluginConfig { .field("user_config", &self.user_config) .field("credentials", &self.credentials) // SecretValue shows [REDACTED] .field("data_dir", &self.data_dir) + .field("user_id", &self.user_id) + .field("user_plugin_id", &self.user_plugin_id) .finish() } } @@ -110,6 +116,8 @@ impl Default for PluginConfig { user_config: None, credentials: None, data_dir: None, + user_id: None, + user_plugin_id: None, } } } @@ -298,6 +306,8 @@ impl PluginHandle { user_config: self.config.user_config.clone(), credentials: self.config.credentials.as_ref().map(|s| s.inner().clone()), data_dir: self.config.data_dir.clone(), + user_id: self.config.user_id.clone(), + user_plugin_id: self.config.user_plugin_id.clone(), }; debug!( diff --git a/crates/codex-services/src/plugin/manager.rs b/crates/codex-services/src/plugin/manager.rs index 40d5822a..f7630a68 100644 --- a/crates/codex-services/src/plugin/manager.rs +++ b/crates/codex-services/src/plugin/manager.rs @@ -912,6 +912,11 @@ impl PluginManager { user_config, credentials, data_dir, + // Always send the per-user identity to user plugins, regardless of + // auth: a credential-less or shared-key plugin needs it to scope + // data per user (the credential alone may not identify the user). + user_id: Some(user_plugin.user_id.to_string()), + user_plugin_id: Some(user_plugin.id.to_string()), }) } @@ -1871,6 +1876,9 @@ impl PluginManager { user_config: None, credentials, data_dir, + // System plugins are not bound to a user. + user_id: None, + user_plugin_id: None, }) } diff --git a/crates/codex-services/src/plugin/protocol.rs b/crates/codex-services/src/plugin/protocol.rs index 6dba87d5..e4ab9b3b 100644 --- a/crates/codex-services/src/plugin/protocol.rs +++ b/crates/codex-services/src/plugin/protocol.rs @@ -1125,6 +1125,17 @@ pub struct InitializeParams { /// Plugins can use this for larger file-based storage (SQLite DBs, caches, etc.) #[serde(default, skip_serializing_if = "Option::is_none")] pub data_dir: Option, + /// Stable identifier of the Codex user this instance acts for. + /// Present for user-plugin spawns (sync/recommendation), absent for system + /// plugins. Lets credential-less or shared-key plugins scope data per user + /// (the credential alone may not identify the user). Opaque UUID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + /// Stable identifier of this user-plugin connection (the per-connection + /// scope, matching host key-value storage scoping). Present for user-plugin + /// spawns, absent for system plugins. Opaque UUID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_plugin_id: Option, } // ============================================================================= @@ -2471,6 +2482,8 @@ mod tests { user_config: Some(json!({"progressUnit": "chapters"})), credentials: Some(json!({"access_token": "secret"})), data_dir: None, + user_id: None, + user_plugin_id: None, }; let json = serde_json::to_value(¶ms).unwrap(); assert_eq!(json["adminConfig"]["clientId"], "abc"); @@ -2487,6 +2500,8 @@ mod tests { user_config: None, credentials: None, data_dir: None, + user_id: None, + user_plugin_id: None, }; let json = serde_json::to_value(¶ms).unwrap(); assert_eq!(json["config"]["merged"], true); @@ -2543,6 +2558,23 @@ mod tests { assert!(!json.as_object().unwrap().contains_key("credentials")); } + #[test] + fn test_initialize_params_with_user_identity() { + let params = InitializeParams { + user_id: Some("user-123".to_string()), + user_plugin_id: Some("conn-456".to_string()), + ..Default::default() + }; + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!(json["userId"], "user-123"); + assert_eq!(json["userPluginId"], "conn-456"); + // Identity is omitted entirely when absent (system plugins). + let empty = serde_json::to_value(InitializeParams::default()).unwrap(); + let obj = empty.as_object().unwrap(); + assert!(!obj.contains_key("userId")); + assert!(!obj.contains_key("userPluginId")); + } + #[test] fn test_initialize_params_data_dir_deserialization() { let json = json!({ diff --git a/docker-compose.yml b/docker-compose.yml index ed10d214..60d77bde 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,6 +88,9 @@ services: - ./docker/data/thumbnails:/app/data/thumbnails:rw - ./docker/data/uploads:/app/data/uploads:rw - ./docker/data/cache:/app/data/cache:rw + # Plugin file storage (dataDir) — where plugins write files, incl. the + # echo plugins' recorded request/response payloads under /payloads/ + - ./docker/data/plugins:/app/data/plugins:rw # Plugin dist directories (built by plugins-builder service) - ./plugins/metadata-mangabaka/dist:/opt/codex/plugins/metadata-mangabaka/dist:ro - ./plugins/metadata-echo/dist:/opt/codex/plugins/metadata-echo/dist:ro @@ -171,6 +174,9 @@ services: - ./docker/data/thumbnails:/app/data/thumbnails:rw - ./docker/data/uploads:/app/data/uploads:rw - ./docker/data/cache:/app/data/cache:rw + # Plugin file storage (dataDir) — where plugins write files, incl. the + # echo plugins' recorded request/response payloads under /payloads/ + - ./docker/data/plugins:/app/data/plugins:rw # Plugin dist directories (built by plugins-builder service) - ./plugins/metadata-mangabaka/dist:/opt/codex/plugins/metadata-mangabaka/dist:ro - ./plugins/metadata-echo/dist:/opt/codex/plugins/metadata-echo/dist:ro diff --git a/docs/api/openapi.json b/docs/api/openapi.json index a5220e74..3bf3d3e1 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -42458,6 +42458,7 @@ "pluginType", "enabled", "connected", + "requiresAuth", "healthStatus", "requiresOauth", "oauthConfigured", @@ -42484,7 +42485,7 @@ }, "connected": { "type": "boolean", - "description": "Whether the plugin is connected (has valid credentials/OAuth)" + "description": "Whether the plugin is connected and ready to operate. True when the\nplugin has valid credentials/OAuth, or when it requires no per-user\nauthentication (credential-less or shared-key plugins)." }, "createdAt": { "type": "string", @@ -42565,6 +42566,10 @@ "type": "string", "description": "Plugin type: \"system\" or \"user\"" }, + "requiresAuth": { + "type": "boolean", + "description": "Whether this plugin requires per-user authentication (OAuth or required\ncredentials). When false, the connect/disconnect flow is not applicable;\nthe plugin is usable as soon as it is enabled." + }, "requiresOauth": { "type": "boolean", "description": "Whether this plugin requires OAuth authentication" diff --git a/plugins/recommendations-echo/package-lock.json b/plugins/recommendations-echo/package-lock.json index e35c8308..9cca5a08 100644 --- a/plugins/recommendations-echo/package-lock.json +++ b/plugins/recommendations-echo/package-lock.json @@ -112,9 +112,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -132,9 +129,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -152,9 +146,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -172,9 +163,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -823,9 +811,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -843,9 +828,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -863,9 +845,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -883,9 +862,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -903,9 +879,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -923,9 +896,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1460,9 +1430,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1484,9 +1451,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1508,9 +1472,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1532,9 +1493,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/plugins/sdk-typescript/src/server.ts b/plugins/sdk-typescript/src/server.ts index b384a866..94778012 100644 --- a/plugins/sdk-typescript/src/server.ts +++ b/plugins/sdk-typescript/src/server.ts @@ -168,6 +168,22 @@ export interface InitializeParams { * configured. */ dataDir?: string; + /** + * Stable identifier of the Codex user this plugin instance is acting for. + * + * Sent for user-plugin spawns (sync / recommendation), absent for system + * plugins. Because a credential-less or shared-key plugin can't derive the + * user's identity from its credentials, use this (an opaque UUID) to scope + * data per user in the plugin's own backend. + */ + userId?: string; + /** + * Stable identifier of this user-plugin connection — the same scope the host + * uses for {@link storage}. Sent for user-plugin spawns, absent for system + * plugins. Opaque UUID. Use it when "this connection" (rather than the human) + * is the right granularity. + */ + userPluginId?: string; /** * Per-user key-value storage client. * diff --git a/plugins/sync-echo/package-lock.json b/plugins/sync-echo/package-lock.json index 24486644..01fed2d8 100644 --- a/plugins/sync-echo/package-lock.json +++ b/plugins/sync-echo/package-lock.json @@ -112,9 +112,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -132,9 +129,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -152,9 +146,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -172,9 +163,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -823,9 +811,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -843,9 +828,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -863,9 +845,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -883,9 +862,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -903,9 +879,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -923,9 +896,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1460,9 +1430,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1484,9 +1451,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1508,9 +1472,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1532,9 +1493,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/web/openapi.json b/web/openapi.json index a5220e74..3bf3d3e1 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -42458,6 +42458,7 @@ "pluginType", "enabled", "connected", + "requiresAuth", "healthStatus", "requiresOauth", "oauthConfigured", @@ -42484,7 +42485,7 @@ }, "connected": { "type": "boolean", - "description": "Whether the plugin is connected (has valid credentials/OAuth)" + "description": "Whether the plugin is connected and ready to operate. True when the\nplugin has valid credentials/OAuth, or when it requires no per-user\nauthentication (credential-less or shared-key plugins)." }, "createdAt": { "type": "string", @@ -42565,6 +42566,10 @@ "type": "string", "description": "Plugin type: \"system\" or \"user\"" }, + "requiresAuth": { + "type": "boolean", + "description": "Whether this plugin requires per-user authentication (OAuth or required\ncredentials). When false, the connect/disconnect flow is not applicable;\nthe plugin is usable as soon as it is enabled." + }, "requiresOauth": { "type": "boolean", "description": "Whether this plugin requires OAuth authentication" diff --git a/web/src/components/plugins/UserPluginCard.test.tsx b/web/src/components/plugins/UserPluginCard.test.tsx index 78df8f12..db4c869b 100644 --- a/web/src/components/plugins/UserPluginCard.test.tsx +++ b/web/src/components/plugins/UserPluginCard.test.tsx @@ -26,6 +26,7 @@ const connectedPlugin: UserPluginDto = { lastSyncAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago lastSuccessAt: new Date(Date.now() - 3600000).toISOString(), requiresOauth: true, + requiresAuth: true, oauthConfigured: true, description: "Sync your reading progress with AniList", config: {}, @@ -44,6 +45,7 @@ const enabledNotConnected: UserPluginDto = { connected: false, healthStatus: "unknown", requiresOauth: true, + requiresAuth: true, oauthConfigured: true, description: "Sync with MyAnimeList", config: {}, @@ -63,6 +65,7 @@ const connectedRecommendationPlugin: UserPluginDto = { externalUsername: "@testuser", externalAvatarUrl: "https://example.com/avatar.png", requiresOauth: true, + requiresAuth: true, oauthConfigured: true, description: "Personalized manga recommendations powered by AniList community data", @@ -71,6 +74,27 @@ const connectedRecommendationPlugin: UserPluginDto = { createdAt: new Date().toISOString(), } as UserPluginDto; +// A credential-less sync plugin (e.g. sync-echo): connected once enabled, +// requires no per-user auth. +const noAuthSyncPlugin: UserPluginDto = { + id: "inst-4", + pluginId: "plugin-6", + pluginName: "sync-echo", + pluginDisplayName: "Echo Sync Plugin", + pluginType: "user", + enabled: true, + connected: true, + requiresAuth: false, + requiresOauth: false, + oauthConfigured: false, + healthStatus: "healthy", + description: "A debug sync plugin", + config: {}, + autoSync: false, + capabilities: { readSync: true, userRecommendationProvider: false }, + createdAt: new Date().toISOString(), +} as UserPluginDto; + const availablePlugin: AvailablePluginDto = { pluginId: "plugin-3", name: "smart-recs", @@ -539,6 +563,42 @@ describe("ConnectedPluginCard", () => { }); }); +describe("ConnectedPluginCard - no-auth plugin", () => { + const noAuthProps = { + plugin: noAuthSyncPlugin, + onDisconnect: vi.fn(), + onDisable: vi.fn(), + onConnect: vi.fn(), + onSync: vi.fn(), + onSetSyncMode: vi.fn(), + onSettings: vi.fn(), + }; + + it("shows an 'Enabled' badge rather than 'Connected'", () => { + renderWithProviders(); + expect(screen.getByText("Enabled")).toBeInTheDocument(); + expect(screen.queryByText("Connected")).not.toBeInTheDocument(); + }); + + it("shows Sync Now and the Automatic sync toggle", () => { + renderWithProviders(); + expect( + screen.getByRole("button", { name: /sync now/i }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Automatic sync")).toBeInTheDocument(); + }); + + it("offers Disable but not Disconnect (nothing to unlink)", () => { + renderWithProviders(); + expect( + screen.getByRole("button", { name: /disable/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /disconnect/i }), + ).not.toBeInTheDocument(); + }); +}); + // ============================================================================= // AvailablePluginCard Tests // ============================================================================= diff --git a/web/src/components/plugins/UserPluginCard.tsx b/web/src/components/plugins/UserPluginCard.tsx index e3b18617..e9da4420 100644 --- a/web/src/components/plugins/UserPluginCard.tsx +++ b/web/src/components/plugins/UserPluginCard.tsx @@ -147,6 +147,11 @@ export function ConnectedPluginCard({ const configFields = (plugin.userConfigSchema?.fields as { key: string }[] | undefined) ?? []; const hasSettings = isSyncPlugin || configFields.length > 0; + // A plugin that requires per-user auth and is connected has an external + // account to unlink ("Disconnect"). A credential-less / shared-key plugin + // (requiresAuth === false) is connected once enabled but has nothing to + // disconnect — it is managed purely via Enable/Disable. + const connectedViaAuth = plugin.connected && plugin.requiresAuth; return ( @@ -171,7 +176,7 @@ export function ConnectedPluginCard({ - {plugin.connected ? ( + {connectedViaAuth ? ( Connected - ) : plugin.requiresOauth ? ( + ) : !plugin.requiresAuth ? ( } + leftSection={} > - Not Connected + Enabled ) : ( } + leftSection={} > - Enabled + Not Connected )} {plugin.connected && ( @@ -396,7 +401,7 @@ export function ConnectedPluginCard({ Settings )} - {plugin.connected && ( + {connectedViaAuth && (