diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9864a7f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + - run: cargo test + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: frontend/.node-version + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm test diff --git a/.gitignore b/.gitignore index 6b7adc8..0698a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ frontend/dist/ .DS_Store tmp/ -.claude \ No newline at end of file +.claude + +# Local screenshot mocks — never commit +/*.png \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 8a138c3..454ea5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,7 @@ cd backend && cargo clippy # Frontend cd frontend && npm install cd frontend && npm run build +cd frontend && npm run test cd frontend && npm run dev # Docker diff --git a/README.fr.md b/README.fr.md index c32c1db..5661466 100644 --- a/README.fr.md +++ b/README.fr.md @@ -128,6 +128,20 @@ npm run dev Le serveur de développement Vite démarre sur `http://localhost:5173` et proxifie `/api/*` vers le backend. +## Tests + +Les tests backend sont dans le module Rust qu'ils couvrent (`#[cfg(test)]`). Les tests frontend sont des fichiers Vitest à côté du TypeScript concerné (`*.test.ts` / `*.test.tsx`). + +```bash +# Backend — tests unitaires, sans base de données +cd backend && cargo test + +# Frontend +cd frontend && npm test +``` + +La CI lance les deux à chaque push et pull request (`.github/workflows/test.yml`). + ## Variables d'environnement ### Principales @@ -262,11 +276,13 @@ rclone-replication-ui/ ```bash # Backend cd backend && cargo build # Compiler +cd backend && cargo test # Tests cd backend && cargo clippy # Lint cd backend && cargo fmt # Formater # Frontend cd frontend && npm run build # Build production +cd frontend && npm run test # Tests cd frontend && npm run dev # Serveur de développement cd frontend && npm run lint # Lint diff --git a/README.md b/README.md index cca8484..1ca7003 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,20 @@ npm run dev The Vite dev server starts on `http://localhost:5173` and proxies `/api/*` to the backend. +## Tests + +Backend tests live next to the Rust they cover (`#[cfg(test)]` modules). Frontend tests are Vitest files next to the TypeScript they cover (`*.test.ts` / `*.test.tsx`). + +```bash +# Backend — unit tests, no database +cd backend && cargo test + +# Frontend +cd frontend && npm test +``` + +CI runs both on every push and pull request (`.github/workflows/test.yml`). + ## Environment variables ### Core @@ -262,11 +276,13 @@ rclone-replication-ui/ ```bash # Backend cd backend && cargo build # Build +cd backend && cargo test # Tests cd backend && cargo clippy # Lint cd backend && cargo fmt # Format # Frontend cd frontend && npm run build # Production build +cd frontend && npm run test # Tests cd frontend && npm run dev # Dev server cd frontend && npm run lint # Lint diff --git a/backend/Dockerfile b/backend/Dockerfile index 080acc6..fb8572a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,6 +13,7 @@ RUN mkdir src && echo "fn main() {}" > src/main.rs \ # Compilation de l'application COPY src ./src +COPY locales ./locales RUN touch src/main.rs && cargo build --release # ─── Rclone stage ──────────────────────────────────────────────────────────── diff --git a/backend/locales/en/notifications.json b/backend/locales/en/notifications.json new file mode 100644 index 0000000..83b1754 --- /dev/null +++ b/backend/locales/en/notifications.json @@ -0,0 +1,19 @@ +{ + "reason": "The previous run is still in progress", + "error": { + "subject": "Replication task failed", + "body": "**Task**: `{task_id}`\n**Run**: `{run_id}`\n**Exit code**: `{exit_code}`\n\n**Logs**:\n```\n{logs}\n```" + }, + "success": { + "subject": "Replication task completed successfully", + "body": "**Task**: `{task_id}`\n**Run**: `{run_id}`\n\nThe synchronization completed successfully." + }, + "skipped": { + "subject": "Replication task skipped", + "body": "**Task**: `{task_id}`\n**Reason**: {reason}\n\nThe cron schedule tried to start this task, but the previous synchronization has not finished. The run was _skipped_." + }, + "test": { + "subject": "Test — rclone-ui", + "body": "This message confirms that the notification channel is **working correctly**.\n\n_Sent from rclone-ui._" + } +} diff --git a/backend/locales/fr/notifications.json b/backend/locales/fr/notifications.json new file mode 100644 index 0000000..0e43bbd --- /dev/null +++ b/backend/locales/fr/notifications.json @@ -0,0 +1,19 @@ +{ + "reason": "L'exécution précédente est encore en cours", + "error": { + "subject": "Échec de la tâche de réplication", + "body": "**Tâche** : `{task_id}`\n**Run** : `{run_id}`\n**Code de sortie** : `{exit_code}`\n\n**Logs** :\n```\n{logs}\n```" + }, + "success": { + "subject": "Tâche de réplication terminée avec succès", + "body": "**Tâche** : `{task_id}`\n**Run** : `{run_id}`\n\nLa synchronisation s'est terminée avec succès." + }, + "skipped": { + "subject": "Tâche de réplication ignorée", + "body": "**Tâche** : `{task_id}`\n**Raison** : L'exécution précédente est encore en cours\n\nLa planification cron a tenté de lancer cette tâche, mais la synchronisation précédente n'est pas terminée. L'exécution a été _ignorée_." + }, + "test": { + "subject": "Test — rclone-ui", + "body": "Ce message confirme que le canal de notification **fonctionne correctement**.\n\n_Envoyé depuis rclone-ui._" + } +} diff --git a/backend/locales/pt/notifications.json b/backend/locales/pt/notifications.json new file mode 100644 index 0000000..eae03b3 --- /dev/null +++ b/backend/locales/pt/notifications.json @@ -0,0 +1,19 @@ +{ + "reason": "A execução anterior ainda está em curso", + "error": { + "subject": "Falha da tarefa de replicação", + "body": "**Tarefa** : `{task_id}`\n**Execução** : `{run_id}`\n**Código de saída** : `{exit_code}`\n\n**Registos** :\n```\n{logs}\n```" + }, + "success": { + "subject": "Tarefa de replicação concluída com sucesso", + "body": "**Tarefa** : `{task_id}`\n**Execução** : `{run_id}`\n\nA sincronização terminou com sucesso." + }, + "skipped": { + "subject": "Tarefa de replicação ignorada", + "body": "**Tarefa** : `{task_id}`\n**Motivo** : {reason}\n\nO agendamento cron tentou iniciar esta tarefa, mas a sincronização anterior ainda não terminou. A execução foi _ignorada_." + }, + "test": { + "subject": "Teste — rclone-ui", + "body": "Esta mensagem confirma que o canal de notificação **funciona corretamente**.\n\n_Enviado a partir do rclone-ui._" + } +} diff --git a/backend/src/entities/notification_channel.rs b/backend/src/entities/notification_channel.rs index 5de9ff0..6a5d05d 100644 --- a/backend/src/entities/notification_channel.rs +++ b/backend/src/entities/notification_channel.rs @@ -9,10 +9,23 @@ pub struct Model { pub name: String, pub apprise_url: String, pub enabled: bool, + #[serde(default = "default_language")] + pub language: String, + #[sea_orm(column_type = "JsonBinary")] + #[serde(default = "default_templates")] + pub templates: serde_json::Value, pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, } +fn default_language() -> String { + "fr".to_string() +} + +fn default_templates() -> serde_json::Value { + serde_json::json!({}) +} + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm(has_many = "super::task::Entity")] diff --git a/backend/src/migration/m011_add_notification_channel_language.rs b/backend/src/migration/m011_add_notification_channel_language.rs new file mode 100644 index 0000000..edfd7ce --- /dev/null +++ b/backend/src/migration/m011_add_notification_channel_language.rs @@ -0,0 +1,33 @@ +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> { + let db = manager.get_connection(); + db.execute_unprepared( + "ALTER TABLE notification_channels \ + ADD COLUMN IF NOT EXISTS language TEXT NOT NULL DEFAULT 'fr'", + ) + .await?; + db.execute_unprepared( + "ALTER TABLE notification_channels \ + ADD COLUMN IF NOT EXISTS templates JSONB NOT NULL DEFAULT '{}'::jsonb", + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + db.execute_unprepared( + "ALTER TABLE notification_channels DROP COLUMN IF EXISTS templates", + ) + .await?; + db.execute_unprepared("ALTER TABLE notification_channels DROP COLUMN IF EXISTS language") + .await?; + Ok(()) + } +} diff --git a/backend/src/migration/mod.rs b/backend/src/migration/mod.rs index 5d3cbde..45b1b90 100644 --- a/backend/src/migration/mod.rs +++ b/backend/src/migration/mod.rs @@ -10,6 +10,7 @@ mod m007_add_retry_to_tasks; mod m008_add_skipped_status; mod m009_add_encryption_to_tasks; mod m010_create_encrypted_file_state; +mod m011_add_notification_channel_language; pub struct Migrator; @@ -27,6 +28,7 @@ impl MigratorTrait for Migrator { Box::new(m008_add_skipped_status::Migration), Box::new(m009_add_encryption_to_tasks::Migration), Box::new(m010_create_encrypted_file_state::Migration), + Box::new(m011_add_notification_channel_language::Migration), ] } } diff --git a/backend/src/models/notification.rs b/backend/src/models/notification.rs index fcaca35..607a14f 100644 --- a/backend/src/models/notification.rs +++ b/backend/src/models/notification.rs @@ -1,3 +1,6 @@ +use crate::services::notification_message::{ + NotificationEvent, NotificationLanguage, NotificationMessage, +}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -8,17 +11,82 @@ pub struct ChannelWithTaskCount { pub name: String, pub apprise_url: String, pub enabled: bool, + pub language: String, + pub templates: ChannelTemplates, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub task_count: i64, } +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ChannelTemplates { + #[serde(default)] + pub error: TemplateOverride, + #[serde(default)] + pub success: TemplateOverride, + #[serde(default)] + pub skipped: TemplateOverride, + #[serde(default)] + pub test: TemplateOverride, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct TemplateOverride { + #[serde(default)] + pub subject: String, + #[serde(default)] + pub body: String, +} + +impl ChannelTemplates { + pub fn from_json(value: &serde_json::Value) -> Self { + serde_json::from_value(value.clone()).unwrap_or_default() + } + + pub fn to_json(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap_or_else(|_| serde_json::json!({})) + } + + pub fn for_event(&self, event: NotificationEvent) -> &TemplateOverride { + match event { + NotificationEvent::Error => &self.error, + NotificationEvent::Success => &self.success, + NotificationEvent::Skipped => &self.skipped, + NotificationEvent::Test => &self.test, + } + } +} + +impl TemplateOverride { + pub fn subject_override(&self) -> Option<&str> { + let trimmed = self.subject.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + } + + pub fn body_override(&self) -> Option<&str> { + let trimmed = self.body.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + } +} + #[derive(Debug, Deserialize)] pub struct CreateChannelRequest { pub name: String, pub apprise_url: String, #[serde(default = "default_true")] pub enabled: bool, + #[serde(default = "default_language")] + pub language: String, + #[serde(default)] + pub templates: ChannelTemplates, } #[derive(Debug, Deserialize)] @@ -26,8 +94,112 @@ pub struct UpdateChannelRequest { pub name: String, pub apprise_url: String, pub enabled: bool, + pub language: Option, + pub templates: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PreviewRequest { + pub language: String, + #[serde(default)] + pub templates: ChannelTemplates, +} + +#[derive(Debug, Serialize)] +pub struct PreviewResponse { + pub language: String, + pub error: NotificationMessage, + pub success: NotificationMessage, + pub skipped: NotificationMessage, + pub test: NotificationMessage, +} + +pub fn render_channel_event( + language: &str, + templates: &ChannelTemplates, + event: NotificationEvent, + ctx: &crate::services::notification_message::NotificationContext, +) -> NotificationMessage { + let override_ = templates.for_event(event); + crate::services::notification_message::render( + event, + NotificationLanguage::parse(language), + ctx, + override_.subject_override(), + override_.body_override(), + ) } fn default_true() -> bool { true } + +fn default_language() -> String { + "fr".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::notification_message::{NotificationContext, NotificationEvent}; + use serde_json::json; + + #[test] + fn missing_create_language_defaults_to_french() { + let req: CreateChannelRequest = + serde_json::from_str(r#"{"name":"alerts","apprise_url":"mmost://host/hook"}"#).unwrap(); + assert_eq!(req.language, "fr"); + assert!(req.enabled); + assert_eq!(req.templates, ChannelTemplates::default()); + } + + #[test] + fn empty_json_templates_are_blank_overrides() { + let templates = ChannelTemplates::from_json(&json!({})); + assert_eq!(templates, ChannelTemplates::default()); + assert_eq!(templates.error.subject_override(), None); + assert_eq!(templates.error.body_override(), None); + } + + #[test] + fn blank_overrides_use_catalog_text() { + let ctx = NotificationContext { + task_id: "task-1".into(), + run_id: "run-1".into(), + exit_code: "1".into(), + logs: "log".into(), + reason: String::new(), + }; + let catalog = render_channel_event("en", &ChannelTemplates::default(), NotificationEvent::Success, &ctx); + let blank = ChannelTemplates { + success: TemplateOverride { + subject: " ".into(), + body: String::new(), + }, + ..ChannelTemplates::default() + }; + let rendered = render_channel_event("en", &blank, NotificationEvent::Success, &ctx); + assert_eq!(rendered, catalog); + } + + #[test] + fn custom_overrides_win_per_event() { + let ctx = NotificationContext { + task_id: "task-1".into(), + run_id: "run-1".into(), + exit_code: "9".into(), + logs: "log".into(), + reason: String::new(), + }; + let templates = ChannelTemplates { + error: TemplateOverride { + subject: "Fail {task_id}".into(), + body: "code={exit_code}".into(), + }, + ..ChannelTemplates::default() + }; + let msg = render_channel_event("fr", &templates, NotificationEvent::Error, &ctx); + assert_eq!(msg.subject, "Fail task-1"); + assert_eq!(msg.body, "code=9"); + } +} diff --git a/backend/src/models/task_run.rs b/backend/src/models/task_run.rs index 636d299..83dc71c 100644 --- a/backend/src/models/task_run.rs +++ b/backend/src/models/task_run.rs @@ -1,10 +1,11 @@ use serde::Serialize; +use uuid::Uuid; /// Vue sans log_output pour les listes (inclut stats pour le tableau) #[derive(Debug, Serialize)] pub struct TaskRunSummary { - pub id: uuid::Uuid, - pub task_id: uuid::Uuid, + pub id: Uuid, + pub task_id: Uuid, pub triggered_by: String, pub status: String, pub started_at: chrono::DateTime, @@ -13,3 +14,34 @@ pub struct TaskRunSummary { pub exit_code: Option, pub stats: Option, } + +/// Run metadata without the log blob. +#[derive(Debug, Serialize)] +pub struct TaskRunDetail { + pub id: Uuid, + pub task_id: Uuid, + pub triggered_by: String, + pub status: String, + pub started_at: chrono::DateTime, + pub finished_at: Option>, + pub duration_ms: Option, + pub exit_code: Option, + pub stats: Option, + pub log_bytes: i64, +} + +#[derive(Debug, Serialize)] +pub struct LogLineView { + pub offset: usize, + pub text: String, +} + +#[derive(Debug, Serialize)] +pub struct RunLogPage { + pub lines: Vec, + pub total_bytes: usize, + pub at_start: bool, + pub at_end: bool, + pub prev_offset: Option, + pub next_offset: Option, +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 413bc08..43ef566 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -41,11 +41,13 @@ pub fn build_router(state: AppState) -> Router { .route("/api/oauth/callback", get(oauth::callback)) // Runs .route("/api/runs/{run_id}", get(runs::get_run)) + .route("/api/runs/{run_id}/logs", get(runs::get_run_logs)) // Notifications .route( "/api/notifications", get(notifications::list).post(notifications::create), ) + .route("/api/notifications/preview", post(notifications::preview)) .route( "/api/notifications/{id}", get(notifications::get) diff --git a/backend/src/routes/notifications.rs b/backend/src/routes/notifications.rs index eeb3e6a..d0e5301 100644 --- a/backend/src/routes/notifications.rs +++ b/backend/src/routes/notifications.rs @@ -1,8 +1,14 @@ use crate::{ entities::{notification_channel, task}, errors::{AppError, AppResult}, - models::notification::{ChannelWithTaskCount, CreateChannelRequest, UpdateChannelRequest}, - services::apprise, + models::notification::{ + ChannelTemplates, ChannelWithTaskCount, CreateChannelRequest, PreviewRequest, + PreviewResponse, UpdateChannelRequest, render_channel_event, + }, + services::{ + apprise, + notification_message::{NotificationContext, NotificationEvent, NotificationLanguage}, + }, state::AppState, }; use axum::{ @@ -14,6 +20,39 @@ use sea_orm::*; use serde_json::json; use uuid::Uuid; +fn require_language(language: &str) -> AppResult { + if !NotificationLanguage::is_supported(language) { + return Err(AppError::BadRequest("Invalid language".into())); + } + Ok(NotificationLanguage::parse(language).as_str().to_string()) +} + +fn channel_to_list_item(ch: notification_channel::Model, task_count: i64) -> ChannelWithTaskCount { + ChannelWithTaskCount { + id: ch.id, + name: ch.name, + apprise_url: ch.apprise_url, + enabled: ch.enabled, + language: ch.language, + templates: ChannelTemplates::from_json(&ch.templates), + created_at: ch.created_at.into(), + updated_at: ch.updated_at.into(), + task_count, + } +} + +fn preview_for(language: &str, templates: &ChannelTemplates) -> PreviewResponse { + let parsed = NotificationLanguage::parse(language); + let ctx = NotificationContext::preview(parsed); + PreviewResponse { + language: parsed.as_str().to_string(), + error: render_channel_event(language, templates, NotificationEvent::Error, &ctx), + success: render_channel_event(language, templates, NotificationEvent::Success, &ctx), + skipped: render_channel_event(language, templates, NotificationEvent::Skipped, &ctx), + test: render_channel_event(language, templates, NotificationEvent::Test, &ctx), + } +} + pub async fn list(State(state): State) -> AppResult>> { let channels = notification_channel::Entity::find() .order_by_asc(notification_channel::Column::Name) @@ -29,15 +68,7 @@ pub async fn list(State(state): State) -> AppResult, Path(id): Path) -> AppR .map_err(AppError::Database)?; if result.rows_affected == 0 { - return Err(AppError::NotFound(format!("Channel {id} not found"))); + return Err(AppError::NotFound("Channel not found".into())); } Ok(StatusCode::NO_CONTENT) } +pub async fn preview(Json(req): Json) -> AppResult> { + let language = require_language(&req.language)?; + Ok(Json(preview_for(&language, &req.templates))) +} + pub async fn test_notification( State(state): State, Path(id): Path, @@ -128,17 +173,31 @@ pub async fn test_notification( .one(&state.db) .await .map_err(AppError::Database)? - .ok_or_else(|| AppError::NotFound(format!("Channel {id} not found")))?; + .ok_or_else(|| AppError::NotFound("Channel not found".into()))?; + + let templates = ChannelTemplates::from_json(&channel.templates); + let language = NotificationLanguage::parse(&channel.language); + let ctx = NotificationContext::preview(language); + let message = render_channel_event( + &channel.language, + &templates, + NotificationEvent::Test, + &ctx, + ); match apprise::send_notification( &state.config.apprise_bin, &[channel.apprise_url], - "Test — rclone-ui", - "Ce message confirme que le canal de notification **fonctionne correctement**.\n\n_Envoyé depuis rclone-ui._", + &message.subject, + &message.body, ) .await { - Ok(_) => Ok(Json(json!({"success": true, "message": "Test notification sent"}))), - Err(e) => Ok(Json(json!({"success": false, "message": e.to_string()}))), + Ok(_) => Ok(Json( + json!({"success": true, "message": "Test notification sent"}), + )), + Err(_) => Ok(Json( + json!({"success": false, "message": "Test notification failed"}), + )), } } diff --git a/backend/src/routes/runs.rs b/backend/src/routes/runs.rs index 54f5294..cde6f43 100644 --- a/backend/src/routes/runs.rs +++ b/backend/src/routes/runs.rs @@ -1,45 +1,54 @@ use crate::{ - entities::{task, task_run}, + entities::task, errors::{AppError, AppResult}, - models::task_run::TaskRunSummary, + models::task_run::{LogLineView, RunLogPage, TaskRunDetail, TaskRunSummary}, + services::run_log::{self, LogQuery}, state::AppState, }; use axum::{ Json, - extract::{Path, State}, + extract::{Path, Query, State}, }; -use sea_orm::*; +use sea_orm::{DatabaseBackend, EntityTrait, FromQueryResult, Statement}; +use serde::Deserialize; use uuid::Uuid; pub async fn list_for_task( State(state): State, Path(task_id): Path, ) -> AppResult>> { - // Verify task exists if task::Entity::find_by_id(task_id) .one(&state.db) .await? .is_none() { - return Err(AppError::NotFound(format!("Task {task_id} not found"))); + return Err(AppError::NotFound("Task not found".into())); } - let runs = task_run::Entity::find() - .filter(task_run::Column::TaskId.eq(task_id)) - .order_by_desc(task_run::Column::StartedAt) - .limit(100) - .all(&state.db) - .await?; + let rows = TaskRunListRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#" + SELECT id, task_id, triggered_by, status, started_at, finished_at, + duration_ms, exit_code, stats + FROM task_runs + WHERE task_id = $1 + ORDER BY started_at DESC + LIMIT 100 + "#, + [task_id.into()], + )) + .all(&state.db) + .await?; - let summaries = runs + let summaries = rows .into_iter() .map(|r| TaskRunSummary { id: r.id, task_id: r.task_id, triggered_by: r.triggered_by, status: r.status, - started_at: r.started_at.into(), - finished_at: r.finished_at.map(|t| t.into()), + started_at: r.started_at.with_timezone(&chrono::Utc), + finished_at: r.finished_at.map(|t| t.with_timezone(&chrono::Utc)), duration_ms: r.duration_ms, exit_code: r.exit_code, stats: r.stats, @@ -49,13 +58,186 @@ pub async fn list_for_task( Ok(Json(summaries)) } +#[derive(Debug, FromQueryResult)] +struct TaskRunListRow { + id: Uuid, + task_id: Uuid, + triggered_by: String, + status: String, + started_at: chrono::DateTime, + finished_at: Option>, + duration_ms: Option, + exit_code: Option, + stats: Option, +} + +#[derive(Debug, FromQueryResult)] +struct TaskRunMetaRow { + id: Uuid, + task_id: Uuid, + triggered_by: String, + status: String, + started_at: chrono::DateTime, + finished_at: Option>, + duration_ms: Option, + exit_code: Option, + stats: Option, + log_bytes: i64, +} + pub async fn get_run( State(state): State, Path(run_id): Path, -) -> AppResult> { - let run = task_run::Entity::find_by_id(run_id) - .one(&state.db) - .await? - .ok_or_else(|| AppError::NotFound(format!("Run {run_id} not found")))?; - Ok(Json(run)) +) -> AppResult> { + let row = TaskRunMetaRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#" + SELECT id, task_id, triggered_by, status, started_at, finished_at, + duration_ms, exit_code, stats, + octet_length(COALESCE(log_output, ''))::bigint AS log_bytes + FROM task_runs + WHERE id = $1 + "#, + [run_id.into()], + )) + .one(&state.db) + .await? + .ok_or_else(|| AppError::NotFound("Run not found".into()))?; + + Ok(Json(TaskRunDetail { + id: row.id, + task_id: row.task_id, + triggered_by: row.triggered_by, + status: row.status, + started_at: row.started_at.with_timezone(&chrono::Utc), + finished_at: row.finished_at.map(|t| t.with_timezone(&chrono::Utc)), + duration_ms: row.duration_ms, + exit_code: row.exit_code, + stats: row.stats, + log_bytes: row.log_bytes, + })) +} + +#[derive(Debug, Deserialize)] +pub struct LogPageParams { + from: Option, + after: Option, + before: Option, + window: Option, +} + +#[derive(Debug, FromQueryResult)] +struct LogChunkRow { + total_bytes: i64, + chunk: Option>, +} + +pub async fn get_run_logs( + State(state): State, + Path(run_id): Path, + Query(params): Query, +) -> AppResult> { + let query = log_query(params)?; + let total = total_log_bytes(&state, run_id).await?; + let (start, len) = run_log::fetch_range(query, total); + let chunk = fetch_log_chunk(&state, run_id, start, len).await?; + let window = run_log::lines_from_chunk(&chunk, start, total, run_log::starts_at_line(query)); + + Ok(Json(RunLogPage { + lines: window + .lines + .into_iter() + .map(|line| LogLineView { + offset: line.offset, + text: line.text, + }) + .collect(), + total_bytes: window.total_bytes, + at_start: window.at_start, + at_end: window.at_end, + prev_offset: window.prev_offset, + next_offset: window.next_offset, + })) +} + +fn log_query(params: LogPageParams) -> AppResult { + let window_bytes = params + .window + .and_then(|n| usize::try_from(n).ok()) + .map(run_log::clamp_window) + .unwrap_or(run_log::DEFAULT_WINDOW_BYTES); + + match (params.after, params.before, params.from.as_deref()) { + (Some(_), Some(_), _) => Err(AppError::BadRequest("Invalid log query".into())), + (Some(after), None, _) => Ok(LogQuery::After { + offset: offset_from_i64(after)?, + window_bytes, + }), + (None, Some(before), _) => Ok(LogQuery::Before { + offset: offset_from_i64(before)?, + window_bytes, + }), + (None, None, Some("start")) => Ok(LogQuery::FromStart { window_bytes }), + (None, None, Some("end") | None) => Ok(LogQuery::FromEnd { window_bytes }), + (None, None, Some(_)) => Err(AppError::BadRequest("Invalid log query".into())), + } +} + +fn offset_from_i64(value: i64) -> AppResult { + usize::try_from(value).map_err(|_| AppError::BadRequest("Invalid log query".into())) +} + +fn pg_substr_args(start: usize, len: usize) -> AppResult<(i32, i32)> { + let start = i32::try_from(start.saturating_add(1)) + .map_err(|_| AppError::BadRequest("Invalid log query".into()))?; + let len = + i32::try_from(len).map_err(|_| AppError::BadRequest("Invalid log query".into()))?; + Ok((start, len)) +} + +async fn total_log_bytes(state: &AppState, run_id: Uuid) -> AppResult { + let row = LogChunkRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#" + SELECT octet_length(COALESCE(log_output, ''))::bigint AS total_bytes, + NULL::bytea AS chunk + FROM task_runs + WHERE id = $1 + "#, + [run_id.into()], + )) + .one(&state.db) + .await? + .ok_or_else(|| AppError::NotFound("Run not found".into()))?; + + usize::try_from(row.total_bytes).map_err(|_| AppError::BadRequest("Invalid log query".into())) +} + +async fn fetch_log_chunk( + state: &AppState, + run_id: Uuid, + start: usize, + len: usize, +) -> AppResult { + if len == 0 { + return Ok(String::new()); + } + + // Postgres substr(bytea, int, int) is 1-based and counts bytes, not characters. + let (pg_start, pg_len) = pg_substr_args(start, len)?; + let row = LogChunkRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#" + SELECT octet_length(COALESCE(log_output, ''))::bigint AS total_bytes, + substr(convert_to(COALESCE(log_output, ''), 'UTF8'), $2, $3) AS chunk + FROM task_runs + WHERE id = $1 + "#, + [run_id.into(), pg_start.into(), pg_len.into()], + )) + .one(&state.db) + .await? + .ok_or_else(|| AppError::NotFound("Run not found".into()))?; + + Ok(run_log::decode_chunk(row.chunk.as_deref().unwrap_or(&[]))) } diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index bfb45e9..3d53f5f 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,8 +1,10 @@ pub mod apprise; pub mod crypto; +pub mod notification_message; pub mod crypto_transfer; pub mod oauth; pub mod rclone; +pub mod run_log; pub mod scheduler; pub mod secrets; pub mod task_executor; diff --git a/backend/src/services/notification_message.rs b/backend/src/services/notification_message.rs new file mode 100644 index 0000000..664e182 --- /dev/null +++ b/backend/src/services/notification_message.rs @@ -0,0 +1,365 @@ +//! Builds notification subjects and bodies from on-disk locale catalogs. +//! +//! Sending and the preview endpoint both call [`render`]. Catalogs live in +//! `backend/locales/{en,fr,pt}/notifications.json`. French is the fallback and +//! keeps the verbatim pre-i18n wording, including historical whitespace. + +use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; + +const EN_JSON: &str = include_str!("../../locales/en/notifications.json"); +const FR_JSON: &str = include_str!("../../locales/fr/notifications.json"); +const PT_JSON: &str = include_str!("../../locales/pt/notifications.json"); + +static EN: LazyLock = LazyLock::new(|| parse_catalog(EN_JSON)); +static FR: LazyLock = LazyLock::new(|| parse_catalog(FR_JSON)); +static PT: LazyLock = LazyLock::new(|| parse_catalog(PT_JSON)); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NotificationEvent { + Error, + Success, + Skipped, + Test, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NotificationLanguage { + En, + Fr, + Pt, +} + +impl NotificationLanguage { + pub const ALL: [Self; 3] = [Self::En, Self::Fr, Self::Pt]; + + /// Unknown or missing values keep the historical French messages. + pub fn parse(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "en" => Self::En, + "pt" => Self::Pt, + _ => Self::Fr, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::En => "en", + Self::Fr => "fr", + Self::Pt => "pt", + } + } + + pub fn is_supported(value: &str) -> bool { + Self::ALL.iter().any(|language| language.as_str() == value.trim().to_ascii_lowercase()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct NotificationContext { + pub task_id: String, + pub run_id: String, + pub exit_code: String, + pub logs: String, + pub reason: String, +} + +impl NotificationContext { + pub fn for_run( + task_id: impl ToString, + run_id: impl ToString, + exit_code: Option, + logs: &str, + ) -> Self { + Self { + task_id: task_id.to_string(), + run_id: run_id.to_string(), + exit_code: exit_code.map(|c| c.to_string()).unwrap_or_default(), + logs: logs.lines().take(50).collect::>().join("\n"), + reason: String::new(), + } + } + + pub fn preview(language: NotificationLanguage) -> Self { + Self { + task_id: "00000000-0000-4000-8000-000000000001".into(), + run_id: "00000000-0000-4000-8000-000000000002".into(), + exit_code: "1".into(), + logs: "Transferred: 0 B / 0 B\n[ERROR] something went wrong".into(), + reason: catalog(language).reason.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotificationMessage { + pub subject: String, + pub body: String, +} + +#[derive(Debug, Deserialize)] +struct Catalog { + reason: String, + error: NotificationMessage, + success: NotificationMessage, + skipped: NotificationMessage, + test: NotificationMessage, +} + +fn parse_catalog(raw: &str) -> Catalog { + serde_json::from_str(raw).expect("notification catalog must be valid JSON") +} + +fn catalog(language: NotificationLanguage) -> &'static Catalog { + match language { + NotificationLanguage::En => &EN, + NotificationLanguage::Fr => &FR, + NotificationLanguage::Pt => &PT, + } +} + +fn template_for(catalog: &Catalog, event: NotificationEvent) -> &NotificationMessage { + match event { + NotificationEvent::Error => &catalog.error, + NotificationEvent::Success => &catalog.success, + NotificationEvent::Skipped => &catalog.skipped, + NotificationEvent::Test => &catalog.test, + } +} + +pub fn render( + event: NotificationEvent, + language: NotificationLanguage, + ctx: &NotificationContext, + custom_subject: Option<&str>, + custom_body: Option<&str>, +) -> NotificationMessage { + let catalog = catalog(language); + let mut ctx = ctx.clone(); + if ctx.reason.is_empty() { + ctx.reason = catalog.reason.clone(); + } + + let defaults = template_for(catalog, event); + let subject = match nonempty(custom_subject) { + Some(template) => interpolate(template, &ctx), + None => interpolate(&defaults.subject, &ctx), + }; + let mut body = match nonempty(custom_body) { + Some(template) => interpolate(template, &ctx), + None => interpolate(&defaults.body, &ctx), + }; + truncate_utf8(&mut body, 4000); + NotificationMessage { subject, body } +} + +fn truncate_utf8(value: &mut String, max_bytes: usize) { + if value.len() <= max_bytes { + return; + } + let mut end = max_bytes; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + value.truncate(end); +} + +fn nonempty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|s| !s.is_empty()) +} + +fn interpolate(template: &str, ctx: &NotificationContext) -> String { + template + .replace("{task_id}", &ctx.task_id) + .replace("{run_id}", &ctx.run_id) + .replace("{exit_code}", &ctx.exit_code) + .replace("{logs}", &ctx.logs) + .replace("{reason}", &ctx.reason) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_ctx() -> NotificationContext { + NotificationContext { + task_id: "task-1".into(), + run_id: "run-1".into(), + exit_code: "1".into(), + logs: "Transferred: 0 B / 0 B\n[ERROR] something went wrong".into(), + reason: String::new(), + } + } + + /// Byte-for-byte copy of the pre-i18n `format!` in `task_executor`. + fn legacy_french_error_body(task_id: &str, run_id: &str, exit_code: &str, logs: &str) -> String { + format!( + "**Tâche** : `{task_id}`\n\ + **Run** : `{run_id}`\n\ + **Code de sortie** : `{exit_code}`\n\ + \n\ + **Logs** :\n\ + ```\n\ + {logs}\n\ + ```" + ) + } + + fn legacy_french_success_body(task_id: &str, run_id: &str) -> String { + format!( + "**Tâche** : `{task_id}`\n\ + **Run** : `{run_id}`\n\ + \n\ + La synchronisation s'est terminée avec succès." + ) + } + + fn legacy_french_skipped_body(task_id: &str) -> String { + format!( + "**Tâche** : `{task_id}`\n\ + **Raison** : L'exécution précédente est encore en cours\n\ + \n\ + La planification cron a tenté de lancer cette tâche, \ + mais la synchronisation précédente n'est pas terminée. \ + L'exécution a été _ignorée_." + ) + } + + #[test] + fn catalogs_exist_for_every_supported_language() { + for language in NotificationLanguage::ALL { + let catalog = catalog(language); + assert!(!catalog.reason.is_empty(), "{}", language.as_str()); + assert!(!catalog.error.subject.is_empty(), "{}", language.as_str()); + } + } + + #[test] + fn french_error_matches_legacy_executor() { + let ctx = sample_ctx(); + let mut expected = legacy_french_error_body(&ctx.task_id, &ctx.run_id, &ctx.exit_code, &ctx.logs); + expected.truncate(4000); + let msg = render(NotificationEvent::Error, NotificationLanguage::Fr, &ctx, None, None); + assert_eq!(msg.subject, "Échec de la tâche de réplication"); + assert_eq!(msg.body, expected); + } + + #[test] + fn french_success_matches_legacy_executor() { + let ctx = sample_ctx(); + let expected = legacy_french_success_body(&ctx.task_id, &ctx.run_id); + let msg = render(NotificationEvent::Success, NotificationLanguage::Fr, &ctx, None, None); + assert_eq!(msg.subject, "Tâche de réplication terminée avec succès"); + assert_eq!(msg.body, expected); + } + + #[test] + fn french_skipped_matches_legacy_scheduler() { + let ctx = sample_ctx(); + let expected = legacy_french_skipped_body(&ctx.task_id); + let msg = render(NotificationEvent::Skipped, NotificationLanguage::Fr, &ctx, None, None); + assert_eq!(msg.subject, "Tâche de réplication ignorée"); + assert_eq!(msg.body, expected); + } + + #[test] + fn french_test_matches_legacy_route() { + let msg = render( + NotificationEvent::Test, + NotificationLanguage::Fr, + &sample_ctx(), + None, + None, + ); + assert_eq!(msg.subject, "Test — rclone-ui"); + assert_eq!( + msg.body, + "Ce message confirme que le canal de notification **fonctionne correctement**.\n\n_Envoyé depuis rclone-ui._" + ); + } + + #[test] + fn unknown_language_is_french() { + assert_eq!(NotificationLanguage::parse(""), NotificationLanguage::Fr); + assert_eq!(NotificationLanguage::parse("de"), NotificationLanguage::Fr); + } + + #[test] + fn empty_custom_fields_use_catalog() { + let ctx = sample_ctx(); + let default = render( + NotificationEvent::Success, + NotificationLanguage::En, + &ctx, + None, + None, + ); + let blank = render( + NotificationEvent::Success, + NotificationLanguage::En, + &ctx, + Some(" "), + Some(""), + ); + assert_eq!(default, blank); + } + + #[test] + fn custom_template_interpolates() { + let ctx = sample_ctx(); + let msg = render( + NotificationEvent::Error, + NotificationLanguage::En, + &ctx, + Some("Fail {task_id}"), + Some("code={exit_code}"), + ); + assert_eq!(msg.subject, "Fail task-1"); + assert_eq!(msg.body, "code=1"); + } + + #[test] + fn is_supported_accepts_only_catalog_codes() { + assert!(NotificationLanguage::is_supported("en")); + assert!(NotificationLanguage::is_supported("FR")); + assert!(NotificationLanguage::is_supported("pt")); + assert!(!NotificationLanguage::is_supported("de")); + assert!(!NotificationLanguage::is_supported("")); + } + + #[test] + fn preview_context_uses_catalog_reason() { + let ctx = NotificationContext::preview(NotificationLanguage::Fr); + assert_eq!(ctx.reason, catalog(NotificationLanguage::Fr).reason); + } + + #[test] + fn body_is_truncated_to_4000_bytes() { + let long = "x".repeat(5000); + let msg = render( + NotificationEvent::Test, + NotificationLanguage::En, + &sample_ctx(), + None, + Some(&long), + ); + assert_eq!(msg.body.len(), 4000); + } + + #[test] + fn truncate_does_not_split_multibyte_characters() { + let long = "é".repeat(3000); + let msg = render( + NotificationEvent::Test, + NotificationLanguage::Fr, + &sample_ctx(), + None, + Some(&long), + ); + assert!(msg.body.len() <= 4000); + assert!(msg.body.is_char_boundary(msg.body.len())); + assert!(msg.body.ends_with('é') || msg.body.is_empty()); + } +} diff --git a/backend/src/services/run_log.rs b/backend/src/services/run_log.rs new file mode 100644 index 0000000..6c4a113 --- /dev/null +++ b/backend/src/services/run_log.rs @@ -0,0 +1,347 @@ +//! Windowed views over a stored run log. +//! +//! Callers fetch only a byte range from the database, then [`lines_from_chunk`] +//! snaps that range to whole lines so the API never materialises the full blob. + +pub const DEFAULT_WINDOW_BYTES: usize = 64 * 1024; +pub const MAX_WINDOW_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogQuery { + FromStart { window_bytes: usize }, + FromEnd { window_bytes: usize }, + After { offset: usize, window_bytes: usize }, + Before { offset: usize, window_bytes: usize }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogLine { + pub offset: usize, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogWindow { + pub lines: Vec, + pub total_bytes: usize, + pub at_start: bool, + pub at_end: bool, + pub prev_offset: Option, + pub next_offset: Option, +} + +pub fn clamp_window(window_bytes: usize) -> usize { + if window_bytes == 0 { + return DEFAULT_WINDOW_BYTES; + } + window_bytes.min(MAX_WINDOW_BYTES) +} + +/// 0-based `[start, start+len)` range to read from the stored log. +pub fn fetch_range(query: LogQuery, total: usize) -> (usize, usize) { + if total == 0 { + return (0, 0); + } + match query { + LogQuery::FromStart { window_bytes } => (0, window_bytes.min(total)), + LogQuery::FromEnd { window_bytes } => { + let len = window_bytes.min(total); + (total - len, len) + } + LogQuery::After { + offset, + window_bytes, + } => { + if offset >= total { + return (total, 0); + } + (offset, window_bytes.min(total - offset)) + } + LogQuery::Before { + offset, + window_bytes, + } => { + let end = offset.min(total); + let start = end.saturating_sub(window_bytes); + (start, end - start) + } + } +} + +/// Snap a fetched chunk to whole lines and attach offsets in the full log. +/// +/// `starts_at_line` is true when `chunk_start` is known to be the first byte +/// of a line (FromStart / After). FromEnd / Before may begin mid-line. +pub fn lines_from_chunk( + chunk: &str, + chunk_start: usize, + total_bytes: usize, + starts_at_line: bool, +) -> LogWindow { + if total_bytes == 0 { + return LogWindow { + lines: Vec::new(), + total_bytes, + at_start: true, + at_end: true, + prev_offset: None, + next_offset: None, + }; + } + + let (snapped_start, snapped) = snap_to_lines(chunk, chunk_start, total_bytes, starts_at_line); + let lines = split_lines(snapped, snapped_start); + let at_start = snapped_start == 0; + let consumed_end = match lines.last() { + Some(line) => line.offset + line.text.len() + 1, + None => snapped_start, + }; + let at_end = consumed_end >= total_bytes || chunk_start + chunk.len() >= total_bytes; + let prev_offset = if at_start { + None + } else { + lines.first().map(|line| line.offset) + }; + let next_offset = if at_end { + None + } else { + Some(consumed_end.min(total_bytes)) + }; + + LogWindow { + lines, + total_bytes, + at_start, + at_end, + prev_offset, + next_offset, + } +} + +fn snap_to_lines( + chunk: &str, + chunk_start: usize, + total_bytes: usize, + starts_at_line: bool, +) -> (usize, &str) { + let at_file_end = chunk_start + chunk.len() >= total_bytes; + + let mut local_start = 0usize; + if !starts_at_line && chunk_start > 0 { + match chunk.find('\n') { + Some(i) => local_start = i + 1, + None => return (chunk_start + chunk.len(), ""), + } + } + + let mut local_end = chunk.len(); + if !at_file_end { + match chunk[local_start..].rfind('\n') { + Some(i) => local_end = local_start + i, + None if local_start == 0 => {} + None => return (chunk_start + local_start, ""), + } + } + + (chunk_start + local_start, &chunk[local_start..local_end]) +} + +/// Slice the stored UTF-8 the same way Postgres `substr(convert_to(...), start, len)` does: +/// 0-based byte offset, then drop a split codepoint at either end. +pub fn slice_stored_log(log: &str, start: usize, len: usize) -> String { + let bytes = log.as_bytes(); + if start >= bytes.len() || len == 0 { + return String::new(); + } + let end = (start + len).min(bytes.len()); + decode_chunk(&bytes[start..end]) +} + +pub fn starts_at_line(query: LogQuery) -> bool { + matches!(query, LogQuery::FromStart { .. } | LogQuery::After { .. }) +} + +pub fn window_stored_log(log: &str, query: LogQuery) -> LogWindow { + let (start, len) = fetch_range(query, log.len()); + let chunk = slice_stored_log(log, start, len); + lines_from_chunk(&chunk, start, log.len(), starts_at_line(query)) +} + +pub fn decode_chunk(bytes: &[u8]) -> String { + let mut start = 0; + while start < bytes.len() && bytes[start] & 0b1100_0000 == 0b1000_0000 { + start += 1; + } + let mut end = bytes.len(); + while end > start && std::str::from_utf8(&bytes[start..end]).is_err() { + end -= 1; + } + String::from_utf8_lossy(&bytes[start..end]).into_owned() +} + +fn split_lines(snapped: &str, start_offset: usize) -> Vec { + if snapped.is_empty() { + return Vec::new(); + } + + let mut offset = start_offset; + let mut lines = Vec::new(); + for (i, text) in snapped.split('\n').enumerate() { + if i > 0 { + offset += 1; + } + if text.is_empty() && offset >= start_offset + snapped.len() { + break; + } + lines.push(LogLine { + offset, + text: text.to_string(), + }); + offset += text.len(); + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + fn joined(window: &LogWindow) -> Vec<&str> { + window.lines.iter().map(|line| line.text.as_str()).collect() + } + + #[test] + fn empty_log_is_at_both_ends() { + let window = lines_from_chunk("", 0, 0, true); + assert!(window.lines.is_empty()); + assert!(window.at_start); + assert!(window.at_end); + assert_eq!(window.prev_offset, None); + assert_eq!(window.next_offset, None); + } + + #[test] + fn small_log_fits_in_one_window() { + let log = "alpha\nbeta\ngamma"; + let (start, len) = fetch_range( + LogQuery::FromStart { + window_bytes: DEFAULT_WINDOW_BYTES, + }, + log.len(), + ); + let window = lines_from_chunk(&log[start..start + len], start, log.len(), true); + assert_eq!(joined(&window), ["alpha", "beta", "gamma"]); + assert!(window.at_start); + assert!(window.at_end); + assert_eq!(window.lines[0].offset, 0); + assert_eq!(window.lines[1].offset, 6); + assert_eq!(window.lines[2].offset, 11); + } + + #[test] + fn from_end_drops_a_leading_partial_line() { + let log = "aaaa\nbbbb\ncccc\ndddd"; + let query = LogQuery::FromEnd { window_bytes: 10 }; + let (start, len) = fetch_range(query, log.len()); + let window = lines_from_chunk(&log[start..start + len], start, log.len(), false); + assert!(window.at_end); + assert!(!window.at_start); + assert_eq!(joined(&window), ["cccc", "dddd"]); + assert_eq!(window.prev_offset, Some(window.lines[0].offset)); + } + + #[test] + fn after_offset_walks_forward() { + let log = "one\ntwo\nthree\nfour"; + let query = LogQuery::After { + offset: 4, + window_bytes: 20, + }; + let (start, len) = fetch_range(query, log.len()); + let window = lines_from_chunk(&log[start..start + len], start, log.len(), true); + assert_eq!(joined(&window), ["two", "three", "four"]); + assert!(window.at_end); + assert_eq!(window.lines[0].offset, 4); + } + + #[test] + fn before_offset_walks_backward() { + let log = "one\ntwo\nthree\nfour"; + let four = log.rfind("four").unwrap(); + let query = LogQuery::Before { + offset: four, + window_bytes: 10, + }; + let (start, len) = fetch_range(query, log.len()); + let window = lines_from_chunk(&log[start..start + len], start, log.len(), false); + assert!(joined(&window).contains(&"two") || joined(&window).contains(&"three")); + assert!(!window.at_end); + assert_eq!(window.next_offset, Some(four)); + } + + #[test] + fn fetch_range_is_clamped_to_the_file() { + assert_eq!( + fetch_range(LogQuery::FromStart { window_bytes: 9 }, 4), + (0, 4) + ); + assert_eq!( + fetch_range(LogQuery::After { + offset: 10, + window_bytes: 8 + }, 10), + (10, 0) + ); + } + + #[test] + fn clamp_window_rejects_zero_and_caps_the_max() { + assert_eq!(clamp_window(0), DEFAULT_WINDOW_BYTES); + assert_eq!(clamp_window(MAX_WINDOW_BYTES + 10), MAX_WINDOW_BYTES); + } + + #[test] + fn character_substring_with_byte_offsets_misses_the_end() { + let log = "café\n".repeat(20_000); + let (start, len) = fetch_range( + LogQuery::FromEnd { + window_bytes: 1024, + }, + log.len(), + ); + let by_chars: String = log.chars().skip(start).take(len).collect(); + assert!(start > log.chars().count()); + assert!(by_chars.is_empty()); + assert!(!slice_stored_log(&log, start, len).is_empty()); + } + + #[test] + fn from_end_of_a_multibyte_log_returns_the_last_lines() { + let log = "échec opération café\n".repeat(20_000); + assert!(log.len() > DEFAULT_WINDOW_BYTES); + assert!(log.len() > log.chars().count()); + + let window = window_stored_log( + &log, + LogQuery::FromEnd { + window_bytes: DEFAULT_WINDOW_BYTES, + }, + ); + assert!(!window.lines.is_empty()); + assert!(window.at_end); + assert!(window + .lines + .last() + .is_some_and(|line| line.text.contains("café") || line.text.contains("échec"))); + } + + #[test] + fn decode_chunk_keeps_whole_characters_and_drops_a_split_prefix() { + assert_eq!(decode_chunk("é".as_bytes()), "é"); + let cafe = "café".as_bytes(); + assert_eq!(&cafe[3..], "é".as_bytes()); + let continuation = &cafe[4..]; + assert_eq!(continuation[0] & 0b1100_0000, 0b1000_0000); + assert_eq!(decode_chunk(continuation), ""); + } +} diff --git a/backend/src/services/scheduler.rs b/backend/src/services/scheduler.rs index f2b641e..976ee35 100644 --- a/backend/src/services/scheduler.rs +++ b/backend/src/services/scheduler.rs @@ -154,19 +154,25 @@ async fn log_skipped_run( _ => return, }; - let body = format!( - "**Tâche** : `{task_id}`\n\ - **Raison** : L'exécution précédente est encore en cours\n\ - \n\ - La planification cron a tenté de lancer cette tâche, \ - mais la synchronisation précédente n'est pas terminée. \ - L'exécution a été _ignorée_." + let templates = + crate::models::notification::ChannelTemplates::from_json(&channel.templates); + let ctx = crate::services::notification_message::NotificationContext::for_run( + task_id, + uuid::Uuid::nil(), + None, + "", + ); + let message = crate::models::notification::render_channel_event( + &channel.language, + &templates, + crate::services::notification_message::NotificationEvent::Skipped, + &ctx, ); let _ = crate::services::apprise::send_notification( &state.config.apprise_bin, &[channel.apprise_url], - "Tâche de réplication ignorée", - &body, + &message.subject, + &message.body, ) .await; } diff --git a/backend/src/services/task_executor.rs b/backend/src/services/task_executor.rs index e32e364..d450514 100644 --- a/backend/src/services/task_executor.rs +++ b/backend/src/services/task_executor.rs @@ -2,7 +2,12 @@ use crate::{ entities::{encrypted_file_state, notification_channel, remote, task, task_run}, errors::{AppError, AppResult}, models::remote::RcloneRemote, - services::{apprise, crypto, crypto_transfer, rclone}, + models::notification::{ChannelTemplates, render_channel_event}, + services::{ + apprise, crypto, crypto_transfer, + notification_message::{NotificationContext, NotificationEvent}, + rclone, + }, sse::broadcaster::SseEvent, sse::global::GlobalEvent, state::{AppState, RunningTask}, @@ -252,31 +257,13 @@ async fn execute_task_background( }; if should_notify { - let (subject, detail) = if exit_code != 0 { - let logs_tail = error_logs.lines().take(50).collect::>().join("\n"); - - let mut body = format!( - "**Tâche** : `{task_id}`\n\ - **Run** : `{run_id}`\n\ - **Code de sortie** : `{exit_code}`\n\ - \n\ - **Logs** :\n\ - ```\n\ - {logs_tail}\n\ - ```" - ); - body.truncate(4000); - ("Échec de la tâche de réplication", body) + let event = if exit_code != 0 { + NotificationEvent::Error } else { - let body = format!( - "**Tâche** : `{task_id}`\n\ - **Run** : `{run_id}`\n\ - \n\ - La synchronisation s'est terminée avec succès." - ); - ("Tâche de réplication terminée avec succès", body) + NotificationEvent::Success }; - send_notification(&state, channel_id, subject, &detail).await; + let ctx = NotificationContext::for_run(task_id, run_id, Some(exit_code), &error_logs); + send_run_notification(&state, channel_id, event, &ctx).await; } } } @@ -999,7 +986,12 @@ async fn finish_run( tracing::info!("Run {run_id} finished status={status} in {duration_ms}ms"); } -async fn send_notification(state: &AppState, channel_id: Uuid, subject: &str, detail: &str) { +async fn send_run_notification( + state: &AppState, + channel_id: Uuid, + event: NotificationEvent, + ctx: &NotificationContext, +) { let channel = match notification_channel::Entity::find_by_id(channel_id) .filter(notification_channel::Column::Enabled.eq(true)) .one(&state.db) @@ -1009,11 +1001,13 @@ async fn send_notification(state: &AppState, channel_id: Uuid, subject: &str, de _ => return, }; + let templates = ChannelTemplates::from_json(&channel.templates); + let message = render_channel_event(&channel.language, &templates, event, ctx); let _ = apprise::send_notification( &state.config.apprise_bin, &[channel.apprise_url], - subject, - detail, + &message.subject, + &message.body, ) .await; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f4f29e0..c05fbf4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,16 +17,23 @@ "clsx": "^2.1.1", "cron-parser": "^5.5.0", "cronstrue": "^3.14.0", + "i18next": "^26.4.2", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-hook-form": "^7.72.1", + "react-i18next": "^17.0.14", "react-router-dom": "^7.14.1", + "react-virtuoso": "^4.18.13", "tailwind-merge": "^3.5.0", "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.28.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.58.2", @@ -36,14 +43,23 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "postcss": "^8.5.10", "prettier": "^3.8.3", "tailwindcss": "^4.2.2", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.8" + "vite": "^8.0.8", + "vitest": "^4.1.11" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -56,6 +72,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -268,6 +335,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -316,6 +392,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -563,6 +792,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@hookform/resolvers": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", @@ -1057,6 +1304,13 @@ "url": "https://paulmillr.com/funding/" } }, + "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/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -1371,6 +1625,105 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1381,6 +1734,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1674,58 +2053,171 @@ } } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/age-encryption": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/age-encryption/-/age-encryption-0.3.0.tgz", - "integrity": "sha512-vDhGGp5ZXpbKL6oc3FEBjGhyepr/0SwIWmia6CcPXvYcxGBSQNcDdMv9m2yVOkjjYuJEmtGEhOojIUcjrj0cEw==", - "license": "BSD-3-Clause", "dependencies": { - "@noble/ciphers": "^2.1.1", - "@noble/curves": "^2.0.1", - "@noble/hashes": "^2.0.1", - "@noble/post-quantum": "^0.5.3", - "@scure/base": "^2.0.0" + "@vitest/spy": "4.1.11", + "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/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "4" + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">= 6.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/age-encryption": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/age-encryption/-/age-encryption-0.3.0.tgz", + "integrity": "sha512-vDhGGp5ZXpbKL6oc3FEBjGhyepr/0SwIWmia6CcPXvYcxGBSQNcDdMv9m2yVOkjjYuJEmtGEhOojIUcjrj0cEw==", + "license": "BSD-3-Clause", + "dependencies": { + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@noble/post-quantum": "^0.5.3", + "@scure/base": "^2.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1739,6 +2231,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1762,6 +2265,26 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1803,6 +2326,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -1894,6 +2427,16 @@ ], "license": "CC-BY-4.0" }, + "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/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2015,6 +2558,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2022,6 +2586,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2039,6 +2617,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2055,6 +2640,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2064,6 +2659,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2098,6 +2701,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2116,6 +2732,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2407,6 +3030,16 @@ "node": ">=4.0" } }, + "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/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2417,6 +3050,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2722,6 +3365,28 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2735,6 +3400,43 @@ "node": ">= 6" } }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2772,6 +3474,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2795,6 +3507,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2831,6 +3550,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3214,6 +3984,17 @@ "node": ">=12" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3232,6 +4013,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3253,6 +4041,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -3307,6 +4105,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3370,6 +4182,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3390,6 +4215,13 @@ "node": ">=8" } }, + "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", @@ -3462,6 +4294,36 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -3518,6 +4380,41 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "17.0.14", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.14.tgz", + "integrity": "sha512-ZpMBfJL3BiXPuYHj5QMY1GwvKJNhE1vCxOJQ8BoMBdHP5XONTfi/Qss1nuRlnFdIl72PKo2jPmw+fxuLjRFgaQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.14.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", @@ -3556,6 +4453,40 @@ "react-dom": ">=18" } }, + "node_modules/react-virtuoso": { + "version": "4.18.13", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.13.tgz", + "integrity": "sha512-M4kP7zbV7JmLrnJYs0X2FBA8gnNlHisSYuKMyeJVYtWz31CAJeP57dgPJzfneUdKDKmL9SiqvcBQ32si8CfQSQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3605,6 +4536,19 @@ "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3653,6 +4597,13 @@ "node": ">=8" } }, + "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", @@ -3662,6 +4613,33 @@ "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.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3688,6 +4666,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -3717,6 +4702,23 @@ "url": "https://opencollective.com/webpack" } }, + "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.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3733,6 +4735,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3770,7 +4828,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3804,6 +4862,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3845,6 +4913,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.8", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", @@ -3922,6 +4999,144 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "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.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "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/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3938,6 +5153,23 @@ "node": ">= 8" } }, + "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" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3948,6 +5180,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3e0dc42..e683620 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -19,16 +20,23 @@ "clsx": "^2.1.1", "cron-parser": "^5.5.0", "cronstrue": "^3.14.0", + "i18next": "^26.4.2", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-hook-form": "^7.72.1", + "react-i18next": "^17.0.14", "react-router-dom": "^7.14.1", + "react-virtuoso": "^4.18.13", "tailwind-merge": "^3.5.0", "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.28.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.58.2", @@ -38,11 +46,13 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "postcss": "^8.5.10", "prettier": "^3.8.3", "tailwindcss": "^4.2.2", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.8" + "vite": "^8.0.8", + "vitest": "^4.1.11" } } diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index 9ad96ed..4a1fa14 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -1,5 +1,11 @@ import client from './client'; -import { NotificationChannel, CreateChannelPayload } from '../types/notification'; +import { + ChannelTemplates, + CreateChannelPayload, + NotificationChannel, + NotificationPreview, +} from '../types/notification'; +import type { SupportedLanguage } from '../i18n'; export const getChannels = () => client.get('/notifications').then(r => r.data); export const getChannel = (id: string) => client.get(`/notifications/${id}`).then(r => r.data); @@ -7,3 +13,5 @@ export const createChannel = (payload: CreateChannelPayload) => client.post client.put(`/notifications/${id}`, payload).then(r => r.data); export const deleteChannel = (id: string) => client.delete(`/notifications/${id}`); export const testChannel = (id: string) => client.post<{ success: boolean; message: string }>(`/notifications/${id}/test`).then(r => r.data); +export const previewNotification = (payload: { language: SupportedLanguage; templates: ChannelTemplates }) => + client.post('/notifications/preview', payload).then(r => r.data); diff --git a/frontend/src/api/runs.ts b/frontend/src/api/runs.ts index 7e72581..4555f1e 100644 --- a/frontend/src/api/runs.ts +++ b/frontend/src/api/runs.ts @@ -1,5 +1,15 @@ import client from './client'; -import { TaskRun, TaskRunSummary } from '../types/taskRun'; +import { RunLogPage, RunLogQuery, TaskRun, TaskRunSummary } from '../types/taskRun'; export const getTaskRuns = (taskId: string) => client.get(`/tasks/${taskId}/runs`).then(r => r.data); export const getRun = (runId: string) => client.get(`/runs/${runId}`).then(r => r.data); + +export const getRunLogs = (runId: string, query: RunLogQuery) => { + const params = + 'from' in query + ? { from: query.from } + : 'after' in query + ? { after: query.after } + : { before: query.before }; + return client.get(`/runs/${runId}/logs`, { params }).then(r => r.data); +}; diff --git a/frontend/src/components/layout/Sidebar.test.tsx b/frontend/src/components/layout/Sidebar.test.tsx new file mode 100644 index 0000000..63e0b37 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.test.tsx @@ -0,0 +1,39 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import i18n from '../../i18n'; +import { Sidebar } from './Sidebar'; + +afterEach(() => { + cleanup(); +}); + +describe('Sidebar brand', () => { + beforeEach(async () => { + await i18n.changeLanguage('en'); + }); + + it('takes the logo block to the main page with a pointer cursor', async () => { + const user = userEvent.setup(); + render( + + + + home-page} /> + } /> + + + , + ); + + const brand = screen.getByRole('link', { name: /rclone-ui/i }); + expect(brand).toHaveAttribute('href', '/'); + expect(brand).toHaveClass('cursor-pointer'); + expect(brand).toHaveTextContent('File replication'); + + await user.click(brand); + expect(screen.getByText('home-page')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9e012b1..abefbf2 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,32 +1,36 @@ -import { NavLink } from 'react-router-dom'; +import { Link, NavLink } from 'react-router-dom'; import { Server, RefreshCw, Bell } from 'lucide-react'; - -const links = [ - { to: '/tasks', label: 'Tâches', desc: 'Gérer les réplications', icon: RefreshCw }, - { to: '/remotes', label: 'Stockages', desc: 'Sources et destinations', icon: Server }, - { to: '/notifications', label: 'Notifications', desc: 'Alertes et canaux', icon: Bell }, -]; +import { useTranslation } from 'react-i18next'; +import { LanguageSwitcher } from '../../i18n/LanguageSwitcher'; export function Sidebar() { + const { t } = useTranslation(); + const links = [ + { to: '/tasks', label: t('nav.tasks'), desc: t('nav.tasksDesc'), icon: RefreshCw }, + { to: '/remotes', label: t('nav.remotes'), desc: t('nav.remotesDesc'), icon: Server }, + { to: '/notifications', label: t('nav.notifications'), desc: t('nav.notificationsDesc'), icon: Bell }, + ]; + return ( diff --git a/frontend/src/components/progress/LiveProgressPanel.tsx b/frontend/src/components/progress/LiveProgressPanel.tsx index 0c6168c..dc5519e 100644 --- a/frontend/src/components/progress/LiveProgressPanel.tsx +++ b/frontend/src/components/progress/LiveProgressPanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; import { Terminal, CheckCircle2, XCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface Props { lines: string[]; @@ -8,6 +9,7 @@ interface Props { } export function LiveProgressPanel({ lines, done, status }: Props) { + const { t } = useTranslation(); const bottomRef = useRef(null); useEffect(() => { @@ -18,17 +20,17 @@ export function LiveProgressPanel({ lines, done, status }: Props) {
- Progression en temps réel + {t('progress.title')} {done && status && ( - {status === 'success' ? <> Terminé : <> Échec} + {status === 'success' ? <> {t('progress.finished')} : <> {t('progress.failed')}} )}
{lines.length === 0 && !done && ( -

En attente des logs...

+

{t('progress.waiting')}

)} {lines.map((line, i) => (
{line}
diff --git a/frontend/src/components/remotes/GenericConfigFields.tsx b/frontend/src/components/remotes/GenericConfigFields.tsx index b719ccf..115c2ef 100644 --- a/frontend/src/components/remotes/GenericConfigFields.tsx +++ b/frontend/src/components/remotes/GenericConfigFields.tsx @@ -1,5 +1,6 @@ import { Control, UseFormRegister, useFieldArray } from 'react-hook-form'; import { Plus, Trash2, Info } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface Props { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -9,28 +10,29 @@ interface Props { } export function GenericConfigFields({ control, register }: Props) { + const { t } = useTranslation(); const { fields, append, remove } = useFieldArray({ control, name: 'configEntries' }); return (
- Configuration avancée + {t('remotes.generic.legend')}

- Entrez les paires clé/valeur correspondant aux options de configuration rclone pour ce type de stockage. + {t('remotes.generic.help')}

{fields.map((field, index) => (
); diff --git a/frontend/src/components/remotes/OAuthButton.tsx b/frontend/src/components/remotes/OAuthButton.tsx index b24281b..febb0da 100644 --- a/frontend/src/components/remotes/OAuthButton.tsx +++ b/frontend/src/components/remotes/OAuthButton.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { KeyRound, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface Props { /** Type rclone : 'dropbox', 'drive', 'onedrive' */ @@ -16,13 +17,14 @@ interface Props { * via window.postMessage envoyé par la page de callback du backend. */ export function OAuthButton({ provider, label, clientId, clientSecret, onSuccess }: Props) { + const { t } = useTranslation(); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const handleClick = () => { setError(null); if (!clientId || !clientSecret) { - setError('Renseignez d\'abord le Client ID et le Client Secret avant de connecter.'); + setError(t('remotes.oauth.needCredentials')); return; } @@ -35,7 +37,7 @@ export function OAuthButton({ provider, label, clientId, clientSecret, onSuccess const popup = window.open(url, 'rclone-ui-oauth', 'width=600,height=720,scrollbars=yes'); if (!popup) { - setError('Impossible d\'ouvrir la fenêtre OAuth. Autorisez les popups pour ce site.'); + setError(t('remotes.oauth.popupBlocked')); return; } @@ -49,12 +51,11 @@ export function OAuthButton({ provider, label, clientId, clientSecret, onSuccess if (e.data.success) { onSuccess(JSON.stringify(e.data.token)); } else { - setError(e.data.error || 'Échec du flow OAuth.'); + setError(e.data.error || t('remotes.oauth.failed')); } }; window.addEventListener('message', onMessage); - // Détecte la fermeture manuelle de la popup const closedCheck = window.setInterval(() => { if (popup.closed) { window.removeEventListener('message', onMessage); @@ -74,10 +75,10 @@ export function OAuthButton({ provider, label, clientId, clientSecret, onSuccess className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors" > {pending ? : } - Connecter via OAuth ({label}) + {t('remotes.oauth.connect', { provider: label })}

- Auth interactive — remplit le champ « Token » automatiquement. + {t('remotes.oauth.hint')}

{error &&

{error}

} diff --git a/frontend/src/components/remotes/StandardRemoteFields.tsx b/frontend/src/components/remotes/StandardRemoteFields.tsx index 569d9d7..a041230 100644 --- a/frontend/src/components/remotes/StandardRemoteFields.tsx +++ b/frontend/src/components/remotes/StandardRemoteFields.tsx @@ -1,4 +1,5 @@ import { UseFormRegister, UseFormWatch, UseFormSetValue, FieldErrors } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import type { RemoteFieldDef } from '../../config/remoteFieldSchemas'; import { OAuthButton } from './OAuthButton'; @@ -20,6 +21,11 @@ interface Props { const inputCls = "w-full border border-surface-300 rounded-lg px-3 py-2.5 text-sm focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-shadow"; +function translatedLabel(exists: boolean, value: unknown, fallback: string): string { + if (!exists || typeof value !== 'string' || value.length === 0) return fallback; + return value; +} + /** Mapping des types rclone supportant le flow OAuth intégré. */ const OAUTH_PROVIDERS: Record = { dropbox: 'Dropbox', @@ -28,6 +34,7 @@ const OAUTH_PROVIDERS: Record = { }; export function StandardRemoteFields({ fields, remoteType, register, watch, setValue, errors, isEdit }: Props) { + const { t, i18n } = useTranslation(); const oauthLabel = OAUTH_PROVIDERS[remoteType]; const clientId = (watch('config.client_id') ?? '') as string; const clientSecret = (watch('config.client_secret') ?? '') as string; @@ -35,7 +42,7 @@ export function StandardRemoteFields({ fields, remoteType, register, watch, setV return (
- Paramètres de connexion + {t('remotes.form.connectionParams')} {oauthLabel && ( @@ -45,8 +52,6 @@ export function StandardRemoteFields({ fields, remoteType, register, watch, setV clientId={clientId} clientSecret={clientSecret} onSuccess={(tokenJson) => { - // Le token rclone est stocké dans `config.token` (Dropbox/OneDrive) - // ou peut servir pour Drive si l'on passe en mode OAuth (rare en pratique). setValue('config.token', tokenJson, { shouldDirty: true, shouldValidate: true }); }} /> @@ -55,19 +60,31 @@ export function StandardRemoteFields({ fields, remoteType, register, watch, setV {fields.map((field) => { const fieldError = (errors.config as Record | undefined)?.[field.key]; const isPassword = field.type === 'password'; - const editPlaceholder = isPassword && isEdit ? '•••••• (laisser vide pour conserver)' : field.placeholder; + const labelKey = `remotes.types.${remoteType}.fields.${field.key}.label`; + const helpKey = `remotes.types.${remoteType}.fields.${field.key}.help`; + const placeholderKey = `remotes.types.${remoteType}.fields.${field.key}.placeholder`; + const label = translatedLabel(i18n.exists(labelKey), t(labelKey), field.label); + const help = translatedLabel(i18n.exists(helpKey), t(helpKey), field.helpText ?? ''); + const placeholder = translatedLabel(i18n.exists(placeholderKey), t(placeholderKey), field.placeholder ?? ''); + const editPlaceholder = isPassword && isEdit ? t('remotes.form.keepSecret') : placeholder || undefined; return (
{field.type === 'select' ? ( ) : ( @@ -81,10 +98,10 @@ export function StandardRemoteFields({ fields, remoteType, register, watch, setV /> )} - {field.helpText &&

{field.helpText}

} + {help &&

{help}

} {isEdit && isPassword && (

- Stocké de manière sécurisée. Laissez vide pour conserver la valeur actuelle. + {t('remotes.form.keepSecretHelp')}

)} {fieldError?.message &&

{fieldError.message}

} diff --git a/frontend/src/components/tasks/EncryptionFields.tsx b/frontend/src/components/tasks/EncryptionFields.tsx index f252c9e..761c66a 100644 --- a/frontend/src/components/tasks/EncryptionFields.tsx +++ b/frontend/src/components/tasks/EncryptionFields.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Lock, KeyRound, Info } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { KeygenModal } from './KeygenModal'; interface Props { @@ -15,6 +16,7 @@ interface Props { * et saisir / générer la clé publique age. */ export function EncryptionFields({ enabled, publicKey, onEnabledChange, onPublicKeyChange, error }: Props) { + const { t } = useTranslation(); const [keygenOpen, setKeygenOpen] = useState(false); const inputCls = 'w-full border border-surface-300 rounded-lg px-3 py-2.5 text-sm focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-shadow'; @@ -22,7 +24,7 @@ export function EncryptionFields({ enabled, publicKey, onEnabledChange, onPublic return (
- Chiffrement de la destination + {t('encryption.legend')} @@ -44,7 +45,7 @@ export function EncryptionFields({ enabled, publicKey, onEnabledChange, onPublic {enabled && (