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..e2f876dc 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 @@ -194,6 +216,16 @@ plugins: # default_reading_direction: LEFT_TO_RIGHT (default), RIGHT_TO_LEFT, VERTICAL, WEBTOON # allowed_formats: list of formats, e.g. [cbz, cbr, epub, pdf] # excluded_patterns: list of glob patterns, e.g. ["*.txt", "thumbs.db"] +# +# title_preprocessing_rules: regex transforms applied to series titles before +# metadata search, in order. Each rule: pattern, replacement (empty to remove), +# optional description, optional enabled (default true). Use single-quoted +# scalars so backslashes stay literal: '\s*\(Digital\)$' not "\\s*\\(Digital\\)$". +# +# auto_match_conditions: gate auto-matching on series properties. +# mode: all (AND) or any (OR); rules: [{field, operator, value}]. +# operators: is_null, is_not_null, equals, not_equals, gt, gte, lt, lte, +# contains, not_contains, starts_with, ends_with, matches, in, not_in libraries: - name: Comics path: /libraries/comics @@ -201,6 +233,19 @@ libraries: # book_strategy: filename # number_strategy: file_order # default_reading_direction: LEFT_TO_RIGHT + # title_preprocessing_rules: + # - pattern: '\s*\(Digital\)$' + # replacement: '' + # description: Remove (Digital) suffix + # enabled: true + # auto_match_conditions: + # mode: all + # rules: + # - field: external_ids.plugin:mangabaka + # operator: is_null + # - field: book_count + # operator: gte + # value: 1 - name: Manga path: /libraries/manga diff --git a/crates/codex-api/src/docs.rs b/crates/codex-api/src/docs.rs index df26f127..611ba14b 100644 --- a/crates/codex-api/src/docs.rs +++ b/crates/codex-api/src/docs.rs @@ -486,6 +486,8 @@ 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::set_metadata_settings, v1::handlers::user_plugins::oauth_start, v1::handlers::user_plugins::oauth_callback, v1::handlers::user_plugins::set_user_credentials, @@ -977,6 +979,8 @@ The following paths are exempt from rate limiting: v1::dto::UserPluginsListResponse, 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/plugins.rs b/crates/codex-api/src/routes/v1/dto/plugins.rs index afa8e1b0..c979b61d 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, } } @@ -416,6 +423,14 @@ pub struct PluginCapabilitiesDto { /// new chapter / volume releases for tracked series). #[serde(default)] pub release_source: bool, + /// Whether the plugin consumes enriched series data (bibliographic metadata, + /// custom metadata) on the sync/recommendation entries it receives. + #[serde(default)] + pub wants_full_metadata: bool, + /// Whether the plugin consumes the per-book reading-progress breakdown on the + /// sync entries it receives. Only meaningful when `user_read_sync` is true. + #[serde(default)] + pub wants_detailed_progress: bool, } impl From for PluginCapabilitiesDto { @@ -431,6 +446,8 @@ impl From for PluginCapabilitiesDto { external_id_source: c.external_id_source, user_recommendation_provider: c.user_recommendation_provider, release_source, + wants_full_metadata: c.wants_full_metadata, + wants_detailed_progress: c.wants_detailed_progress, } } } @@ -718,6 +735,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..92d1af2f 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) @@ -71,6 +77,21 @@ 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, + /// The admin-configured cron schedule that drives automatic syncs for this + /// plugin (the normalized 6-field form), or `None` when the admin has not + /// set one. Surfaced read-only so the UI can show the cadence to users and + /// indicate when auto sync isn't set up yet. The cadence is plugin-wide, not + /// per-user. + #[serde(skip_serializing_if = "Option::is_none")] + pub sync_cron_schedule: Option, + /// User privacy opt-out for sending user-defined custom metadata (host-side, + /// from `config._codex.sendCustomMetadata`). Default false. tags/genres/the + /// bibliographic block are admin policy on the plugin, not user-controlled. + pub send_custom_metadata: bool, /// Plugin capabilities (derived from manifest) pub capabilities: UserPluginCapabilitiesDto, /// User-facing configuration schema (from plugin manifest) @@ -115,6 +136,12 @@ 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, + /// 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 @@ -125,6 +152,29 @@ 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 a connection's metadata-enrichment opt-ins (user-controlled). +/// +/// Only `sendCustomMetadata` is a per-user choice (a privacy opt-out for the +/// user's custom fields). Whether tags/genres/the bibliographic block are sent is +/// admin policy on the plugin, not set here. Other `_codex` settings are +/// preserved (partial update). Only meaningful for `wantsFullMetadata` plugins. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SetMetadataSettingsRequest { + /// 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")] @@ -368,6 +418,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, @@ -378,9 +429,14 @@ mod tests { description: None, user_setup_instructions: None, config: serde_json::json!({}), + auto_sync: false, + sync_cron_schedule: None, + send_custom_metadata: false, capabilities: UserPluginCapabilitiesDto { read_sync: true, user_recommendation_provider: false, + wants_full_metadata: false, + wants_detailed_progress: false, }, user_config_schema: None, last_sync_result: None, @@ -389,8 +445,49 @@ 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")); + // Absent when the admin hasn't set a schedule. + assert!(!json.as_object().unwrap().contains_key("syncCronSchedule")); + } + + #[test] + fn test_user_plugin_dto_exposes_sync_cron_schedule() { + let dto = UserPluginDto { + id: Uuid::new_v4(), + plugin_id: Uuid::new_v4(), + plugin_name: "sync-anilist".to_string(), + plugin_display_name: "AniList Sync".to_string(), + plugin_type: "user".to_string(), + enabled: true, + connected: true, + requires_auth: true, + health_status: "healthy".to_string(), + external_username: None, + external_avatar_url: None, + last_sync_at: None, + last_success_at: None, + requires_oauth: true, + oauth_configured: true, + description: None, + user_setup_instructions: None, + config: serde_json::json!({}), + auto_sync: true, + sync_cron_schedule: Some("0 */5 * * * *".to_string()), + send_custom_metadata: false, + capabilities: UserPluginCapabilitiesDto { + read_sync: true, + user_recommendation_provider: false, + wants_full_metadata: false, + wants_detailed_progress: false, + }, + user_config_schema: None, + last_sync_result: None, + created_at: chrono::Utc::now(), + }; + let json = serde_json::to_value(&dto).unwrap(); + assert_eq!(json["syncCronSchedule"], "0 */5 * * * *"); } #[test] @@ -416,6 +513,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, @@ -426,9 +524,14 @@ mod tests { description: None, user_setup_instructions: None, config: serde_json::json!({}), + auto_sync: false, + sync_cron_schedule: None, + send_custom_metadata: false, capabilities: UserPluginCapabilitiesDto { read_sync: true, user_recommendation_provider: false, + wants_full_metadata: false, + wants_detailed_progress: false, }, user_config_schema: Some(schema), last_sync_result: None, @@ -459,6 +562,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, @@ -469,9 +573,14 @@ mod tests { description: None, user_setup_instructions: None, config: serde_json::json!({}), + auto_sync: false, + sync_cron_schedule: None, + send_custom_metadata: false, capabilities: UserPluginCapabilitiesDto { 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/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..5f8b80fe 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, SetMetadataSettingsRequest, + SetSyncModeRequest, SetUserCredentialsRequest, SyncStatusDto, SyncStatusQuery, + SyncTriggerResponse, UpdateUserPluginConfigRequest, UserPluginCapabilitiesDto, UserPluginDto, + UserPluginTaskDto, UserPluginTasksQuery, UserPluginsListResponse, }; use crate::extractors::auth::AuthContext; use crate::{error::ApiError, extractors::AppState}; @@ -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 @@ -119,6 +125,14 @@ 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), + wants_detailed_progress: manifest + .as_ref() + .map(|m| m.capabilities.wants_detailed_progress) + .unwrap_or(false), }; let user_config_schema = manifest @@ -143,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(), @@ -154,6 +169,9 @@ 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(), + sync_cron_schedule: plugin.sync_cron_schedule.clone(), + send_custom_metadata: instance.send_custom_metadata_enabled(), capabilities, user_config_schema, last_sync_result, @@ -247,6 +265,14 @@ 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), + wants_detailed_progress: manifest + .as_ref() + .map(|m| m.capabilities.wants_detailed_progress) + .unwrap_or(false), }, } }) @@ -721,6 +747,186 @@ 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, + )) +} + +/// 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}; + + 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"); + if let Some(v) = request.send_custom_metadata { + codex_obj.insert( + SEND_CUSTOM_METADATA_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 @@ -776,8 +982,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(), )); @@ -857,6 +1068,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!( @@ -928,7 +1143,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, @@ -1030,9 +1245,9 @@ pub async fn set_user_credentials( UserPluginTasksQuery, ), responses( - (status = 200, description = "Latest task found", body = UserPluginTaskDto), + (status = 200, description = "Latest task, or null if the plugin has no task yet", body = Option), (status = 401, description = "Not authenticated"), - (status = 404, description = "No tasks found for this plugin"), + (status = 404, description = "Plugin not enabled for this user"), ), security( ("jwt_bearer" = []), @@ -1045,13 +1260,16 @@ pub async fn get_plugin_tasks( auth: AuthContext, Path(plugin_id): Path, Query(query): Query, -) -> Result, ApiError> { +) -> Result>, ApiError> { // Verify user has this plugin enabled 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()))?; + // "No task yet" is a normal state (e.g. a plugin that has never synced), not + // an error — return 200 with a null body so callers don't have to treat a + // missing task as a 404. let task = TaskRepository::find_latest_user_plugin_task( &state.db, plugin_id, @@ -1059,8 +1277,7 @@ pub async fn get_plugin_tasks( query.task_type.as_deref(), ) .await - .map_err(|e| ApiError::Internal(format!("Failed to query tasks: {}", e)))? - .ok_or_else(|| ApiError::NotFound("No tasks found for this plugin".to_string()))?; + .map_err(|e| ApiError::Internal(format!("Failed to query tasks: {}", e)))?; - Ok(Json(UserPluginTaskDto::from(task))) + Ok(Json(task.map(UserPluginTaskDto::from))) } 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..b1bb341b 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,16 @@ 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), + ) + // 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/mod.rs b/crates/codex-db/src/entities/mod.rs index 006d79df..b96c4bac 100644 --- a/crates/codex-db/src/entities/mod.rs +++ b/crates/codex-db/src/entities/mod.rs @@ -27,6 +27,7 @@ pub mod plugin_failures; pub mod plugins; pub mod read_progress; pub mod refresh_tokens; +pub mod scheduled_firing_claims; pub mod series; pub mod settings; pub mod settings_history; diff --git a/crates/codex-db/src/entities/plugins.rs b/crates/codex-db/src/entities/plugins.rs index 5420098e..0e28adac 100644 --- a/crates/codex-db/src/entities/plugins.rs +++ b/crates/codex-db/src/entities/plugins.rs @@ -15,6 +15,10 @@ #![allow(dead_code)] +use super::user_plugins::{ + ALLOW_CUSTOM_METADATA_KEY, CODEX_CONFIG_NAMESPACE, SEND_GENRES_KEY, SEND_METADATA_KEY, + SEND_TAGS_KEY, +}; use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -129,6 +133,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, @@ -837,6 +852,46 @@ impl Model { ) } + /// Admin metadata-enrichment policy: read a host-only `config._codex.` + /// boolean, defaulting to **true**. These control whether the host attaches + /// series tags/genres/bibliographic-metadata to the entries it sends a plugin + /// that declares `wantsFullMetadata`. Default-on so a capable plugin works out + /// of the box; the admin sets `false` to withhold a field (e.g. heavy + /// summaries). The `_codex` namespace is host-only — the plugin ignores it. + /// (Custom metadata is the user's call instead; see + /// [`super::user_plugins::Model::send_custom_metadata_enabled`].) + fn metadata_policy_flag(&self, key: &str) -> bool { + self.config + .get(CODEX_CONFIG_NAMESPACE) + .and_then(|codex| codex.get(key)) + .and_then(|v| v.as_bool()) + .unwrap_or(true) + } + + /// Whether the host should send series `tags` to this plugin (admin policy). + pub fn send_tags_enabled(&self) -> bool { + self.metadata_policy_flag(SEND_TAGS_KEY) + } + + /// Whether the host should send series `genres` to this plugin (admin policy). + pub fn send_genres_enabled(&self) -> bool { + self.metadata_policy_flag(SEND_GENRES_KEY) + } + + /// Whether the host should send the bibliographic metadata block to this + /// plugin (admin policy). + pub fn send_metadata_enabled(&self) -> bool { + self.metadata_policy_flag(SEND_METADATA_KEY) + } + + /// Whether the admin permits this plugin to receive user-defined custom + /// metadata at all (admin policy, default true). This is a gate on top of the + /// user's own opt-out: custom metadata is sent only when the admin allows it + /// AND the user opts in. + pub fn allow_custom_metadata_enabled(&self) -> bool { + self.metadata_policy_flag(ALLOW_CUSTOM_METADATA_KEY) + } + /// Get the cached manifest if available pub fn cached_manifest(&self) -> Option { self.manifest @@ -1030,6 +1085,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 +1267,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 +1318,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 +1380,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 +1425,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/prelude.rs b/crates/codex-db/src/entities/prelude.rs index 34ba89f7..475e3e59 100644 --- a/crates/codex-db/src/entities/prelude.rs +++ b/crates/codex-db/src/entities/prelude.rs @@ -18,6 +18,7 @@ pub use super::library_jobs::Entity as LibraryJobs; pub use super::pages::Entity as Pages; #[allow(unused_imports)] pub use super::refresh_tokens::Entity as RefreshTokens; +pub use super::scheduled_firing_claims::Entity as ScheduledFiringClaims; pub use super::series::Entity as Series; pub use super::task_metrics::Entity as TaskMetrics; pub use super::tasks::Entity as Tasks; diff --git a/crates/codex-db/src/entities/scheduled_firing_claims.rs b/crates/codex-db/src/entities/scheduled_firing_claims.rs new file mode 100644 index 00000000..f75a9517 --- /dev/null +++ b/crates/codex-db/src/entities/scheduled_firing_claims.rs @@ -0,0 +1,30 @@ +//! Scheduled-firing claims: a distributed mutex for cron occurrences. +//! +//! Every `serve` replica runs its own in-process scheduler, so in a +//! horizontally-scaled deployment a cron fires once per replica. For jobs whose +//! firing does real work (e.g. fanning out per-user plugin syncs), each replica +//! claims the firing here before acting; the composite primary key +//! `(job_key, fire_slot)` lets exactly one INSERT win, and the rest skip. +//! +//! `job_key` identifies the logical job (e.g. `"plugin_sync:"`) and +//! `fire_slot` is the firing instant truncated to the cron's granularity so all +//! replicas firing for the same occurrence compute the same key. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "scheduled_firing_claims")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub job_key: String, + #[sea_orm(primary_key, auto_increment = false)] + pub fire_slot: DateTime, + pub claimed_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/user_plugins.rs b/crates/codex-db/src/entities/user_plugins.rs index 6f35da0c..08f57390 100644 --- a/crates/codex-db/src/entities/user_plugins.rs +++ b/crates/codex-db/src/entities/user_plugins.rs @@ -27,6 +27,36 @@ 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"; + +/// 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"; +/// User opt-in (on the connection) for sharing custom metadata. Default false. +pub const SEND_CUSTOM_METADATA_KEY: &str = "sendCustomMetadata"; +/// Admin gate (on the plugin) for whether custom metadata may be sent at all. +/// Default true; an admin sets false to forbid it regardless of the user opt-in. +/// Effective custom = capability && allowCustomMetadata && sendCustomMetadata. +pub const ALLOW_CUSTOM_METADATA_KEY: &str = "allowCustomMetadata"; + #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "user_plugins")] pub struct Model { @@ -174,6 +204,50 @@ 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 + /// 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.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(key)) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } + + /// Whether the user opted into sending user-defined custom metadata. + /// + /// This is the one enrichment field under per-user control (a privacy + /// opt-out, default false): custom metadata can hold private annotations, so + /// even when the plugin/admin wants it, the user may withhold it. Tags, + /// genres, and the bibliographic block are admin-controlled instead (see + /// [`super::plugins::Model::send_tags_enabled`] etc.). + 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 @@ -295,6 +369,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(); @@ -322,4 +411,51 @@ 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()); + } + + #[test] + fn test_send_custom_metadata_default_false() { + // Privacy opt-out: off unless the user explicitly enables it. + assert!(!test_model().send_custom_metadata_enabled()); + } + + #[test] + fn test_send_custom_metadata_enabled() { + let mut model = test_model(); + model.config = serde_json::json!({ "_codex": { "sendCustomMetadata": true } }); + assert!(model.send_custom_metadata_enabled()); + } } diff --git a/crates/codex-db/src/repositories/mod.rs b/crates/codex-db/src/repositories/mod.rs index 28193a2c..62e88a91 100644 --- a/crates/codex-db/src/repositories/mod.rs +++ b/crates/codex-db/src/repositories/mod.rs @@ -21,6 +21,7 @@ pub mod read_progress; pub mod refresh_token; pub mod release_ledger; pub mod release_sources; +pub mod scheduled_firing; pub mod series; pub mod series_aliases; pub mod series_covers; @@ -85,6 +86,7 @@ pub use release_ledger::{ }; #[allow(unused_imports)] pub use release_sources::{NewReleaseSource, ReleaseSourceRepository, ReleaseSourceUpdate}; +pub use scheduled_firing::ScheduledFiringRepository; pub use series::{SeriesQueryOptions, SeriesQuerySort, SeriesRepository, SeriesSortFieldRepo}; #[allow(unused_imports)] pub use series_aliases::SeriesAliasRepository; diff --git a/crates/codex-db/src/repositories/plugins.rs b/crates/codex-db/src/repositories/plugins.rs index e7247561..44a07b5f 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), @@ -346,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? @@ -416,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) } @@ -1844,4 +1867,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-db/src/repositories/scheduled_firing.rs b/crates/codex-db/src/repositories/scheduled_firing.rs new file mode 100644 index 00000000..509df90a --- /dev/null +++ b/crates/codex-db/src/repositories/scheduled_firing.rs @@ -0,0 +1,148 @@ +//! Repository for [`scheduled_firing_claims`]: a distributed claim used to make +//! a cron firing run on exactly one replica. + +use anyhow::{Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; + +use crate::entities::scheduled_firing_claims::{self, Entity as ScheduledFiringClaims}; + +pub struct ScheduledFiringRepository; + +impl ScheduledFiringRepository { + /// How long claim rows are kept before the winner opportunistically prunes + /// them. Only needs to outlive the longest plausible cron interval so a slow + /// recurrence never collides with a stale row; two days is comfortably safe. + const RETENTION_DAYS: i64 = 2; + + /// Try to claim the firing identified by `(job_key, fire_slot)`. + /// + /// Returns `true` if this caller won the claim and should do the work, or + /// `false` if another replica already claimed this exact firing. Concurrency- + /// safe: the table's composite primary key makes exactly one INSERT succeed; + /// the losers see a unique/primary-key violation, which is reported as + /// `Ok(false)` rather than an error. Any other DB error is propagated so the + /// caller can decide how to proceed (callers fan-out fail-open, since the + /// per-task dedup still prevents duplicates). + pub async fn try_claim( + db: &DatabaseConnection, + job_key: &str, + fire_slot: DateTime, + ) -> Result { + let claim = scheduled_firing_claims::ActiveModel { + job_key: Set(job_key.to_string()), + fire_slot: Set(fire_slot), + claimed_at: Set(Utc::now()), + }; + + match ScheduledFiringClaims::insert(claim).exec(db).await { + Ok(_) => { + // Winner-only cleanup: paid once per firing, not once per replica. + Self::prune_old(db, job_key, fire_slot).await; + Ok(true) + } + Err(e) => { + let msg = e.to_string().to_lowercase(); + if msg.contains("unique") + || msg.contains("duplicate") + || msg.contains("primary key") + || msg.contains("constraint") + { + Ok(false) + } else { + Err(e).context("Failed to claim scheduled firing") + } + } + } + } + + /// Best-effort deletion of this job's claims older than the retention window. + /// Failures are swallowed: pruning is housekeeping, not correctness. + async fn prune_old(db: &DatabaseConnection, job_key: &str, now: DateTime) { + let cutoff = now - Duration::days(Self::RETENTION_DAYS); + let _ = ScheduledFiringClaims::delete_many() + .filter(scheduled_firing_claims::Column::JobKey.eq(job_key)) + .filter(scheduled_firing_claims::Column::FireSlot.lt(cutoff)) + .exec(db) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::setup_test_db; + + fn slot(secs: i64) -> DateTime { + DateTime::from_timestamp(secs, 0).unwrap() + } + + #[tokio::test] + async fn first_claim_wins_second_loses() { + let db = setup_test_db().await; + let fire = slot(1_700_000_000); + + assert!( + ScheduledFiringRepository::try_claim(&db, "plugin_sync:a", fire) + .await + .unwrap(), + "first claim should win" + ); + assert!( + !ScheduledFiringRepository::try_claim(&db, "plugin_sync:a", fire) + .await + .unwrap(), + "second claim for the same firing should lose" + ); + } + + #[tokio::test] + async fn distinct_slots_and_jobs_each_win() { + let db = setup_test_db().await; + let fire = slot(1_700_000_000); + + // Same job, different firing → independent claim. + assert!( + ScheduledFiringRepository::try_claim(&db, "plugin_sync:a", fire) + .await + .unwrap() + ); + assert!( + ScheduledFiringRepository::try_claim(&db, "plugin_sync:a", slot(1_700_000_060)) + .await + .unwrap() + ); + // Different job, same firing → independent claim. + assert!( + ScheduledFiringRepository::try_claim(&db, "plugin_sync:b", fire) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn concurrent_claims_elect_exactly_one_winner() { + let db = setup_test_db().await; + let fire = slot(1_700_000_000); + + // Fan out N concurrent claims for the same firing on a shared pool, + // mimicking N replicas firing at once. Exactly one must win. + let mut handles = Vec::new(); + for _ in 0..8 { + let db = db.clone(); + handles.push(tokio::spawn(async move { + ScheduledFiringRepository::try_claim(&db, "plugin_sync:race", fire) + .await + .unwrap() + })); + } + + let mut wins = 0; + for h in handles { + if h.await.unwrap() { + wins += 1; + } + } + assert_eq!(wins, 1, "exactly one concurrent claim should win"); + } +} 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/plugin.rs b/crates/codex-models/src/plugin.rs index 34cccff7..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")] @@ -111,6 +126,21 @@ 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, + /// 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 @@ -424,3 +454,69 @@ 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); + } + + 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-models/src/task.rs b/crates/codex-models/src/task.rs index d4389b5c..a4386f41 100644 --- a/crates/codex-models/src/task.rs +++ b/crates/codex-models/src/task.rs @@ -269,49 +269,56 @@ fn default_mode() -> String { impl TaskType { /// Returns the default priority for this task type. /// - /// Higher values = more urgent. Uses large gaps for future insertions. + /// Higher values = more urgent; the worker claims tasks in descending + /// priority order (`ORDER BY priority DESC, scheduled_for ASC`). Uses large + /// gaps for future insertions. + /// + /// User-facing and integration work (plugin sync / recommendations, metadata + /// fetch, release tracking) is prioritized *above* bulk background work + /// (scanning, analysis, thumbnails) so an interactive action is never stuck + /// behind a long scan or analysis backlog. Note priority only affects claim + /// order, not preemption — an already-running task is not interrupted. /// Categories: - /// 1000-900: Scanning (library discovery, post-scan cleanup) - /// 800-750: Analysis (book/series analysis, title reprocessing) - /// 600-570: Thumbnails (single and batch generation) - /// 400-380: Metadata (deduplication, external lookups, plugin matching) - /// 200-180: Plugins (user-facing plugin operations) + /// 1000-960: User plugin operations (interactive, user-facing) + /// 900-860: Metadata fetch (external lookups, matching, dedup) + /// 850-820: Release tracking (polling, bulk track, backfill) + /// 800: Export (user-initiated) + /// 600-550: Scanning (library discovery, post-scan cleanup) + /// 500-450: Analysis (book/series analysis, title reprocessing, renumber) + /// 400-370: Thumbnails (single and batch generation) /// 100: Cleanup (low-priority maintenance) pub fn default_priority(&self) -> i32 { match self { + // User plugin operations (interactive, user-facing) + TaskType::UserPluginRecommendationDismiss { .. } => 1000, + TaskType::UserPluginSync { .. } => 980, + TaskType::UserPluginRecommendations { .. } => 960, + // Metadata fetch + TaskType::RefreshMetadata { .. } => 900, + TaskType::RefreshLibraryMetadata { .. } => 890, + TaskType::PluginAutoMatch { .. } => 880, + TaskType::FindDuplicates => 860, + // Release tracking + TaskType::PollReleaseSource { .. } => 850, + TaskType::BulkTrackForReleases { .. } => 840, + TaskType::BackfillTrackingFromMetadata { .. } => 820, + // Export (user-initiated) + TaskType::ExportSeries { .. } => 800, // Scanning - TaskType::ScanLibrary { .. } => 1000, - TaskType::PurgeDeleted { .. } => 900, + TaskType::ScanLibrary { .. } => 600, + TaskType::PurgeDeleted { .. } => 550, // Analysis - TaskType::AnalyzeBook { .. } => 800, - TaskType::AnalyzeSeries { .. } => 790, - TaskType::ReprocessSeriesTitle { .. } => 780, - TaskType::ReprocessSeriesTitles { .. } => 770, - TaskType::RenumberSeries { .. } => 760, - TaskType::RenumberSeriesBatch { .. } => 750, + TaskType::AnalyzeBook { .. } => 500, + TaskType::AnalyzeSeries { .. } => 490, + TaskType::ReprocessSeriesTitle { .. } => 480, + TaskType::ReprocessSeriesTitles { .. } => 470, + TaskType::RenumberSeries { .. } => 460, + TaskType::RenumberSeriesBatch { .. } => 450, // Thumbnails - TaskType::GenerateThumbnail { .. } => 600, - TaskType::GenerateSeriesThumbnail { .. } => 590, - TaskType::GenerateThumbnails { .. } => 580, - TaskType::GenerateSeriesThumbnails { .. } => 570, - // Metadata - TaskType::FindDuplicates => 400, - TaskType::RefreshMetadata { .. } => 390, - TaskType::RefreshLibraryMetadata { .. } => 385, - TaskType::PluginAutoMatch { .. } => 380, - // Export - TaskType::ExportSeries { .. } => 450, - // Plugins - TaskType::UserPluginRecommendationDismiss { .. } => 200, - TaskType::UserPluginSync { .. } => 190, - TaskType::UserPluginRecommendations { .. } => 180, - // Release tracking maintenance - TaskType::BackfillTrackingFromMetadata { .. } => 150, - // User-initiated bulk track/untrack: above the maintenance - // backfill but below scheduled release polling. - TaskType::BulkTrackForReleases { .. } => 155, - // Release polling: scheduled background discovery - TaskType::PollReleaseSource { .. } => 170, + TaskType::GenerateThumbnail { .. } => 400, + TaskType::GenerateSeriesThumbnail { .. } => 390, + TaskType::GenerateThumbnails { .. } => 380, + TaskType::GenerateSeriesThumbnails { .. } => 370, // Cleanup TaskType::CleanupBookFiles { .. } | TaskType::CleanupSeriesFiles { .. } @@ -539,6 +546,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( @@ -1147,7 +1171,7 @@ mod tests { assert_eq!(task.job_id(), Some(job_id)); assert_eq!(task.series_id(), None); assert_eq!(task.book_id(), None); - assert_eq!(task.default_priority(), 385); + assert_eq!(task.default_priority(), 890); let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); assert_eq!(type_str, "refresh_library_metadata"); @@ -1184,7 +1208,7 @@ mod tests { assert_eq!(task.library_id(), None); assert_eq!(task.series_id(), None); assert_eq!(task.book_id(), None); - assert_eq!(task.default_priority(), 170); + assert_eq!(task.default_priority(), 850); let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); assert_eq!(type_str, "poll_release_source"); @@ -1207,7 +1231,7 @@ mod tests { assert_eq!(task.library_id(), None); assert_eq!(task.series_id(), None); assert_eq!(task.book_id(), None); - assert_eq!(task.default_priority(), 155); + assert_eq!(task.default_priority(), 840); let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); assert_eq!(type_str, "bulk_track_for_releases"); @@ -1275,18 +1299,18 @@ mod tests { let plugin_id = Uuid::new_v4(); let user_id = Uuid::new_v4(); - // Scanning: highest priority + // Scanning assert_eq!( TaskType::ScanLibrary { library_id, mode: "normal".to_string() } .default_priority(), - 1000 + 600 ); assert_eq!( TaskType::PurgeDeleted { library_id }.default_priority(), - 900 + 550 ); // Analysis @@ -1296,15 +1320,15 @@ mod tests { force: false } .default_priority(), - 800 + 500 ); assert_eq!( TaskType::AnalyzeSeries { series_id }.default_priority(), - 790 + 490 ); assert_eq!( TaskType::ReprocessSeriesTitle { series_id }.default_priority(), - 780 + 480 ); assert_eq!( TaskType::ReprocessSeriesTitles { @@ -1312,18 +1336,18 @@ mod tests { series_ids: None } .default_priority(), - 770 + 470 ); assert_eq!( TaskType::RenumberSeries { series_id }.default_priority(), - 760 + 460 ); assert_eq!( TaskType::RenumberSeriesBatch { series_ids: Some(vec![series_id]) } .default_priority(), - 750 + 450 ); // Thumbnails @@ -1333,7 +1357,7 @@ mod tests { force: false } .default_priority(), - 600 + 400 ); assert_eq!( TaskType::GenerateSeriesThumbnail { @@ -1341,7 +1365,7 @@ mod tests { force: false } .default_priority(), - 590 + 390 ); assert_eq!( TaskType::GenerateThumbnails { @@ -1352,7 +1376,7 @@ mod tests { force: false } .default_priority(), - 580 + 380 ); assert_eq!( TaskType::GenerateSeriesThumbnails { @@ -1361,18 +1385,18 @@ mod tests { force: false } .default_priority(), - 570 + 370 ); - // Metadata - assert_eq!(TaskType::FindDuplicates.default_priority(), 400); + // Metadata fetch + assert_eq!(TaskType::FindDuplicates.default_priority(), 860); assert_eq!( TaskType::RefreshMetadata { book_id, source: "test".to_string() } .default_priority(), - 390 + 900 ); assert_eq!( TaskType::PluginAutoMatch { @@ -1381,10 +1405,10 @@ mod tests { source_scope: None } .default_priority(), - 380 + 880 ); - // Plugins + // User plugin operations: highest priority assert_eq!( TaskType::UserPluginRecommendationDismiss { plugin_id, @@ -1393,15 +1417,15 @@ mod tests { reason: None } .default_priority(), - 200 + 1000 ); assert_eq!( TaskType::UserPluginSync { plugin_id, user_id }.default_priority(), - 190 + 980 ); assert_eq!( TaskType::UserPluginRecommendations { plugin_id, user_id }.default_priority(), - 180 + 960 ); // Cleanup: lowest priority @@ -1426,10 +1450,31 @@ mod tests { #[test] fn test_default_priority_ordering_invariants() { let library_id = Uuid::new_v4(); - let _series_id = Uuid::new_v4(); + let series_id = Uuid::new_v4(); let book_id = Uuid::new_v4(); - // Scanning > Analysis > Thumbnails > Metadata > Plugins > Cleanup + // Policy: user plugin > metadata fetch > release > export > scanning > + // analysis > thumbnails > cleanup. The headline invariant is that + // user-facing/integration work outranks bulk scan/analysis work. + let plugin = TaskType::UserPluginSync { + plugin_id: Uuid::new_v4(), + user_id: Uuid::new_v4(), + } + .default_priority(); + let metadata = TaskType::RefreshMetadata { + book_id, + source: "test".to_string(), + } + .default_priority(); + let release = TaskType::PollReleaseSource { + source_id: Uuid::new_v4(), + } + .default_priority(); + let export = TaskType::ExportSeries { + export_id: Uuid::new_v4(), + user_id: Uuid::new_v4(), + } + .default_priority(); let scan = TaskType::ScanLibrary { library_id, mode: "normal".to_string(), @@ -1445,34 +1490,32 @@ mod tests { force: false, } .default_priority(); - let metadata = TaskType::FindDuplicates.default_priority(); - let plugin = TaskType::UserPluginSync { - plugin_id: Uuid::new_v4(), - user_id: Uuid::new_v4(), - } - .default_priority(); - let cleanup = TaskType::CleanupOrphanedFiles.default_priority(); - - assert!( - scan > analyze, - "Scanning should have higher priority than analysis" - ); + let cleanup = TaskType::CleanupSeriesFiles { series_id }.default_priority(); + + // Headline: user-facing / integration work outranks bulk scan + analysis. + assert!(plugin > scan, "User plugin sync should outrank scanning"); + assert!(plugin > analyze, "User plugin sync should outrank analysis"); + assert!(metadata > scan, "Metadata fetch should outrank scanning"); + assert!(metadata > analyze, "Metadata fetch should outrank analysis"); + assert!(release > scan, "Release tracking should outrank scanning"); assert!( - analyze > thumbnail, - "Analysis should have higher priority than thumbnails" - ); - assert!( - thumbnail > metadata, - "Thumbnails should have higher priority than metadata" - ); - assert!( - metadata > plugin, - "Metadata should have higher priority than plugins" + release > analyze, + "Release tracking should outrank analysis" ); + + // Top-band internal order: plugin > metadata > release > export. assert!( - plugin > cleanup, - "Plugins should have higher priority than cleanup" + plugin > metadata, + "User plugin sync should outrank metadata" ); + assert!(metadata > release, "Metadata fetch should outrank release"); + assert!(release > export, "Release should outrank export"); + assert!(export > scan, "Export should outrank scanning"); + + // Bulk-band internal order preserved: scan > analyze > thumbnail > cleanup. + assert!(scan > analyze, "Scanning should outrank analysis"); + assert!(analyze > thumbnail, "Analysis should outrank thumbnails"); + assert!(thumbnail > cleanup, "Thumbnails should outrank cleanup"); } #[test] diff --git a/crates/codex-scheduler/src/lib.rs b/crates/codex-scheduler/src/lib.rs index f590bb7e..42350823 100644 --- a/crates/codex-scheduler/src/lib.rs +++ b/crates/codex-scheduler/src/lib.rs @@ -1,14 +1,18 @@ pub mod release_sources; use anyhow::{Context, Result}; +use chrono::{DateTime, Duration, DurationRound, Utc}; use chrono_tz::Tz; use sea_orm::DatabaseConnection; 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, ScheduledFiringRepository, + TaskRepository, UserPluginsRepository, +}; use codex_scanner::{ScanMode, ScanningConfig}; use codex_services::library_jobs::{LibraryJobConfig, parse_job_config}; use codex_services::settings::SettingsService; @@ -91,6 +95,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 +727,114 @@ 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 { + // Every replica runs this scheduler, so every replica's cron + // fires this closure. Claim the firing so exactly one replica + // fans out; the rest skip. Fail open on a claim error: the + // per-(plugin,user) unique index still prevents duplicate tasks, + // so proceeding is safe and keeps syncs flowing if the claim + // table is briefly unavailable. + let slot = firing_slot(Utc::now()); + let job_key = plugin_sync_job_key(plugin_id); + match ScheduledFiringRepository::try_claim(&db, &job_key, slot).await { + Ok(false) => { + debug!( + "Plugin sync '{}' ({}) firing {} already claimed by another replica; skipping", + plugin_name, plugin_id, slot + ); + return; + } + Ok(true) => {} + Err(e) => warn!( + "Plugin sync claim check failed for '{}' ({}): {}; proceeding", + plugin_name, plugin_id, e + ), + } + + 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 +919,132 @@ 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, 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() +} + +/// Logical job key for a plugin's scheduled-sync firing claim. +pub(crate) fn plugin_sync_job_key(plugin_id: Uuid) -> String { + format!("plugin_sync:{plugin_id}") +} + +/// Truncate a firing instant to its minute so every replica firing for the same +/// cron occurrence computes the same claim slot, tolerating the sub-second clock +/// skew between replicas. Trade-off: sub-minute cron cadences collapse to at +/// most one fan-out per minute under multi-replica claiming — an acceptable +/// bound, since sub-minute external sync is not a sane cadence and the +/// per-(plugin, user) dedup still applies as a backstop. +pub(crate) fn firing_slot(now: DateTime) -> DateTime { + now.duration_trunc(Duration::minutes(1)).unwrap_or(now) +} + +/// 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(); + + // 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, requires_auth) { + 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 +1098,214 @@ 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 firing_slot_truncates_to_the_minute() { + // 1_700_000_040 is an exact minute boundary (divisible by 60). + let minute = DateTime::from_timestamp(1_700_000_040, 0).unwrap(); + // Two instants in that minute (sub-second skew between replicas) must + // map to the same slot so they claim the same firing. + let a = DateTime::from_timestamp(1_700_000_057, 250_000_000).unwrap(); + let b = DateTime::from_timestamp(1_700_000_082, 900_000_000).unwrap(); + assert_eq!(firing_slot(a), minute); + assert_eq!(firing_slot(b), minute); + // The next minute is a distinct slot. + let next_minute = DateTime::from_timestamp(1_700_000_100, 0).unwrap(); + let c = DateTime::from_timestamp(1_700_000_115, 0).unwrap(); + assert_eq!(firing_slot(c), next_minute); + } + + #[test] + fn plugin_sync_job_key_is_stable_and_scoped() { + let id = uuid::Uuid::nil(); + assert_eq!( + plugin_sync_job_key(id), + "plugin_sync:00000000-0000-0000-0000-000000000000" + ); + } + + #[test] + fn is_auto_sync_eligible_matrix() { + // Auth-required plugin (requires_auth = true) + assert!( + 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), true), + "disabled is ineligible" + ); + assert!( + !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), 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 { + 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/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/library.rs b/crates/codex-services/src/plugin/library.rs index feb1d394..52bcfcc4 100644 --- a/crates/codex-services/src/plugin/library.rs +++ b/crates/codex-services/src/plugin/library.rs @@ -1,20 +1,30 @@ //! 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 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, - 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 @@ -30,60 +40,175 @@ 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, + /// 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 +/// 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, + + /// 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 { + /// 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 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 +/// 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 +218,53 @@ pub async fn build_user_library( } }; - // 5. Build entries - let mut entries = Vec::new(); + // 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 { + ( + 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; + let mut read_books: Vec = Vec::new(); - 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 +274,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, @@ -134,33 +287,35 @@ pub async fn build_user_library( 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, + }); + } } } } - // 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 +327,116 @@ 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, + read_books, + }, + ); + } + + 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, + // Recommendations don't use per-book progress detail. + include_book_detail: false, + }, + ) + .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()), + // Enrichment is wired in a later phase; entries carry no metadata yet. + metadata: None, + custom_metadata: None, }); } @@ -228,9 +453,98 @@ 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::{ + BookMetadataRepository, 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, + read_books: vec![], + } + } + + /// 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 +577,251 @@ 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, + std::slice::from_ref(&series), + 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_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; + let conn = db.sea_orm_connection(); + let engagements = + build_series_engagements(conn, Uuid::new_v4(), &[], EngagementOptions::default()) + .await + .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/manager.rs b/crates/codex-services/src/plugin/manager.rs index 9171392f..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, }) } @@ -2089,6 +2097,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 +2147,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 +2197,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 +2249,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-services/src/plugin/protocol.rs b/crates/codex-services/src/plugin/protocol.rs index b6a6f798..e4ab9b3b 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 @@ -1022,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, } // ============================================================================= @@ -2147,6 +2261,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 +2340,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 +2460,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(); @@ -2362,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"); @@ -2378,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); @@ -2434,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!({ @@ -2522,4 +2663,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..7f48753c 100644 --- a/crates/codex-services/src/plugin/sync.rs +++ b/crates/codex-services/src/plugin/sync.rs @@ -111,16 +111,43 @@ 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 + /// 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 -#[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) @@ -132,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, } // ============================================================================= @@ -371,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()), @@ -380,6 +455,10 @@ mod tests { title: None, library_id: String::new(), 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"); @@ -418,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); @@ -433,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); @@ -448,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); @@ -472,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 // ========================================================================= @@ -489,6 +672,7 @@ mod tests { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: None, started_at: None, @@ -498,6 +682,10 @@ mod tests { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }, SyncEntry { external_id: "2".to_string(), @@ -511,6 +699,10 @@ mod tests { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }, ], }; @@ -630,6 +822,7 @@ mod tests { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: Some(7.0), started_at: None, @@ -639,6 +832,10 @@ mod tests { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }], next_cursor: Some("page2".to_string()), has_more: true, @@ -725,6 +922,7 @@ mod tests { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: None, started_at: None, @@ -734,6 +932,10 @@ mod tests { title: Some("Berserk".to_string()), library_id: String::new(), 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"); @@ -754,6 +956,10 @@ mod tests { title: None, library_id: String::new(), 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")); @@ -795,6 +1001,10 @@ mod tests { title: None, library_id: "11111111-1111-1111-1111-111111111111".to_string(), 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..27108084 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs @@ -17,11 +17,16 @@ 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}; +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 +41,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 +54,11 @@ struct CodexRecommendationSettings { /// Stored in config as 0-10 (display scale), converted by multiplying by 10. /// Default: 0 (no threshold). drop_threshold: i32, + /// User privacy opt-out for sending user-defined `custom_metadata` on seeds. + /// Default: false. The bibliographic metadata block is admin policy on the + /// plugin, not user-controlled; genres/tags are always sent to recommendation + /// plugins as baseline taste signal. + send_custom_metadata: bool, } impl CodexRecommendationSettings { @@ -82,10 +89,16 @@ impl CodexRecommendationSettings { .map(|v| v.clamp(0, 100)) .unwrap_or(0); + 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_custom_metadata, } } } @@ -146,6 +159,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 +431,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 +442,49 @@ impl TaskHandler for UserPluginRecommendationsHandler { rec_settings.max_seeds ); + // Attach opt-in enrichment to the seeds when the plugin declares the + // capability. The bibliographic block is admin policy on the plugin + // (default on); custom_metadata is the user's privacy opt-out. + // (genres/tags already ride on every recommendation entry.) + 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); + let send_metadata = wants_full_metadata + && plugin_model + .as_ref() + .is_some_and(|p| p.send_metadata_enabled()); + // Custom metadata: capability + admin allow-gate + user opt-in. + let send_custom_metadata = wants_full_metadata + && plugin_model + .as_ref() + .is_some_and(|p| p.allow_custom_metadata_enabled()) + && rec_settings.send_custom_metadata; + attach_seed_metadata(db, user_id, &mut seeds, send_metadata, send_custom_metadata) + .await; + + // genres/tags are baseline taste signal on recommendation entries, so + // they default on; strip them only when a `wantsFullMetadata` plugin's + // admin policy explicitly turns them off (parity with sync). + let strip_genres = wants_full_metadata + && !plugin_model + .as_ref() + .is_some_and(|p| p.send_genres_enabled()); + let strip_tags = wants_full_metadata + && !plugin_model.as_ref().is_some_and(|p| p.send_tags_enabled()); + if strip_genres || strip_tags { + for seed in &mut seeds { + if strip_genres { + seed.genres.clear(); + } + if strip_tags { + seed.tags.clear(); + } + } + } + // 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..e66072b9 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs @@ -184,12 +184,16 @@ impl TaskHandler for UserPluginSyncHandler { let do_push = sync_mode == "both" || sync_mode == "push"; let codex_settings = CodexSyncSettings::from_user_config(&user_config); - // Admin-configured library scope for this plugin (empty = all libraries). - let allowed_library_ids: Vec = - match PluginsRepository::get_by_id(db, plugin_id).await { - Ok(Some(plugin)) => plugin.library_ids_vec(), - _ => Vec::new(), - }; + // Admin-configured plugin row: drives library scope and the + // admin-side metadata-enrichment policy (tags/genres/metadata). + let plugin = PluginsRepository::get_by_id(db, plugin_id) + .await + .ok() + .flatten(); + let allowed_library_ids: Vec = plugin + .as_ref() + .map(|p| p.library_ids_vec()) + .unwrap_or_default(); if !allowed_library_ids.is_empty() { debug!( "Task {}: Plugin scoped to {} libraries", @@ -269,11 +273,43 @@ 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); + // 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); + // tags/genres/metadata are admin policy on the plugin (default on when + // the plugin declares the capability); custom_metadata is the user's + // privacy opt-out (default off). All gated by the capability. + let metadata_flags = push::MetadataFlags { + tags: wants_full_metadata && plugin.as_ref().is_some_and(|p| p.send_tags_enabled()), + genres: wants_full_metadata + && plugin.as_ref().is_some_and(|p| p.send_genres_enabled()), + metadata: wants_full_metadata + && plugin.as_ref().is_some_and(|p| p.send_metadata_enabled()), + // Custom metadata: capability + admin allow-gate + user opt-in. + custom_metadata: wants_full_metadata + && plugin + .as_ref() + .is_some_and(|p| p.allow_custom_metadata_enabled()) + && codex_settings.send_custom_metadata, + detailed_progress: wants_detailed_progress, + }; + if let Some(ref source) = external_id_source { debug!( "Task {}: Plugin declares externalIdSource: {}", @@ -365,6 +401,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 439a7b40..d3de99b8 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs @@ -2,46 +2,42 @@ //! 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::library_names; -use codex_services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; +use codex_services::plugin::library::{ + EngagementOptions, SeriesBookProgress, SeriesEngagement, build_series_engagements, +}; +use codex_services::plugin::sync::{SyncBookProgress, 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( - db: &DatabaseConnection, - series_ids: &[Uuid], -) -> HashMap { - let series = 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; +/// 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, +} - series - .into_iter() - .map(|s| { - let name = names.get(&s.library_id).cloned().unwrap_or_default(); - (s.id, (s.library_id, name)) - }) - .collect() +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 @@ -50,6 +46,188 @@ 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], + allowed_library_ids: &[Uuid], +) -> Vec { + SeriesRepository::get_by_ids(db, series_ids) + .await + .unwrap_or_default() + .into_iter() + .filter(|s| library_in_scope(allowed_library_ids, s.library_id)) + .collect() +} + +/// 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, + flags: MetadataFlags, +) -> 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 + }; + + // 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. + 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, + max_volume, + max_chapter, + read_books, + }; + + 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(), + // 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 + }, + }) +} + /// Build push entries from a user's Codex reading progress. /// /// For each series that has an external ID matching the given source, @@ -66,8 +244,9 @@ 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 (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 +270,56 @@ 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![]; - } + let series = scoped_series(db, &candidate_series_ids, allowed_library_ids).await; + let opts = EngagementOptions { + include_taxonomy: flags.needs_taxonomy(), + // 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, }; - - // 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 { - Ok(map) => map, - Err(e) => { - warn!( - "Task {}: Failed to batch-fetch reading progress: {}", - task_id, e - ); - HashMap::new() - } - }; - - // 4. Batch-fetch all series metadata (1 query instead of N) - let metadata_map = match SeriesMetadataRepository::get_by_series_ids(db, &series_ids).await { + let engagements = match build_series_engagements(db, user_id, &series, opts).await { Ok(map) => map, Err(e) => { warn!( - "Task {}: Failed to batch-fetch series metadata: {}", + "Task {}: Failed to build series engagements for push: {}", task_id, e ); - HashMap::new() + return vec![]; } }; - // 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() - }; + // 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(); - // 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, flags) + { + 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, @@ -351,6 +328,7 @@ pub(crate) async fn build_push_entries( settings, &matched_series_ids, allowed_library_ids, + flags, ) .await; @@ -376,8 +354,9 @@ 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 + // 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 +372,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 +384,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 +396,41 @@ 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, + // 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 opts = EngagementOptions { + include_taxonomy: flags.needs_taxonomy(), + // 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, Err(e) => { warn!( - "Task {}: Failed to fetch books for unmatched series: {}", + "Task {}: Failed to build series engagements 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, - Err(e) => { - warn!( - "Task {}: Failed to fetch metadata 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() - } - } - } 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, flags) { + 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 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..6cf83e68 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,13 @@ /// 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, SEND_CUSTOM_METADATA_KEY, +}; /// Codex generic sync settings — server-interpreted preferences that control /// which entries to build and send to the plugin. Stored in the user plugin @@ -28,6 +32,10 @@ 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, + /// User privacy opt-out for sending user-defined `custom_metadata`. Default: + /// false. The other enrichment fields (tags/genres/metadata) are admin + /// policy on the plugin, not user-controlled. + pub send_custom_metadata: bool, } impl CodexSyncSettings { @@ -71,6 +79,10 @@ impl CodexSyncSettings { .get("searchFallback") .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 163390ee..d6033be2 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, + 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}; @@ -268,6 +269,10 @@ async fn test_match_and_apply_no_source() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -328,6 +333,10 @@ async fn test_match_and_apply_with_matches() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }, SyncEntry { external_id: "99999".to_string(), // no match @@ -341,6 +350,10 @@ async fn test_match_and_apply_with_matches() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }, ]; @@ -403,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, @@ -412,6 +426,10 @@ 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, + genres: Vec::new(), + tags: Vec::new(), }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -502,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, @@ -511,6 +530,10 @@ async fn test_match_and_apply_skips_already_read() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -542,6 +565,10 @@ fn pulled_completed_entry(external_id: &str) -> SyncEntry { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), } } @@ -594,6 +621,7 @@ async fn test_build_push_entries_respects_library_scope() { Uuid::new_v4(), &default_codex_settings(), &[allowed], + push::MetadataFlags::default(), ) .await; @@ -609,6 +637,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); @@ -721,6 +750,105 @@ 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, + ..Default::default() + }; + 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 { @@ -729,6 +857,7 @@ fn default_codex_settings() -> CodexSyncSettings { count_partial_progress: false, sync_ratings: true, search_fallback: false, + send_custom_metadata: false, } } @@ -785,6 +914,7 @@ async fn test_build_push_entries_with_progress() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -851,6 +981,7 @@ async fn test_build_push_entries_all_completed() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -904,6 +1035,7 @@ async fn test_build_push_entries_skips_no_progress() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -1095,6 +1227,7 @@ async fn test_build_push_entries_skip_completed_series() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -1154,6 +1287,7 @@ async fn test_build_push_entries_skip_in_progress_series() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -1222,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); @@ -1241,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); @@ -1248,6 +1384,258 @@ 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: 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, + "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_eq!(p.max_volume, Some(8)); + 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(); + + // Default flags (no capability): Tier 1 max_chapter still flows. + let entries = push::build_push_entries( + conn, + user.id, + "api:anilist", + Uuid::new_v4(), + &default_codex_settings(), + &[], + 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] +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; @@ -1282,6 +1670,7 @@ async fn test_apply_pulled_entry_uses_volumes() { pages: None, total_chapters: None, total_volumes: None, + ..Default::default() }), score: None, started_at: None, @@ -1291,6 +1680,10 @@ async fn test_apply_pulled_entry_uses_volumes() { title: None, library_id: String::new(), 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) @@ -1415,6 +1808,7 @@ async fn test_build_push_entries_includes_rating() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -1477,6 +1871,7 @@ async fn test_build_push_entries_no_rating_when_disabled() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -1533,6 +1928,7 @@ async fn test_build_push_entries_no_rating_for_unrated() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -1582,6 +1978,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, @@ -1591,6 +1988,10 @@ async fn test_apply_pulled_rating_no_existing() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (matched, _applied) = pull::match_and_apply_pulled_entries( @@ -1674,6 +2075,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, @@ -1683,6 +2085,10 @@ async fn test_apply_pulled_rating_existing_not_overwritten() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (_matched, _applied) = pull::match_and_apply_pulled_entries( @@ -1751,6 +2157,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, @@ -1760,6 +2167,10 @@ async fn test_apply_pulled_rating_disabled() { title: None, library_id: String::new(), library_name: String::new(), + metadata: None, + custom_metadata: None, + genres: Vec::new(), + tags: Vec::new(), }]; let (_matched, _applied) = pull::match_and_apply_pulled_entries( @@ -1832,6 +2243,7 @@ async fn test_build_push_entries_populates_latest_updated_at() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -1900,6 +2312,7 @@ async fn test_build_push_entries_populates_total_volumes() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -1962,6 +2375,7 @@ async fn test_build_push_entries_always_sends_volumes() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; @@ -2065,6 +2479,7 @@ async fn test_build_push_entries_includes_unmatched_with_search_fallback() { Uuid::new_v4(), &settings_no_fallback, &[], + push::MetadataFlags::default(), ) .await; assert_eq!( @@ -2086,6 +2501,7 @@ async fn test_build_push_entries_includes_unmatched_with_search_fallback() { Uuid::new_v4(), &settings_with_fallback, &[], + push::MetadataFlags::default(), ) .await; assert_eq!( @@ -2149,6 +2565,7 @@ async fn test_build_push_entries_unmatched_skips_no_metadata() { Uuid::new_v4(), &settings, &[], + push::MetadataFlags::default(), ) .await; @@ -2205,6 +2622,7 @@ async fn test_build_push_entries_populates_title_for_matched() { Uuid::new_v4(), &default_codex_settings(), &[], + push::MetadataFlags::default(), ) .await; diff --git a/docker-compose.yml b/docker-compose.yml index 3c704c1f..2961d40b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,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 command: ["codex", "serve", "--config", "/app/config/config.docker.yaml"] environment: # Email configuration for Mailhog @@ -88,10 +91,15 @@ 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 - ./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 @@ -169,10 +177,15 @@ 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 - ./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 +253,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 +268,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 +278,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" \ @@ -415,6 +434,8 @@ services: - screenshots_uploads:/app/data/uploads:rw - screenshots_cache:/app/data/cache:rw - ./plugins/metadata-echo/dist:/opt/codex/plugins/metadata-echo/dist:ro + - ./plugins/sync-echo/dist:/opt/codex/plugins/sync-echo/dist:ro + - ./plugins/recommendations-echo/dist:/opt/codex/plugins/recommendations-echo/dist:ro command: ["codex", "serve", "--config", "/app/config/config.screenshots.yaml"] environment: diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 8cec9b2f..54c5f8c6 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": [ @@ -15354,6 +15407,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": [ @@ -15437,11 +15543,18 @@ ], "responses": { "200": { - "description": "Latest task found", + "description": "Latest task, or null if the plugin has no task yet", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserPluginTaskDto" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserPluginTaskDto" + } + ] } } } @@ -15450,7 +15563,7 @@ "description": "Not authenticated" }, "404": { - "description": "No tasks found for this plugin" + "description": "Plugin not enabled for this user" } }, "security": [ @@ -33848,6 +33961,14 @@ "userRecommendationProvider": { "type": "boolean", "description": "Can provide personalized recommendations" + }, + "wantsDetailedProgress": { + "type": "boolean", + "description": "Whether the plugin consumes the per-book reading-progress breakdown on the\nsync entries it receives. Only meaningful when `user_read_sync` is true." + }, + "wantsFullMetadata": { + "type": "boolean", + "description": "Whether the plugin consumes enriched series data (bibliographic metadata,\ncustom metadata) on the sync/recommendation entries it receives." } } }, @@ -34117,6 +34238,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", @@ -38702,6 +38831,19 @@ } } }, + "SetMetadataSettingsRequest": { + "type": "object", + "description": "Request to set a connection's metadata-enrichment opt-ins (user-controlled).\n\nOnly `sendCustomMetadata` is a per-user choice (a privacy opt-out for the\nuser's custom fields). Whether tags/genres/the bibliographic block are sent is\nadmin policy on the plugin, not set here. Other `_codex` settings are\npreserved (partial update). Only meaningful for `wantsFullMetadata` plugins.", + "properties": { + "sendCustomMetadata": { + "type": [ + "boolean", + "null" + ], + "description": "Send user-defined custom metadata to the plugin." + } + } + }, "SetPreferenceRequest": { "type": "object", "description": "Request to set a single preference value", @@ -38799,6 +38941,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 +41948,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", @@ -42260,7 +42418,9 @@ "description": "Plugin capabilities for display (user plugin context)", "required": [ "readSync", - "userRecommendationProvider" + "userRecommendationProvider", + "wantsFullMetadata", + "wantsDetailedProgress" ], "properties": { "readSync": { @@ -42270,6 +42430,14 @@ "userRecommendationProvider": { "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." } } }, @@ -42284,14 +42452,21 @@ "pluginType", "enabled", "connected", + "requiresAuth", "healthStatus", "requiresOauth", "oauthConfigured", "config", + "autoSync", + "sendCustomMetadata", "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)" @@ -42301,7 +42476,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", @@ -42382,10 +42557,25 @@ "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" }, + "sendCustomMetadata": { + "type": "boolean", + "description": "User privacy opt-out for sending user-defined custom metadata (host-side,\nfrom `config._codex.sendCustomMetadata`). Default false. tags/genres/the\nbibliographic block are admin policy on the plugin, not user-controlled." + }, + "syncCronSchedule": { + "type": [ + "string", + "null" + ], + "description": "The admin-configured cron schedule that drives automatic syncs for this\nplugin (the normalized 6-field form), or `None` when the admin has not\nset one. Surfaced read-only so the UI can show the cadence to users and\nindicate when auto sync isn't set up yet. The cadence is plugin-wide, not\nper-user." + }, "userConfigSchema": { "oneOf": [ { diff --git a/docs/docs/plugins/anilist-sync.md b/docs/docs/plugins/anilist-sync.md index ac4da4b4..136c6f86 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: @@ -134,6 +140,31 @@ 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 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, the host can attach extra series data to the entries it sends that plugin. Control is split between the admin and the user: + +**Admin (the plugin's _Configure_ dialog → _Metadata Enrichment_).** Whether each field is sent is the administrator's decision, because the plugin needs the data to function. These are **on by default** for a capable plugin (so it works out of the box); an admin can turn one off — typically to reduce payload: + +| Option | Default | Sent data | +| ------------------------- | ------- | ------------------------------------------------------------------------------------------- | +| **Send tags** | On | Each series' tags (small). Lets the plugin apply tag-based rules. | +| **Send genres** | On | Each series' genres (small). | +| **Send metadata** | On | Summary, authors, publisher, age rating, language, reading direction. The **heaviest** one. | +| **Allow custom metadata** | On | Whether the plugin may receive users' custom metadata at all. A gate on top of the user opt-in — turn it off to forbid custom metadata entirely. | + +On a large library, **Send metadata** can add a lot (summaries are the big field), so an admin may turn it off for plugins that only need tags/genres. + +**User (your connection → _Settings_ → _Metadata Sharing_).** One field is your call, because it's your data: + +| Option | Default | Sent data | +| ------------------------- | ------- | ------------------------------------------ | +| **Share custom metadata** | Off | Your user-defined custom metadata fields. | + +Custom metadata is a free-form field that may hold private annotations, so it is **off by default** and only you can enable it — even if the plugin wants it. Only turn it on for plugins you trust. (Your administrator can also forbid custom metadata for a plugin entirely via the **Allow custom metadata** policy above; when they do, your opt-in has no effect.) + +All of these are stored under the host-only `_codex` namespace (admin policy on the plugin's config; the user opt-out on your connection) and are read only by the server when building entries — the plugin never reads `_codex` directly. The policy applies to both sync and recommendation plugins; for recommendations, genres and tags are sent by default (they're useful taste signal), and an admin can turn them off to trim payload. + ### 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**: @@ -146,10 +177,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 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". -- **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". +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 a857a4e3..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 @@ -86,7 +88,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 +100,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. @@ -150,11 +178,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) @@ -220,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/migration/src/lib.rs b/migration/src/lib.rs index c9ccee59..6fe0b629 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -186,6 +186,12 @@ 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; +// Distributed claim so a cron firing runs on exactly one replica +mod m20260607_000093_create_scheduled_firing_claims; +// Atomic per-(plugin,user) dedup for scheduled user-plugin syncs +mod m20260607_000094_add_user_plugin_sync_unique_index; pub struct Migrator; @@ -343,6 +349,12 @@ 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), + // Distributed claim so a cron firing runs on exactly one replica + Box::new(m20260607_000093_create_scheduled_firing_claims::Migration), + // Atomic per-(plugin,user) dedup for scheduled user-plugin syncs + Box::new(m20260607_000094_add_user_plugin_sync_unique_index::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/migration/src/m20260607_000093_create_scheduled_firing_claims.rs b/migration/src/m20260607_000093_create_scheduled_firing_claims.rs new file mode 100644 index 00000000..4830cf60 --- /dev/null +++ b/migration/src/m20260607_000093_create_scheduled_firing_claims.rs @@ -0,0 +1,64 @@ +use sea_orm_migration::prelude::*; + +/// Distributed claim table so a cron firing runs on exactly one replica. +/// +/// Every `serve` replica runs its own scheduler, so each cron fires once per +/// replica. Jobs whose firing does real work claim `(job_key, fire_slot)` here +/// before acting; the composite primary key makes exactly one INSERT win. +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ScheduledFiringClaims::Table) + .if_not_exists() + // Logical job, e.g. "plugin_sync:". + .col( + ColumnDef::new(ScheduledFiringClaims::JobKey) + .string() + .not_null(), + ) + // Firing instant truncated to the cron's granularity, so all + // replicas firing for the same occurrence agree on the key. + .col( + ColumnDef::new(ScheduledFiringClaims::FireSlot) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(ScheduledFiringClaims::ClaimedAt) + .timestamp_with_time_zone() + .not_null(), + ) + // Composite PK is the uniqueness guarantee that elects the + // single winner per firing. + .primary_key( + Index::create() + .col(ScheduledFiringClaims::JobKey) + .col(ScheduledFiringClaims::FireSlot), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ScheduledFiringClaims::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum ScheduledFiringClaims { + Table, + JobKey, + FireSlot, + ClaimedAt, +} diff --git a/migration/src/m20260607_000094_add_user_plugin_sync_unique_index.rs b/migration/src/m20260607_000094_add_user_plugin_sync_unique_index.rs new file mode 100644 index 00000000..932614de --- /dev/null +++ b/migration/src/m20260607_000094_add_user_plugin_sync_unique_index.rs @@ -0,0 +1,51 @@ +use sea_orm_migration::prelude::*; + +/// Partial unique index making at most one `pending`/`processing` +/// `user_plugin_sync` task exist per `(plugin_id, user_id)`. +/// +/// `user_plugin_sync` tasks key their target by `plugin_id`/`user_id` inside the +/// `params` JSON (not the `library_id`/`series_id`/`book_id` columns that the +/// other dedup indexes cover), so this index is on the extracted JSON values. +/// `TaskRepository::enqueue` already retries on a unique violation and returns +/// the existing task, so this index turns its racy check-then-insert dedup into +/// an atomic one — closing the window where two schedulers (or a scheduled + +/// manual trigger) enqueue duplicate syncs for the same connection. +/// +/// Safe to create on existing data: scheduled fan-out only ever fired on a +/// single replica before this, and the manual path already deduped, so no +/// pre-existing duplicate pending rows can exist to violate the index. +#[derive(DeriveMigrationName)] +pub struct Migration; + +const INDEX_NAME: &str = "unique_pending_user_plugin_sync"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = match manager.get_database_backend() { + sea_orm::DatabaseBackend::Postgres => format!( + r#"CREATE UNIQUE INDEX IF NOT EXISTS {INDEX_NAME} + ON tasks ((params->>'plugin_id'), (params->>'user_id')) + WHERE task_type = 'user_plugin_sync' + AND status IN ('pending', 'processing')"# + ), + _ => format!( + r#"CREATE UNIQUE INDEX IF NOT EXISTS {INDEX_NAME} + ON tasks (json_extract(params, '$.plugin_id'), json_extract(params, '$.user_id')) + WHERE task_type = 'user_plugin_sync' + AND status IN ('pending', 'processing')"# + ), + }; + + manager.get_connection().execute_unprepared(&sql).await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared(&format!("DROP INDEX IF EXISTS {INDEX_NAME}")) + .await?; + Ok(()) + } +} 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/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..9cca5a08 --- /dev/null +++ b/plugins/recommendations-echo/package-lock.json @@ -0,0 +1,1974 @@ +{ + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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..856c91e9 --- /dev/null +++ b/plugins/recommendations-echo/src/manifest.ts @@ -0,0 +1,66 @@ +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, + // Opt in to enriched series data (metadata/custom metadata) so the + // enrichment toggles appear and the payloads can be inspected. + wantsFullMetadata: 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, + }, + ], + }, + userConfigSchema: { + description: + "Per-user settings for the Echo recommendations plugin. These are sent to the plugin as user config on each run.", + fields: [ + { + key: "recommendationRules", + label: "Recommendation Rules (JSON)", + description: + 'Optional per-user JSON rules the plugin applies (e.g. {"excludeGenres":["Ecchi"]}). Echoed back in the recorded payloads so you can confirm it reaches the plugin.', + type: "json" as const, + required: false, + }, + ], + }, + 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/plugins/sdk-typescript/src/server.ts b/plugins/sdk-typescript/src/server.ts index f278091a..94778012 100644 --- a/plugins/sdk-typescript/src/server.ts +++ b/plugins/sdk-typescript/src/server.ts @@ -157,6 +157,33 @@ 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; + /** + * 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/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/manifest.ts b/plugins/sdk-typescript/src/types/manifest.ts index d388db68..8984949c 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 @@ -87,6 +97,17 @@ export interface PluginCapabilities { externalIdSource?: string; /** Can provide recommendations */ userRecommendationProvider?: boolean; + /** + * Request enriched series data on the entries the host sends this plugin. + * + * When `true`, the host attaches series tags/genres, a bibliographic metadata + * block (summary, authors, publisher, age rating, language, reading + * direction), and/or user-defined custom metadata, per the user's opt-in + * `_codex.send*` settings. The host only assembles this for plugins that + * declare it, so plugins that don't are unaffected. Applies to sync and + * recommendation plugins. + */ + wantsFullMetadata?: boolean; /** * Release-source plugin capability. Set when this plugin announces new * chapter/volume releases for tracked series via `releases/poll`. 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; } } 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..01fed2d8 --- /dev/null +++ b/plugins/sync-echo/package-lock.json @@ -0,0 +1,1974 @@ +{ + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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..dbe0b0e5 --- /dev/null +++ b/plugins/sync-echo/src/manifest.ts @@ -0,0 +1,76 @@ +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, + // Opt in to enriched series data (tags/genres/metadata/custom metadata) so + // the enrichment toggles appear and the payloads can be inspected. + wantsFullMetadata: 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, + }, + ], + }, + userConfigSchema: { + description: + "Per-user settings for the Echo sync plugin. These are sent to the plugin as user config on each sync.", + fields: [ + { + key: "syncRules", + label: "Sync Rules (JSON)", + description: + 'Optional per-user JSON rules the plugin applies (e.g. {"skipTags":["Ecchi"]}). Echoed back in the recorded payloads so you can confirm it reaches the plugin.', + type: "json" as const, + required: false, + }, + ], + }, + 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"], + }, + }, +}); diff --git a/src/commands/seed.rs b/src/commands/seed.rs index dc2d9d4b..1c73c90b 100644 --- a/src/commands/seed.rs +++ b/src/commands/seed.rs @@ -10,6 +10,7 @@ use codex_db::repositories::{ api_key::ApiKeyRepository, library::CreateLibraryParams, library::LibraryRepository, plugins::PluginsRepository, user::UserRepository, }; +use codex_models::preprocessing::{AutoMatchConditions, PreprocessingRule}; use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; use codex_services::plugin::protocol::PluginScope; use codex_utils::password::hash_password; @@ -117,10 +118,14 @@ pub struct SeedLibraryConfig { pub allowed_formats: Option>, #[serde(default)] pub excluded_patterns: Option>, + /// Title preprocessing rules, written as native YAML. Serialized to the JSON + /// string the `libraries` table stores at seed time. #[serde(default)] - pub title_preprocessing_rules: Option, + pub title_preprocessing_rules: Option>, + /// Auto-match conditions, written as native YAML. Serialized to the JSON + /// string the `libraries` table stores at seed time. #[serde(default)] - pub auto_match_conditions: Option, + pub auto_match_conditions: Option, } impl SeedConfig { @@ -448,8 +453,24 @@ async fn seed_libraries( .as_ref() .map(|v| serde_json::to_string(v).unwrap_or_default()); params.excluded_patterns = lib_cfg.excluded_patterns.as_ref().map(|v| v.join("\n")); - params.title_preprocessing_rules = lib_cfg.title_preprocessing_rules.clone(); - params.auto_match_conditions = lib_cfg.auto_match_conditions.clone(); + params.title_preprocessing_rules = lib_cfg + .title_preprocessing_rules + .as_ref() + .map(serde_json::to_string) + .transpose() + .context(format!( + "Failed to serialize title_preprocessing_rules for library '{}'", + lib_cfg.name + ))?; + params.auto_match_conditions = lib_cfg + .auto_match_conditions + .as_ref() + .map(serde_json::to_string) + .transpose() + .context(format!( + "Failed to serialize auto_match_conditions for library '{}'", + lib_cfg.name + ))?; LibraryRepository::create_with_params(db_conn, params) .await @@ -718,6 +739,64 @@ libraries: assert_eq!(series_cfg["series_mode"], "from_metadata"); } + #[test] + fn test_seed_library_preprocessing_and_conditions_parsing() { + use codex_models::preprocessing::{ConditionMode, ConditionOperator}; + + // Single-quoted YAML scalars keep regex backslashes literal — no + // JSON-style double-escaping required. + let yaml = r#" +libraries: + - name: Comics + path: /libraries/comics + title_preprocessing_rules: + - pattern: '\s*\(Digital\)$' + replacement: '' + description: Remove (Digital) suffix + enabled: true + - pattern: '_' + replacement: ' ' + auto_match_conditions: + mode: any + rules: + - field: external_ids.plugin:mangabaka + operator: is_null + - field: book_count + operator: gte + value: 1 +"#; + let config: SeedConfig = serde_yaml::from_str(yaml).unwrap(); + let lib = &config.libraries[0]; + + // Preprocessing rules deserialize as native structs. + let rules = lib.title_preprocessing_rules.as_ref().unwrap(); + assert_eq!(rules.len(), 2); + assert_eq!(rules[0].pattern, r"\s*\(Digital\)$"); + assert_eq!(rules[0].replacement, ""); + assert_eq!( + rules[0].description.as_deref(), + Some("Remove (Digital) suffix") + ); + assert!(rules[0].enabled); + // `enabled` defaults to true when omitted. + assert!(rules[1].enabled); + + // Auto-match conditions deserialize as native structs. + let conditions = lib.auto_match_conditions.as_ref().unwrap(); + assert_eq!(conditions.mode, ConditionMode::Any); + assert_eq!(conditions.rules.len(), 2); + assert_eq!(conditions.rules[0].operator, ConditionOperator::IsNull); + assert_eq!(conditions.rules[1].operator, ConditionOperator::Gte); + + // The seed loop serializes these to the JSON strings the libraries table + // stores; the regex round-trips through JSON unchanged. + let rules_json = serde_json::to_string(rules).unwrap(); + assert!(rules_json.contains(r"\\s*\\(Digital\\)$")); + let parsed = + codex_models::preprocessing::parse_preprocessing_rules(Some(&rules_json)).unwrap(); + assert_eq!(parsed, *rules); + } + #[test] fn test_seed_config_parsing_empty() { let yaml = "{}"; diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 8cd97da7..9a613c8d 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -515,11 +515,15 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { thumbnail_service, file_cleanup_service, task_metrics_service: Some(task_metrics_service), - scheduler: if disable_workers { - None - } else { - Some(scheduler.clone()) - }, + // The scheduler runs in every `serve` process (started unconditionally + // above), independent of whether task workers run here. The API needs a + // handle to it so schedule edits (plugin sync cron, library scans, + // release sources) take effect live via `reload_schedules()`. Gating + // this on `disable_workers` previously made those reloads silent no-ops + // in the standard split deployment (web pod with CODEX_DISABLE_WORKERS), + // so an admin-set cron was written to the DB but never registered until + // the pod restarted. + scheduler: Some(scheduler.clone()), read_progress_service, auth_tracking_service, pdf_page_cache, diff --git a/src/commands/worker.rs b/src/commands/worker.rs index 8a9c2bb9..8b31f27e 100644 --- a/src/commands/worker.rs +++ b/src/commands/worker.rs @@ -134,11 +134,19 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { // set up by `TaskWorker::run_task`, not through a manager-held one. // See `codex_events::with_recording_broadcaster`. info!("Initializing plugin manager..."); + // Wire plugin file storage so plugins spawned in the worker (sync cron, + // recommendations refresh, …) receive a real `dataDir` in their init + // message. Without it, `resolve_plugin_data_dir` returns None and plugins + // fall back to a container-local temp dir for any files they write. + let plugin_file_storage = Arc::new(codex_services::PluginFileStorage::new( + &config.files.plugins_dir, + )); let plugin_manager = Arc::new( codex_services::plugin::PluginManager::with_defaults(Arc::new( db.sea_orm_connection().clone(), )) - .with_metrics_service(plugin_metrics_service), + .with_metrics_service(plugin_metrics_service) + .with_plugin_file_storage(plugin_file_storage), ); // Load enabled plugins from database match plugin_manager.load_all().await { 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..2bc312f9 100644 --- a/tests/api/user_plugins.rs +++ b/tests/api/user_plugins.rs @@ -141,7 +141,9 @@ async fn create_sync_plugin( .await .unwrap(); - // Set manifest with sync provider capability + // Set manifest with sync provider capability. Declares OAuth so the plugin + // requires per-user auth (a sync plugin like AniList does) — connections are + // "not connected" until tokens are set, which the sync/status tests rely on. let manifest = json!({ "name": name, "displayName": display_name, @@ -151,6 +153,11 @@ async fn create_sync_plugin( "capabilities": { "userReadSync": true }, + "oauth": { + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token", + "scopes": [] + }, "userDescription": "Sync reading progress" }); PluginsRepository::update_manifest(db, plugin.id, Some(manifest)) @@ -160,6 +167,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( @@ -1323,7 +1356,7 @@ async fn test_get_plugin_tasks_no_tasks() { let (status, _): (StatusCode, Option) = make_json_request(app, request).await; assert_eq!(status, StatusCode::OK); - // No sync tasks exist yet — should return 404 + // No sync tasks exist yet — should return 200 with a null body (not 404). let app = create_test_router(state.clone()).await; let request = get_request_with_auth( &format!( @@ -1332,10 +1365,14 @@ async fn test_get_plugin_tasks_no_tasks() { ), &token, ); - let (status, _): (StatusCode, Option) = + let (status, body): (StatusCode, Option) = make_json_request(app, request).await; - assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(status, StatusCode::OK); + assert!( + body.is_none() || body == Some(serde_json::Value::Null), + "expected null/absent task body, got {body:?}" + ); } #[tokio::test] @@ -1457,7 +1494,8 @@ async fn test_get_plugin_tasks_user_isolation() { make_json_request(app, request).await; assert_eq!(status, StatusCode::OK); - // User B enables plugin but has no task — should get 404 (not User A's task) + // User B enables plugin but has no task — should get 200 with a null body + // (User B must not see User A's task). let app = create_test_router(state.clone()).await; let request = post_request_with_auth( &format!("/api/v1/user/plugins/{}/enable", plugin_id), @@ -1474,9 +1512,13 @@ async fn test_get_plugin_tasks_user_isolation() { ), &token_b, ); - let (status, _): (StatusCode, Option) = + let (status, body): (StatusCode, Option) = make_json_request(app, request).await; - assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(status, StatusCode::OK); + assert!( + body.is_none() || body == Some(serde_json::Value::Null), + "User B must not see User A's task; got {body:?}" + ); } #[tokio::test] @@ -1538,3 +1580,240 @@ 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); +} + +#[tokio::test] +async fn test_set_metadata_settings_custom_opt_out_round_trips() { + 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; + + // Opt into sharing custom metadata. + 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!({ "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_custom_metadata); + assert_eq!(dto.config["_codex"]["sendCustomMetadata"], 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/{}/metadata-settings", plugin_id), + &json!({ "sendCustomMetadata": false }), + &token, + ); + let (status, dto): (StatusCode, Option) = make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + assert!(!dto.expect("metadata-settings body").send_custom_metadata); +} + +#[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!({ "sendCustomMetadata": 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 the custom-metadata opt-out; 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!({ "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_custom_metadata); + assert!(dto.auto_sync, "autoSync sibling must be preserved"); + assert_eq!(dto.config["_codex"]["autoSync"], json!(true)); +} diff --git a/tests/scanner/task_queue_integration.rs b/tests/scanner/task_queue_integration.rs index 4119dc17..d870243d 100644 --- a/tests/scanner/task_queue_integration.rs +++ b/tests/scanner/task_queue_integration.rs @@ -80,8 +80,8 @@ async fn test_normal_scan_queues_unanalyzed_books() { ); assert_eq!(task.status, "pending"); assert_eq!( - task.priority, 800, - "AnalyzeBook default priority should be 800" + task.priority, 500, + "AnalyzeBook default priority should be 500" ); } } @@ -422,8 +422,8 @@ async fn test_queued_tasks_have_zero_priority() { let tasks = get_all_tasks(&db).await; assert_eq!(tasks.len(), 1); assert_eq!( - tasks[0].priority, 1000, - "ScanLibrary tasks should have default priority 1000" + tasks[0].priority, 600, + "ScanLibrary tasks should have default priority 600" ); } 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, diff --git a/tests/task_priority_ordering/mod.rs b/tests/task_priority_ordering/mod.rs index 94fceebb..555041b2 100644 --- a/tests/task_priority_ordering/mod.rs +++ b/tests/task_priority_ordering/mod.rs @@ -229,29 +229,29 @@ async fn test_task_type_priority_ordering() { // Now claim tasks one by one and verify the order let expected_order = vec![ - // Scanning (priority 1000-900) + // User plugin operations (priority 1000-960) + "user_plugin_recommendation_dismiss", + "user_plugin_sync", + "user_plugin_recommendations", + // Metadata fetch (priority 900-860) + "refresh_metadata", + "plugin_auto_match", + "find_duplicates", + // Scanning (priority 600-550) "scan_library", "purge_deleted", - // Analysis (priority 800-750) + // Analysis (priority 500-450) "analyze_book", "analyze_series", "reprocess_series_title", "reprocess_series_titles", "renumber_series", "renumber_series_batch", - // Thumbnails (priority 600-570) + // Thumbnails (priority 400-370) "generate_thumbnail", "generate_series_thumbnail", "generate_thumbnails", "generate_series_thumbnails", - // Metadata (priority 400-380) - "find_duplicates", - "refresh_metadata", - "plugin_auto_match", - // Plugins (priority 200-180) - "user_plugin_recommendation_dismiss", - "user_plugin_sync", - "user_plugin_recommendations", // Cleanup (priority 100, FIFO by scheduled_for) "cleanup_plugin_data", "cleanup_pdf_cache", @@ -444,34 +444,34 @@ async fn test_default_priority_ordering_across_types() { .await .unwrap(); - // Scan should come first (1000), then analyze (800), then thumbnail (600), - // then find_duplicates (400), then cleanup (100) + // find_duplicates comes first (860), then scan (600), then analyze (500), + // then thumbnail (400), then cleanup (100) let task1 = TaskRepository::claim_next(&db, "test-worker", 300) .await .unwrap() .unwrap(); - assert_eq!(task1.task_type, "scan_library"); - assert_eq!(task1.priority, 1000); + assert_eq!(task1.task_type, "find_duplicates"); + assert_eq!(task1.priority, 860); let task2 = TaskRepository::claim_next(&db, "test-worker", 300) .await .unwrap() .unwrap(); - assert_eq!(task2.task_type, "analyze_book"); - assert_eq!(task2.priority, 800); + assert_eq!(task2.task_type, "scan_library"); + assert_eq!(task2.priority, 600); let task3 = TaskRepository::claim_next(&db, "test-worker", 300) .await .unwrap() .unwrap(); - assert_eq!(task3.task_type, "generate_thumbnail"); - assert_eq!(task3.priority, 600); + assert_eq!(task3.task_type, "analyze_book"); + assert_eq!(task3.priority, 500); let task4 = TaskRepository::claim_next(&db, "test-worker", 300) .await .unwrap() .unwrap(); - assert_eq!(task4.task_type, "find_duplicates"); + assert_eq!(task4.task_type, "generate_thumbnail"); assert_eq!(task4.priority, 400); let task5 = TaskRepository::claim_next(&db, "test-worker", 300) diff --git a/tests/task_queue/mod.rs b/tests/task_queue/mod.rs index 545ca67b..2b931258 100644 --- a/tests/task_queue/mod.rs +++ b/tests/task_queue/mod.rs @@ -112,7 +112,7 @@ async fn test_enqueue_task() { assert_eq!(task.task_type, "scan_library"); assert_eq!(task.status, "pending"); - assert_eq!(task.priority, 1000); + assert_eq!(task.priority, 600); assert_eq!(task.attempts, 0); } @@ -936,7 +936,7 @@ async fn test_default_priority_scan_over_analysis() { .await .expect("Failed to enqueue scan task"); - // Should get scan task first (priority 1000 > 800) + // Should get scan task first (priority 600 > 500) let claimed1 = TaskRepository::claim_next(&db, "worker-1", 300) .await .expect("Failed to claim task") @@ -946,7 +946,7 @@ async fn test_default_priority_scan_over_analysis() { claimed1.task_type, "scan_library", "Scan task should be claimed first due to higher default priority" ); - assert_eq!(claimed1.priority, 1000); + assert_eq!(claimed1.priority, 600); TaskRepository::mark_completed(&db, claimed1.id, None) .await @@ -958,7 +958,7 @@ async fn test_default_priority_scan_over_analysis() { .expect("No task available"); assert_eq!(claimed2.task_type, "analyze_book"); - assert_eq!(claimed2.priority, 800); + assert_eq!(claimed2.priority, 500); } /// Test that enqueue_with_priority overrides default_priority @@ -1136,7 +1136,7 @@ async fn test_postgres_default_priority_ordering() { .await .expect("Failed to enqueue scan task"); - // Scan should come first (priority 1000 > 800) + // Scan should come first (priority 600 > 500) let claimed1 = TaskRepository::claim_next(&db, "worker-1", 300) .await .expect("Failed to claim task") @@ -1318,7 +1318,7 @@ async fn test_enqueue_find_duplicates_task() { assert_eq!(task.task_type, "find_duplicates"); assert_eq!(task.status, "pending"); - assert_eq!(task.priority, 400); + assert_eq!(task.priority, 860); assert_eq!(task.attempts, 0); assert!( task.library_id.is_none(), @@ -2088,6 +2088,116 @@ 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" + ); +} + +/// The partial unique index makes the (plugin_id, user_id) dedup atomic at the +/// DB level, not just via enqueue's check-then-insert. A second *raw* pending +/// `user_plugin_sync` row for the same connection must be rejected — this is the +/// guarantee that protects against two schedulers (or scheduled + manual) +/// racing the soft `has_pending_or_processing` check in a multi-replica deploy. +#[tokio::test] +async fn user_plugin_sync_unique_index_blocks_duplicate_pending() { + let (db, _temp_dir) = setup_test_db().await; + + let plugin_id = Uuid::new_v4(); + let user_id = Uuid::new_v4(); + + let row = |plugin_id: Uuid, user_id: Uuid| tasks::ActiveModel { + id: Set(Uuid::new_v4()), + task_type: Set("user_plugin_sync".to_string()), + status: Set("pending".to_string()), + params: Set(Some( + serde_json::json!({ "plugin_id": plugin_id, "user_id": user_id }), + )), + scheduled_for: Set(Utc::now()), + created_at: Set(Utc::now()), + ..Default::default() + }; + + row(plugin_id, user_id) + .insert(&db) + .await + .expect("first pending row inserts"); + + let dup = row(plugin_id, user_id).insert(&db).await; + assert!( + dup.is_err(), + "a second pending row for the same (plugin, user) must violate the unique index" + ); + + // A different user of the same plugin is unaffected. + row(plugin_id, Uuid::new_v4()) + .insert(&db) + .await + .expect("a different user's pending row is still allowed"); +} + /// Test has_pending_or_processing with recommendations task type #[tokio::test] async fn test_find_duplicates_handler_reads_trusted_sources_setting() { diff --git a/tests/task_queue_e2e/mod.rs b/tests/task_queue_e2e/mod.rs index 6097161b..e45265ef 100644 --- a/tests/task_queue_e2e/mod.rs +++ b/tests/task_queue_e2e/mod.rs @@ -339,7 +339,7 @@ async fn test_worker_default_priority_ordering() { .await .expect("Failed to enqueue cleanup task"); - // Enqueue a find_duplicates task (priority 400) second + // Enqueue a find_duplicates task (priority 860) second let dup_id = TaskRepository::enqueue(&db, TaskType::FindDuplicates, None) .await .expect("Failed to enqueue find_duplicates task"); @@ -350,13 +350,13 @@ async fn test_worker_default_priority_ordering() { .expect("Failed to claim") .expect("No task available"); - // FindDuplicates should be claimed first (priority 400 > 100) + // FindDuplicates should be claimed first (priority 860 > 100) assert_eq!( claimed.id, dup_id, "Higher priority task should be claimed first" ); assert_eq!(claimed.task_type, "find_duplicates"); - assert_eq!(claimed.priority, 400); + assert_eq!(claimed.priority, 860); // Verify cleanup task is still pending let cleanup_task = TaskRepository::get_by_id(&db, cleanup_id) diff --git a/web/openapi.json b/web/openapi.json index 8cec9b2f..54c5f8c6 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": [ @@ -15354,6 +15407,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": [ @@ -15437,11 +15543,18 @@ ], "responses": { "200": { - "description": "Latest task found", + "description": "Latest task, or null if the plugin has no task yet", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserPluginTaskDto" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UserPluginTaskDto" + } + ] } } } @@ -15450,7 +15563,7 @@ "description": "Not authenticated" }, "404": { - "description": "No tasks found for this plugin" + "description": "Plugin not enabled for this user" } }, "security": [ @@ -33848,6 +33961,14 @@ "userRecommendationProvider": { "type": "boolean", "description": "Can provide personalized recommendations" + }, + "wantsDetailedProgress": { + "type": "boolean", + "description": "Whether the plugin consumes the per-book reading-progress breakdown on the\nsync entries it receives. Only meaningful when `user_read_sync` is true." + }, + "wantsFullMetadata": { + "type": "boolean", + "description": "Whether the plugin consumes enriched series data (bibliographic metadata,\ncustom metadata) on the sync/recommendation entries it receives." } } }, @@ -34117,6 +34238,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", @@ -38702,6 +38831,19 @@ } } }, + "SetMetadataSettingsRequest": { + "type": "object", + "description": "Request to set a connection's metadata-enrichment opt-ins (user-controlled).\n\nOnly `sendCustomMetadata` is a per-user choice (a privacy opt-out for the\nuser's custom fields). Whether tags/genres/the bibliographic block are sent is\nadmin policy on the plugin, not set here. Other `_codex` settings are\npreserved (partial update). Only meaningful for `wantsFullMetadata` plugins.", + "properties": { + "sendCustomMetadata": { + "type": [ + "boolean", + "null" + ], + "description": "Send user-defined custom metadata to the plugin." + } + } + }, "SetPreferenceRequest": { "type": "object", "description": "Request to set a single preference value", @@ -38799,6 +38941,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 +41948,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", @@ -42260,7 +42418,9 @@ "description": "Plugin capabilities for display (user plugin context)", "required": [ "readSync", - "userRecommendationProvider" + "userRecommendationProvider", + "wantsFullMetadata", + "wantsDetailedProgress" ], "properties": { "readSync": { @@ -42270,6 +42430,14 @@ "userRecommendationProvider": { "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." } } }, @@ -42284,14 +42452,21 @@ "pluginType", "enabled", "connected", + "requiresAuth", "healthStatus", "requiresOauth", "oauthConfigured", "config", + "autoSync", + "sendCustomMetadata", "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)" @@ -42301,7 +42476,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", @@ -42382,10 +42557,25 @@ "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" }, + "sendCustomMetadata": { + "type": "boolean", + "description": "User privacy opt-out for sending user-defined custom metadata (host-side,\nfrom `config._codex.sendCustomMetadata`). Default false. tags/genres/the\nbibliographic block are admin policy on the plugin, not user-controlled." + }, + "syncCronSchedule": { + "type": [ + "string", + "null" + ], + "description": "The admin-configured cron schedule that drives automatic syncs for this\nplugin (the normalized 6-field form), or `None` when the admin has not\nset one. Surfaced read-only so the UI can show the cadence to users and\nindicate when auto sync isn't set up yet. The cadence is plugin-wide, not\nper-user." + }, "userConfigSchema": { "oneOf": [ { diff --git a/web/src/api/userPlugins.ts b/web/src/api/userPlugins.ts index 4a03eb66..4e8d1ee9 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) @@ -137,16 +154,17 @@ export const userPluginsApi = { /** * Get the latest task for a plugin (user-scoped, no TasksRead permission needed) - * Pass taskType to filter by type (e.g., "user_plugin_sync") + * Pass taskType to filter by type (e.g., "user_plugin_sync"). + * Returns null when the plugin has no matching task yet (a normal state). */ getPluginTask: async ( pluginId: string, taskType?: string, - ): Promise => { - const response = await api.get( + ): Promise => { + const response = await api.get( `/user/plugins/${pluginId}/tasks`, { params: taskType ? { type: taskType } : undefined }, ); - return response.data; + return response.data ?? null; }, }; diff --git a/web/src/components/forms/CronInput.test.tsx b/web/src/components/forms/CronInput.test.tsx index 5939ad91..dcbf794c 100644 --- a/web/src/components/forms/CronInput.test.tsx +++ b/web/src/components/forms/CronInput.test.tsx @@ -2,7 +2,25 @@ import { screen, waitFor } from "@testing-library/react"; import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders, userEvent } from "@/test/utils"; -import { CronInput } from "./CronInput"; +import { CronInput, describeCron } from "./CronInput"; + +describe("describeCron", () => { + it("describes a 6-field (seconds-precision) expression", () => { + expect(describeCron("0 */5 * * * *")).toMatch(/every 5 minutes/i); + }); + + it("describes a standard 5-field expression", () => { + expect(describeCron("0 0 * * *")).toMatch(/12:00 am/i); + }); + + it("returns null for empty, nullish, or invalid input", () => { + expect(describeCron("")).toBeNull(); + expect(describeCron(" ")).toBeNull(); + expect(describeCron(null)).toBeNull(); + expect(describeCron(undefined)).toBeNull(); + expect(describeCron("not a cron")).toBeNull(); + }); +}); describe("CronInput", () => { const mockOnChange = vi.fn(); diff --git a/web/src/components/forms/CronInput.tsx b/web/src/components/forms/CronInput.tsx index 78fcdb18..decc7366 100644 --- a/web/src/components/forms/CronInput.tsx +++ b/web/src/components/forms/CronInput.tsx @@ -8,7 +8,8 @@ import { useMemo } from "react"; export interface CronInputProps extends Omit { - value: string; + /** Current cron string. Optional so it composes with `form.getInputProps`. */ + value?: string; onChange: (value: string) => void; showNextRun?: boolean; } @@ -75,8 +76,26 @@ function getNextRunTime(expression: string): Date | null { } } +// Concise human-readable description of a cron expression for read-only display +// (e.g. showing users the admin-set sync cadence). Tolerant of the 6-field +// (seconds-precision) form the backend stores, and of empty/invalid input, +// returning `null` so callers can fall back to a "not set up" message. +export function describeCron( + expression: string | null | undefined, +): string | null { + if (!expression?.trim()) return null; + try { + return cronToString(expression.trim(), { + throwExceptionOnParseError: true, + verbose: false, + }); + } catch { + return null; + } +} + export function CronInput({ - value, + value = "", onChange, showNextRun = true, error, diff --git a/web/src/components/forms/PluginConfigModal.test.tsx b/web/src/components/forms/PluginConfigModal.test.tsx index 10b3cb41..690bc29d 100644 --- a/web/src/components/forms/PluginConfigModal.test.tsx +++ b/web/src/components/forms/PluginConfigModal.test.tsx @@ -189,6 +189,173 @@ describe("PluginConfigModal", () => { ).not.toBeInTheDocument(); }); + it("shows admin metadata-enrichment toggles for a wantsFullMetadata sync plugin", () => { + const plugin = createMockPlugin({ + manifest: { + name: "sync-plugin", + displayName: "Sync Plugin", + version: "1.0.0", + protocolVersion: "1.0", + description: "A sync plugin", + capabilities: { + metadataProvider: [], + userReadSync: true, + wantsFullMetadata: true, + }, + contentTypes: [], + requiredCredentials: [], + scopes: [], + }, + }); + + renderWithProviders( + , + ); + + // Permissions tab is the default for sync plugins; the admin metadata policy + // lives there (default on). + 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("Allow custom metadata")).toBeInTheDocument(); + }); + + it("shows the full metadata policy for a recommendation plugin with wantsFullMetadata", () => { + const plugin = createMockPlugin({ + manifest: { + name: "rec-plugin", + displayName: "Rec Plugin", + version: "1.0.0", + protocolVersion: "1.0", + description: "A recommendation plugin", + capabilities: { + metadataProvider: [], + userRecommendationProvider: true, + wantsFullMetadata: true, + }, + contentTypes: [], + requiredCredentials: [], + scopes: [], + }, + }); + + renderWithProviders( + , + ); + + // Recommendation plugins get the full policy too (tags/genres default on, + // so an admin can turn them off to trim payload). + 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("Allow custom metadata")).toBeInTheDocument(); + }); + + it("shows the automatic-sync cron field for a sync plugin", () => { + const plugin = createMockPlugin({ + manifest: { + name: "sync-plugin", + displayName: "Sync Plugin", + version: "1.0.0", + protocolVersion: "1.0", + description: "A sync plugin", + capabilities: { + metadataProvider: [], + userReadSync: true, + }, + contentTypes: [], + requiredCredentials: [], + scopes: [], + }, + syncCronSchedule: "0 */6 * * *", + }); + + renderWithProviders( + , + ); + + // Relocated from the Edit modal into the Configure dialog's Permissions tab. + expect(screen.getByText("Automatic Sync")).toBeInTheDocument(); + expect(screen.getByText("Sync Schedule (cron)")).toBeInTheDocument(); + }); + + it("hides the automatic-sync cron field for a non-sync plugin", () => { + const plugin = createMockPlugin({ + manifest: { + name: "metadata-plugin", + displayName: "Metadata Plugin", + version: "1.0.0", + protocolVersion: "1.0", + description: "A metadata plugin", + capabilities: { + metadataProvider: ["series"], + userReadSync: false, + }, + contentTypes: ["series"], + requiredCredentials: [], + scopes: ["series:detail"], + }, + }); + + renderWithProviders( + , + ); + + expect(screen.queryByText("Automatic Sync")).not.toBeInTheDocument(); + }); + + it("hides admin metadata-enrichment toggles when the plugin lacks wantsFullMetadata", () => { + const plugin = createMockPlugin({ + manifest: { + name: "sync-plugin", + displayName: "Sync Plugin", + version: "1.0.0", + protocolVersion: "1.0", + description: "A sync plugin", + capabilities: { + metadataProvider: [], + userReadSync: true, + }, + contentTypes: [], + requiredCredentials: [], + scopes: [], + }, + }); + + renderWithProviders( + , + ); + + expect(screen.queryByText("Metadata Enrichment")).not.toBeInTheDocument(); + }); + it("hides search tabs for plugins with no manifest", () => { const plugin = createMockPlugin({ manifest: null }); diff --git a/web/src/components/forms/PluginConfigModal.tsx b/web/src/components/forms/PluginConfigModal.tsx index e479630c..51edc2d6 100644 --- a/web/src/components/forms/PluginConfigModal.tsx +++ b/web/src/components/forms/PluginConfigModal.tsx @@ -44,6 +44,9 @@ function PluginConfigContent({ const queryClient = useQueryClient(); const isMeta = isMetadataProvider(plugin); const isOAuth = isOAuthPlugin(plugin); + const wantsFullMetadata = + plugin.manifest?.capabilities?.wantsFullMetadata === true; + const isSync = plugin.manifest?.capabilities?.userReadSync === true; const [activeTab, setActiveTab] = useState( isMeta ? "general" : "permissions", ); @@ -86,6 +89,12 @@ function PluginConfigContent({ // Extract OAuth config from plugin.config JSON const pluginConfig = plugin.config as Record | null; + // Admin metadata policy lives in config._codex.send* (host-only). Default on. + const codexConfig = (pluginConfig?._codex ?? {}) as Record; + const codexBool = (key: string) => + typeof codexConfig[key] === "boolean" + ? (codexConfig[key] as boolean) + : true; const initialOAuthClientId = typeof pluginConfig?.oauth_client_id === "string" ? pluginConfig.oauth_client_id @@ -108,6 +117,11 @@ function PluginConfigContent({ metadataTargets: initialMetadataTargets, oauthClientId: initialOAuthClientId, oauthClientSecret: initialOAuthClientSecret, + sendTags: codexBool("sendTags"), + sendGenres: codexBool("sendGenres"), + sendMetadata: codexBool("sendMetadata"), + allowCustomMetadata: codexBool("allowCustomMetadata"), + syncCronSchedule: plugin.syncCronSchedule ?? "", }, }); @@ -119,6 +133,12 @@ function PluginConfigContent({ libraryIds: form.values.allLibraries ? [] : form.values.libraryIds, }; + // Admin automatic-sync cadence (sync plugins only). Empty clears it; the + // backend rejects a cron on non-sync plugins. + if (isSync) { + payload.syncCronSchedule = form.values.syncCronSchedule.trim() || null; + } + if (isMeta) { payload.searchQueryTemplate = form.values.searchQueryTemplate.trim() || null; @@ -131,19 +151,37 @@ function PluginConfigContent({ }; } - if (isOAuth) { + // OAuth secrets and the admin metadata policy both live in plugin.config; + // read-modify-write a single object so neither clobbers the other. + if (isOAuth || wantsFullMetadata) { const existingConfig = (plugin.config as Record) ?? {}; const config: Record = { ...existingConfig }; - if (form.values.oauthClientId.trim()) { - config.oauth_client_id = form.values.oauthClientId.trim(); - } else { - delete config.oauth_client_id; + + if (isOAuth) { + if (form.values.oauthClientId.trim()) { + config.oauth_client_id = form.values.oauthClientId.trim(); + } else { + delete config.oauth_client_id; + } + if (form.values.oauthClientSecret.trim()) { + config.oauth_client_secret = form.values.oauthClientSecret.trim(); + } else { + delete config.oauth_client_secret; + } } - if (form.values.oauthClientSecret.trim()) { - config.oauth_client_secret = form.values.oauthClientSecret.trim(); - } else { - delete config.oauth_client_secret; + + if (wantsFullMetadata) { + // Host-only _codex namespace: what the host sends to this plugin. + const codex = { + ...((existingConfig._codex as Record) ?? {}), + sendTags: form.values.sendTags, + sendGenres: form.values.sendGenres, + sendMetadata: form.values.sendMetadata, + allowCustomMetadata: form.values.allowCustomMetadata, + }; + config._codex = codex; } + payload.config = config; } diff --git a/web/src/components/forms/plugin-config/PermissionsTab.tsx b/web/src/components/forms/plugin-config/PermissionsTab.tsx index dcdac398..0f111030 100644 --- a/web/src/components/forms/plugin-config/PermissionsTab.tsx +++ b/web/src/components/forms/plugin-config/PermissionsTab.tsx @@ -8,6 +8,7 @@ import { } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; import type { PluginDto } from "@/api/plugins"; +import { CronInput } from "@/components/forms/CronInput"; import { getPermissionData, getScopeData, @@ -59,6 +60,72 @@ function LibraryFilter({ ); } +/** Admin metadata-enrichment policy — which series data the host sends to a + * `wantsFullMetadata` plugin. On by default; renders nothing for plugins that + * don't declare the capability. Applies to both sync and recommendation + * plugins. */ +function MetadataPolicy({ + plugin, + form, +}: { + plugin: PluginDto; + form: PluginConfigForm; +}) { + if (plugin.manifest?.capabilities?.wantsFullMetadata !== true) return null; + return ( + <> + + + Which series data the host sends to this plugin. On by default; turn off + to reduce payload — summaries are the heaviest. + + + + + + + ); +} + +/** Admin automatic-sync cadence — renders for sync plugins only. Empty disables + * scheduled syncs; users can still sync manually. */ +function SyncSchedule({ + plugin, + form, +}: { + plugin: PluginDto; + form: PluginConfigForm; +}) { + if (!isSyncProvider(plugin)) return null; + return ( + <> + + + + ); +} + export function PermissionsTab({ plugin, form, @@ -113,6 +180,8 @@ export function PermissionsTab({ + + ); } @@ -154,6 +223,8 @@ export function PermissionsTab({ /> + + ); } diff --git a/web/src/components/forms/plugin-config/types.ts b/web/src/components/forms/plugin-config/types.ts index 4517e115..d4cd42b2 100644 --- a/web/src/components/forms/plugin-config/types.ts +++ b/web/src/components/forms/plugin-config/types.ts @@ -99,6 +99,17 @@ export interface PluginConfigFormValues { // OAuth config (OAuth plugins only) oauthClientId: string; oauthClientSecret: string; + // Admin metadata-enrichment policy (wantsFullMetadata plugins only). + // Whether the host sends each field to the plugin; default on. Stored in + // config._codex.send*. + sendTags: boolean; + sendGenres: boolean; + sendMetadata: boolean; + // Admin gate for custom metadata (default on). Users still opt in per + // connection; this lets an admin forbid it entirely. config._codex.allowCustomMetadata. + allowCustomMetadata: boolean; + // Admin automatic-sync cadence (sync plugins only). Cron string; empty = off. + syncCronSchedule: string; } export type PluginConfigForm = UseFormReturnType; diff --git a/web/src/components/plugins/UserPluginCard.test.tsx b/web/src/components/plugins/UserPluginCard.test.tsx index b636ce92..54235d56 100644 --- a/web/src/components/plugins/UserPluginCard.test.tsx +++ b/web/src/components/plugins/UserPluginCard.test.tsx @@ -26,9 +26,12 @@ 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: {}, + autoSync: false, + syncCronSchedule: "0 0 */6 * * *", capabilities: { readSync: true, userRecommendationProvider: false }, createdAt: new Date().toISOString(), } as UserPluginDto; @@ -43,6 +46,7 @@ const enabledNotConnected: UserPluginDto = { connected: false, healthStatus: "unknown", requiresOauth: true, + requiresAuth: true, oauthConfigured: true, description: "Sync with MyAnimeList", config: {}, @@ -62,6 +66,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", @@ -70,6 +75,28 @@ 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, + syncCronSchedule: "0 */5 * * * *", + capabilities: { readSync: true, userRecommendationProvider: false }, + createdAt: new Date().toISOString(), +} as UserPluginDto; + const availablePlugin: AvailablePluginDto = { pluginId: "plugin-3", name: "smart-recs", @@ -169,6 +196,86 @@ 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 the admin-set cadence when a sync schedule is configured", () => { + renderWithProviders( + , + ); + // "0 0 */6 * * *" → cronstrue: "Every 6 hours" + expect(screen.getByText(/every 6 hours/i)).toBeInTheDocument(); + expect( + screen.getByRole("switch", { name: /automatic sync/i }), + ).toBeEnabled(); + }); + + it("shows a not-set-up message and disables the switch when no cron is set", () => { + renderWithProviders( + , + ); + expect( + screen.getByText(/not set up by your administrator yet/i), + ).toBeInTheDocument(); + expect( + screen.getByRole("switch", { name: /automatic sync/i }), + ).toBeDisabled(); + }); + it("shows Sync Now button when connected with onSync handler", () => { const onSync = vi.fn(); renderWithProviders( @@ -485,6 +592,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 b4218f69..8a43bc4f 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"; @@ -31,6 +32,7 @@ import type { SyncStatusDto, UserPluginDto, } from "@/api/userPlugins"; +import { describeCron } from "@/components/forms/CronInput"; // ============================================================================= // Helpers @@ -109,12 +111,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,22 +130,32 @@ export function ConnectedPluginCard({ onConnect, onSaveToken, onRefreshStatus, + onSetSyncMode, syncStatus, disconnecting, disabling, syncing, savingToken, refreshingStatus, + settingSyncMode, }: ConnectedPluginCardProps) { const health = healthBadge(plugin.healthStatus); const [tokenValue, setTokenValue] = useState(""); const showTokenInput = !plugin.connected && plugin.requiresOauth; const isSyncPlugin = plugin.capabilities?.readSync === true; + // Human-readable cadence of the admin-set sync cron, or null when no schedule + // is configured (in which case auto sync can't run and the toggle is disabled). + const autoSyncCadence = describeCron(plugin.syncCronSchedule); const isRecommendationPlugin = plugin.capabilities?.userRecommendationProvider === true; 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 ( @@ -166,7 +180,7 @@ export function ConnectedPluginCard({ - {plugin.connected ? ( + {connectedViaAuth ? ( Connected - ) : plugin.requiresOauth ? ( + ) : !plugin.requiresAuth ? ( } + leftSection={} > - Not Connected + Enabled ) : ( } + leftSection={} > - Enabled + Not Connected )} {plugin.connected && ( @@ -268,6 +282,31 @@ export function ConnectedPluginCard({ )} + {plugin.connected && isSyncPlugin && onSetSyncMode && ( + +
+ Automatic sync + {autoSyncCadence ? ( + + {autoSyncCadence} · schedule set by your administrator + + ) : ( + + Not set up by your administrator yet + + )} +
+ + onSetSyncMode(plugin.pluginId, e.currentTarget.checked) + } + disabled={settingSyncMode || !autoSyncCadence} + aria-label="Automatic sync" + /> +
+ )} + {plugin.connected && isRecommendationPlugin && ( @@ -372,7 +411,7 @@ export function ConnectedPluginCard({ Settings )} - {plugin.connected && ( + {connectedViaAuth && (