From 31c35d7c761ba4de53241f551e15e5635ab84586 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 22 Jan 2026 19:56:31 +0200 Subject: [PATCH 01/33] docs: 86erqa08y Add engineering plan for Leitner Box Review System implementation, detailing backend architecture, logic, and frontend components. --- leitner-box-clarification.md | 169 +++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 leitner-box-clarification.md diff --git a/leitner-box-clarification.md b/leitner-box-clarification.md new file mode 100644 index 0000000..2f63118 --- /dev/null +++ b/leitner-box-clarification.md @@ -0,0 +1,169 @@ +# Leitner Box Review System - Implementation Clarification + +## Task Clarification: + +#### Objective: +Develop a Leitner Box-based flashcard review system capable of handling a large number of phrases efficiently while providing an effective learning and retention process. The system should include mechanisms for dynamically adjusting the number of boxes, categorizing phrases, tracking progress, and determining when phrases can be removed from the iteration. +#### Requirements: +1. **Dynamic Box Structure:** + * Implement a dynamic Leitner box system where the number of boxes can be expanded or split as needed to accommodate large volumes of phrases. + * Categories of phrases should have their own independent set of Leitner boxes. + * Ensure scalability for systems handling over 1,000 phrases. +2. **Phrase Review & Movement Logic:** + * Implement logic for moving phrases between boxes based on user performance. + * Correctly answered phrases progress to higher-numbered boxes with longer intervals. + * Incorrectly answered phrases are moved according to the "Dynamic Relegation" strategy: + * First incorrect answer: Move phrase to the previous box. + * Subsequent incorrect answer: Move phrase to the first box. +3. **Review Interval Management:** + * Use exponential intervals between reviews for higher-numbered boxes (e.g., 1 day, 2 days, 4 days, 8 days, etc.). + * Maintain separate review intervals for each category of phrases. +4. **Removal Criteria:** + * Define criteria for when a phrase can be removed from the iteration: + * Achieving a set number of consecutive correct reviews in the final box (e.g., 5 times). + * Passing a time-based threshold (e.g., 60 days) without errors in the final box. +5. **User Interface Considerations:** + * Ensure user-friendly display and management of phrases, categories, and boxes. + * Provide visual indicators of progress and retention success. +6. **Scalability:** + * Allow the system to handle both early-stage users with few phrases and advanced users with thousands of phrases. +7. **Data Persistence:** + * Ensure that user progress is saved and retrievable even after logging out or closing the application. +#### Outcome: +The final implementation should provide an efficient and scalable Leitner box system suitable for handling extensive phrase databases. Users should be able to add, categorize, review, and retire phrases effectively based on their progress. + + +## 1. Implamentation Overview +This document outlines the engineering plan for implementing the Leitner Box Review System within the `Subturtle` application, using `@modular-rest/server` on the backend and Vue.js on the frontend. + +## 2. Server-Side Architecture +We will implement two new modules in the `modules/` directory: + +### A. Module: `leitner_box` +This module handles all the core logic, data storage, and review calculations. + +**Files:** +- `db.ts`: Defines the separate database schema. +- `service.ts`: Contains the business logic (movement, intervals, bundle generation). +- `functions.ts`: Exposes API endpoints for the frontend. + +**Database Schema (Unified System):** +We will use a **separate database** `subturtle_leitner` for these collections. + +``` +// leitner_system collection (Database: subturtle_leitner) +{ + user: ObjectId (ref: 'user'), + settings: { + dailyLimit: Number, + totalBoxes: Number, // Default 5 (min 3, max 10) + }, + items: [ + { + phraseId: ObjectId, // Cross-database ref to subturtle_user_content.phrase + boxLevel: Number, // 1, 2, 3... + nextReviewDate: Date, + lastAttemptDate: Date + } + ] +} + +// review_bundles collection (Database: subturtle_leitner) +{ + user: ObjectId (ref: 'user'), + createdAt: Date, + type: 'daily' | 'manual', + status: 'pending' | 'completed' | 'expired', + items: [ + { + phraseId: ObjectId, + boxLevelAtGeneration: Number // FREEZE-FRAMED: stores the box level when generated + } + ] +} +``` +*Note: Using cross-database populate to link to phrases in `user_content`.* + +**Functions (`functions.ts`):** +- `getReviewBundle()`: Returns the current batch of phrases to review. +- `submitReviewResult(results: { phraseId: string, correct: boolean }[])`: Updates the box levels for reviewed items. +- `addPhraseToBox(phraseId: string)`: Manually adds a phrase to Box 1. +- `resetBox()`: Resets all progress. +- `getStats()`: Returns box distribution stats for the Dashboard. + +### B. Module: `schedule` (General Purpose) +This module manages background tasks using `node-schedule`. It is designed as a **shared service** that triggers API endpoints (internal webhooks) on a schedule. + +**Files:** +- `db.ts`: Stores scheduled jobs metadata (name, cronExpression, routePath, method, lastRun, status) for persistence and execution. +- `service.ts`: Wraps `node-schedule` and provides methods like `scheduleJob(name, cronExpression, routePath, method)`. It handles the HTTP request execution. +- `functions.ts`: API to create, list, delete, or manually triggers jobs. + +**Mechanism (Webhook/Route Based):** +- **Persistence**: The database stores the **Route Path** (e.g., `/api/v1/leitner-box/functions/generateDailyBundle`), HTTP method, and cron expression. +- **Execution**: When the schedule triggers, the service makes an internal HTTP request (Webhook) to the stored path. +- **Pros**: + - Solves the serialization issue (strings are easy to store). + - Decouples the schedule module from specific implementation details. + - Allows triggering any API endpoint in the system. + +### C. Initialization & Hooks +**Criterion**: "on login step the leitner system is initiated for user". + +**Implementation Strategy:** +We will use `authTriggers` passed to `createRest` in `server/src/index.ts`. +1. **Hook Point**: Add a new trigger to `server/src/triggers.ts`. +2. **Trigger Type**: Use the `login` trigger (if supported by `authTriggers`) or a similar session-based trigger. +3. **Action**: Call `LeitnerService.ensureInitialized(userId)` to ensure the user has their settings and initial box items ready. + +This keeps the initialization logic completely handled on the server side without requiring extra frontend calls after login. + +## 3. Logic & Algorithms + +### Phrase Movement (Leitner System) +- **Correct**: `Current Box + 1`. New `nextReviewDate` = `now + (2 ^ (newBox - 1))` days. +- **Incorrect (First)**: `Current Box - 1` (min 1). +- **Incorrect (Subsequent)**: `Box 1`. +- **Completion**: If Box > `MaxBox` (e.g., 5) OR `TimeInBox` > `Threshold` -> Remove from review cycle (mark as "Mastered"). + +### Universal Reporting & Unified System +- **Unified System**: There is NO separation of boxes by phrase type or category. All phrases live in the same "User's Leitner System". +- **Auto-Entry**: Any phrase reviewed by the user (in a specific Bundle or Daily Review) enters the system. + - **Logic**: + - **If Phrase Exists**: Update box level based on result. + - **If Phrase is New**: Add to **Box 1** and proceed with standard logic. + +### Review Bundles +- **Generation**: + - `node-schedule` triggers daily generation (webhook). + - Finds items where `nextReviewDate <= Now`. + - Creates a `ReviewBundle` document. + - **Freeze-Framed**: The bundle stores a snapshot of the `boxLevel` for each item at the moment of generation. This ensures that even if box settings change mid-bundle, the review session remains consistent. + - Limit: `MaxBundles` (e.g., 3). If full, oldest *unstarted* bundle is replaced. *In-progress* bundles are kept. + +### Settings Change Logic +- **Changing Total Boxes**: + - **Increase**: No immediate action. Items flow into higher boxes naturally. + - **Decrease** (e.g., 5 -> 3): All items currently in boxes > 3 (4, 5) are **moved to Box 3**. Their intervals are adjusted to Box 3's settings. + - Min total is 3 max is 10. + +## 4. Frontend Implementation (Vue.js) + +**Pages:** +- `pages/practice/flashcards-[id].vue`: **Universal Player**. + - **Adaptation**: + - Accepts optional `type=leitner` query param. + - **UI Updates**: Add "Known" (Check) and "Unknown" (X) buttons. + - **Logic**: Reports result to the Leitner system on every rating. +- `pages/practice/review.vue`: **Review Dashboard**. + - Shows current bundles and stats. + - Links to the Universal Player for review sessions. +- `pages/settings/preferences.vue`: **User Preferences**. + - Contains `LeitnerSettings` for "Daily Limit" and "Total Boxes". + +**Components:** +- `LeitnerDashboard`: Visualization of box distribution. +- `LeitnerSettings`: Configuration form. + +**Initialization**: +- Managed server-side via `authTriggers`. No frontend init call required. From b4a806af7f04b5f8497b2dba76a3ee7c96c54657 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 22 Jan 2026 21:31:12 +0200 Subject: [PATCH 02/33] feat: #86erqa08y Implement Leitner Box Spaced Repetition System Server: - Implement LeitnerService for managing spaced repetition logic (box movement, scheduling). - Add database schemas for `leitner_system` and `review_bundle`. - Add API functions: `get-review-bundle`, `submit-review-result`, `get-stats`, `update-settings`. - Implement `generate-daily-bundles` for daily review generation. - Service: Initialize user Leitner system on login (Auth Router). - Data: Centralize database and collection configuration in [config.ts](cci:7://file:///Users/navid-shad/Projects/CodeBridger/learn-by-subtitle/dashboard-app/server/src/config.ts:0:0-0:0). - Schedule: Integrate `node-schedule` for managing background jobs. Frontend: - Feature: Add 'Review' page to sidebar with Leitner box stats and Recent AI Lectures. - Feature: Add 'Preferences' page for Leitner settings (Daily Limit, Total Boxes). - Update: Enhance Flashcards component to support 'Leitner' mode with Known/Unknown feedback. - Config: Add localization keys for Review dashboard. --- .../composables/useDashboardNavigatorItems.ts | 5 + frontend/locales/en.json | 9 +- frontend/pages/practice/flashcards-[id].vue | 109 ++++++- frontend/pages/practice/review.vue | 180 +++++++++++ frontend/pages/settings/preferences.vue | 110 +++++++ server/src/config.ts | 9 + server/src/index.ts | 6 + server/src/modules/auth/router.ts | 23 ++ server/src/modules/leitner_box/db.ts | 98 ++++++ server/src/modules/leitner_box/functions.ts | 65 ++++ server/src/modules/leitner_box/service.ts | 289 ++++++++++++++++++ server/src/modules/phrase_bundle/db.ts | 2 +- server/src/modules/phrase_bundle/triggers.ts | 16 + server/src/modules/schedule/db.ts | 42 +++ server/src/modules/schedule/functions.ts | 41 +++ server/src/modules/schedule/service.ts | 115 +++++++ server/src/triggers.ts | 9 +- 17 files changed, 1115 insertions(+), 13 deletions(-) create mode 100644 frontend/pages/practice/review.vue create mode 100644 frontend/pages/settings/preferences.vue create mode 100644 server/src/modules/leitner_box/db.ts create mode 100644 server/src/modules/leitner_box/functions.ts create mode 100644 server/src/modules/leitner_box/service.ts create mode 100644 server/src/modules/schedule/db.ts create mode 100644 server/src/modules/schedule/functions.ts create mode 100644 server/src/modules/schedule/service.ts diff --git a/frontend/composables/useDashboardNavigatorItems.ts b/frontend/composables/useDashboardNavigatorItems.ts index e2fa22e..6ea8663 100644 --- a/frontend/composables/useDashboardNavigatorItems.ts +++ b/frontend/composables/useDashboardNavigatorItems.ts @@ -17,6 +17,11 @@ export const useDashboardNavigatorItems = (): Array => { icon: 'IconMenuDatatables', to: '/bundles', }, + { + title: t('review.title'), + icon: 'IconMenuScrumboard', + to: '/practice/review', + }, ], }, { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 9088304..75ec5ea 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -219,5 +219,10 @@ "confirm-sign-out": "Confirm Sign Out", "confirm-sign-out-message": "Are you sure you want to sign out of your account?", "save-changes": "Save Changes", - "coming-soon": "Coming soon" -} + "coming-soon": "Coming soon", + "review": { + "title": "Review", + "recent-lectures": "Recent AI Lectures", + "view-all": "View All" + } +} \ No newline at end of file diff --git a/frontend/pages/practice/flashcards-[id].vue b/frontend/pages/practice/flashcards-[id].vue index 8d62868..8676142 100644 --- a/frontend/pages/practice/flashcards-[id].vue +++ b/frontend/pages/practice/flashcards-[id].vue @@ -42,17 +42,40 @@ -
+
-
+ +
+ + + +
+ +
-
+
@@ -61,9 +84,11 @@ diff --git a/frontend/pages/practice/review.vue b/frontend/pages/practice/review.vue new file mode 100644 index 0000000..100413f --- /dev/null +++ b/frontend/pages/practice/review.vue @@ -0,0 +1,180 @@ + + + diff --git a/frontend/pages/settings/preferences.vue b/frontend/pages/settings/preferences.vue new file mode 100644 index 0000000..9543714 --- /dev/null +++ b/frontend/pages/settings/preferences.vue @@ -0,0 +1,110 @@ + + + diff --git a/server/src/config.ts b/server/src/config.ts index 879b973..c09c565 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -20,3 +20,12 @@ export const FREEMIUM_DEFAULT_CREDITS = 5000000; // 5M credits export const FREEMIUM_DEFAULT_SAVE_WORDS = 50; // 50 words can be saved export const FREEMIUM_DEFAULT_LIVED_SESSIONS = 3; // 3 lived sessions can be created export const FREEMIUM_DURATION_DAYS = 30; // 1 month + +// Schedule +export const DATABASE_SCHEDULE = "cms"; +export const SCHEDULE_JOB_COLLECTION = "job"; + +// Leitner System (Generative) +export const DATABASE_GENERATIVE = "generative"; +export const LEITNER_SYSTEM_COLLECTION = "leitner_system"; +export const LEITNER_REVIEW_BUNDLE_COLLECTION = "leitner_review_bundle"; diff --git a/server/src/index.ts b/server/src/index.ts index d5f83d8..d8ba8fc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -95,6 +95,12 @@ const app = createRest({ }, permissionGroups, authTriggers: authTriggers, +}).then((app) => { + // Initialize Schedule Service + const { + ScheduleService, + } = require("./modules/schedule/service"); + ScheduleService.init(); }).catch((err) => { console.error(err); process.exit(1); diff --git a/server/src/modules/auth/router.ts b/server/src/modules/auth/router.ts index 07676e5..5c5b1ad 100644 --- a/server/src/modules/auth/router.ts +++ b/server/src/modules/auth/router.ts @@ -2,6 +2,7 @@ import Router from "koa-router"; import { reply, userManager } from "@modular-rest/server"; import { google } from "googleapis"; import { updateUserProfile } from "../profile/service"; +import { LeitnerService } from "../leitner_box/service"; const name = "auth"; const auth = new Router(); @@ -127,6 +128,13 @@ auth.get("/google/code-login", async (ctx) => { false ); + // Initialize Leitner System + try { + await LeitnerService.ensureInitialized(userId as string); + } catch (e) { + console.error("Failed to initialize Leitner System on login", e); + } + const token = await userManager.issueTokenForUser(email); // Build redirect URL with token and optional redirect parameter @@ -175,6 +183,21 @@ auth.get("/google/access-token-login", async (ctx) => { const token = await userManager.issueTokenForUser(googleEmail); + // Initialize Leitner System + if (registeredUser || (await userManager.getUserByIdentity(googleEmail, "email").catch(() => null))) { + // We need the userId. If registeredUser was null but we just registered, fetch ID? + // userManager.registerUser returns userId. + // Re-fetching user to get ID if we didn't have it (though we just registered it). + const user = await userManager.getUserByIdentity(googleEmail, "email"); + if (user) { + try { + await LeitnerService.ensureInitialized(user.id); + } catch (e) { + console.error("Failed to initialize Leitner System on token login", e); + } + } + } + ctx.body = reply.create("s", { token }); }); diff --git a/server/src/modules/leitner_box/db.ts b/server/src/modules/leitner_box/db.ts new file mode 100644 index 0000000..694a8b3 --- /dev/null +++ b/server/src/modules/leitner_box/db.ts @@ -0,0 +1,98 @@ +import { Schema, defineCollection } from "@modular-rest/server"; +import { DATABASE_GENERATIVE, LEITNER_SYSTEM_COLLECTION, LEITNER_REVIEW_BUNDLE_COLLECTION } from "../../config"; + +interface LeitnerSystemSchema { + refId: string; // user id + settings: { + dailyLimit: number; + totalBoxes: number; // Default 5 (min 3, max 10) + }; + items: Array<{ + phraseId: string; // ref to phrase refId or string ID + boxLevel: number; + nextReviewDate: Date; + lastAttemptDate: Date; + }>; +} + +interface ReviewBundleSchema { + refId: string; // user id + createdAt: Date; + type: "daily" | "manual"; + status: "pending" | "completed" | "expired"; + items: Array<{ + phraseId: string; + boxLevelAtGeneration: number; + }>; +} + +const leitnerSystemSchema = new Schema( + { + refId: { type: String, required: true, unique: true }, + settings: { + dailyLimit: { type: Number, default: 15 }, + totalBoxes: { type: Number, default: 5, min: 3, max: 10 }, + }, + items: [ + { + phraseId: { type: String, required: true }, + boxLevel: { type: Number, default: 1 }, + nextReviewDate: Date, + lastAttemptDate: Date, + consecutiveFailures: { type: Number, default: 0 }, + }, + ], + }, + { timestamps: true } +); + +const reviewBundleSchema = new Schema( + { + refId: { type: String, required: true }, + type: { type: String, enum: ["daily", "manual"], default: "daily" }, + status: { + type: String, + enum: ["pending", "completed", "expired"], + default: "pending", + }, + items: [ + { + phraseId: String, + boxLevelAtGeneration: Number, + }, + ], + }, + { timestamps: true } +); + +const leitnerSystemCollection = defineCollection({ + database: DATABASE_GENERATIVE, + collection: LEITNER_SYSTEM_COLLECTION, + schema: leitnerSystemSchema, + permissions: [ + { + accessType: "user_access", + read: true, + write: false, // Only modified via service/functions + onlyOwnData: true, + ownerIdField: "refId", + }, + ], +}); + +const reviewBundleCollection = defineCollection({ + database: DATABASE_GENERATIVE, + collection: LEITNER_REVIEW_BUNDLE_COLLECTION, + schema: reviewBundleSchema, + permissions: [ + { + accessType: "user_access", + read: true, + write: true, + onlyOwnData: true, + ownerIdField: "refId", + }, + ], +}); + +module.exports = [leitnerSystemCollection, reviewBundleCollection]; diff --git a/server/src/modules/leitner_box/functions.ts b/server/src/modules/leitner_box/functions.ts new file mode 100644 index 0000000..da5ca21 --- /dev/null +++ b/server/src/modules/leitner_box/functions.ts @@ -0,0 +1,65 @@ +import { defineFunction } from "@modular-rest/server"; +import { LeitnerService } from "./service"; + +const SYSTEM_SECRET = process.env.SYSTEM_SECRET || "default_system_secret_key"; + +const getReviewBundle = defineFunction({ + name: "get-review-bundle", + permissionTypes: ["user_access"], + callback: async (params: any) => { + // Assuming params contains userId if user_access is enabled/injected + // or we might need to rely on the fact that for user_access, the caller should pass userId? + // Actually, secure way is ctx.user. + // If modular-rest injects user into params, we use it. + // Based on profile/functions.ts, it uses { userId } = params. + const { userId } = params; + return await LeitnerService.getReviewBundle(userId); + }, +}); + +const submitReviewResult = defineFunction({ + name: "submit-review-result", + permissionTypes: ["user_access"], + callback: async (params: any) => { + const { userId, results } = params; + await LeitnerService.submitReviewResult(userId, results); + return { success: true }; + }, +}); + +const generateDailyBundles = defineFunction({ + name: "generate-daily-bundles", + permissionTypes: ["anonymous_access"], // Cron triggers this + callback: async (params: any) => { + // Security check + const { system_secret } = params; + if (system_secret !== SYSTEM_SECRET) { + throw new Error("Unauthorized system call"); + } + + await LeitnerService.generateDailyBundles(); + return { success: true }; + }, +}); + +const getStats = defineFunction({ + name: "get-stats", + permissionTypes: ["user_access"], + callback: async (params: any) => { + const { userId } = params; + return await LeitnerService.getStats(userId); + } +}); + +const updateSettings = defineFunction({ + name: "update-settings", + permissionTypes: ["user_access"], + callback: async (params: any) => { + const { userId, settings } = params; + // settings: { dailyLimit, totalBoxes } + await LeitnerService.updateSettings(userId, settings); + return { success: true }; + } +}); + +module.exports.functions = [getReviewBundle, submitReviewResult, generateDailyBundles, getStats, updateSettings]; diff --git a/server/src/modules/leitner_box/service.ts b/server/src/modules/leitner_box/service.ts new file mode 100644 index 0000000..f9d6579 --- /dev/null +++ b/server/src/modules/leitner_box/service.ts @@ -0,0 +1,289 @@ +import { getCollection, defineFunction } from "@modular-rest/server"; +import { DATABASE as USER_DB, PHRASE_COLLECTION, DATABASE_GENERATIVE, LEITNER_REVIEW_BUNDLE_COLLECTION, LEITNER_SYSTEM_COLLECTION } from "../../config"; + +export class LeitnerService { + static async ensureInitialized(userId: string) { + const leitnerSystem = await getCollection( + DATABASE_GENERATIVE, + LEITNER_SYSTEM_COLLECTION + ); + const existing = await leitnerSystem.findOne({ refId: userId }); + + if (!existing) { + await leitnerSystem.create({ + refId: userId, + settings: { dailyLimit: 20, totalBoxes: 5 }, + items: [], + }); + } + } + + static async getReviewBundle(userId: string) { + await this.ensureInitialized(userId); + const reviewBundle = await getCollection(DATABASE_GENERATIVE, LEITNER_REVIEW_BUNDLE_COLLECTION); + // Find pending bundle + // Sort by createdAt desc to get latest? + const pending = await reviewBundle.findOne( + { refId: userId, status: "pending" }, + null, + { sort: { createdAt: -1 } } + ) as any; + + if (!pending) return null; + + // Populate phrases logic + // We have phraseIds. We need to fetch phrase details from USER_DB. + const phraseCollection = await getCollection(USER_DB, PHRASE_COLLECTION); + const phraseIds = pending.items.map((i: any) => i.phraseId); + + // Manual pseudo-population + // Assuming phraseId is the _id of the phrase doc + const phrases = await phraseCollection.find({ _id: { $in: phraseIds } }); + + // Map phrases back to items + const populatedItems = pending.items.map((item: any) => { + const phraseParams = phrases.find((p: any) => String(p._id) === String(item.phraseId)); + return { + ...item, + phrase: phraseParams + }; + }); + + return { + ...pending.toJSON(), + items: populatedItems + }; + } + + static async addPhraseToBox( + userId: string, + phraseId: string, + boxLevel: number = 1 + ) { + const leitnerSystem = await getCollection( + DATABASE_GENERATIVE, + LEITNER_SYSTEM_COLLECTION + ); + await this.ensureInitialized(userId); + + const doc = await leitnerSystem.findOne({ refId: userId }) as any; + const exists = doc.items.find((i: any) => i.phraseId === phraseId); + + if (exists) { + if (boxLevel === 1) { + // Reset logic + await leitnerSystem.updateOne( + { refId: userId, "items.phraseId": phraseId }, + { + $set: { + "items.$.boxLevel": 1, + "items.$.nextReviewDate": new Date(), + "items.$.consecutiveFailures": 0, + }, + } + ); + } + } else { + await leitnerSystem.updateOne( + { refId: userId }, + { + $push: { + items: { + phraseId, + boxLevel, + nextReviewDate: new Date(), + lastAttemptDate: null, + consecutiveFailures: 0, + }, + }, + } + ); + } + } + + static async submitReviewResult( + userId: string, + results: { phraseId: string; correct: boolean }[] + ) { + const leitnerSystem = await getCollection( + DATABASE_GENERATIVE, + LEITNER_SYSTEM_COLLECTION + ); + const doc = await leitnerSystem.findOne({ refId: userId }) as any; + + if (!doc) return; // Should not happen if initialized + + const updates = []; + + for (const res of results) { + let item = doc.items.find((i: any) => i.phraseId === res.phraseId); + + let currentBox = item ? item.boxLevel : 1; + let consecutiveFailures = item ? item.consecutiveFailures || 0 : 0; + let newBox = currentBox; + let newFailures = 0; + let nextDate = new Date(); + + if (res.correct) { + newBox = Math.min(doc.settings.totalBoxes, currentBox + 1); + newFailures = 0; + const days = Math.pow(2, newBox - 1); + nextDate.setDate(nextDate.getDate() + days); + } else { + if (consecutiveFailures > 0) { + newBox = 1; + newFailures = 0; + } else { + newBox = Math.max(1, currentBox - 1); + newFailures = 1; + } + // Review ASAP (tomorrow?) or same logic? + // Logic: "Move phrase to previous box". + // Implicitly it adopts the schedule of the new box. + const days = Math.pow(2, newBox - 1); + nextDate.setDate(nextDate.getDate() + days); + } + + if (item) { + await leitnerSystem.updateOne( + { refId: userId, "items.phraseId": res.phraseId }, + { + $set: { + "items.$.boxLevel": newBox, + "items.$.consecutiveFailures": newFailures, + "items.$.nextReviewDate": nextDate, + "items.$.lastAttemptDate": new Date(), + }, + } + ); + } else { + await leitnerSystem.updateOne( + { refId: userId }, + { + $push: { + items: { + phraseId: res.phraseId, + boxLevel: newBox, + consecutiveFailures: newFailures, + nextReviewDate: nextDate, + lastAttemptDate: new Date(), + }, + }, + } + ); + } + } + + // Complete the pending bundle if any + const reviewBundle = await getCollection(DATABASE_GENERATIVE, LEITNER_REVIEW_BUNDLE_COLLECTION); + await reviewBundle.updateOne( + { refId: userId, status: 'pending' }, + { $set: { status: 'completed' } } + ); + } + + static async generateDailyBundles() { + // Iterate all leitner systems + const leitnerSystem = await getCollection( + DATABASE_GENERATIVE, + LEITNER_SYSTEM_COLLECTION + ); + const reviewBundle = await getCollection(DATABASE_GENERATIVE, LEITNER_REVIEW_BUNDLE_COLLECTION); + + // This might be heavy if many users. For now, find all. + const allSystems = await leitnerSystem.find({}) as any[]; + + for (const sys of allSystems) { + const userId = sys.refId; + const limit = sys.settings.dailyLimit; + const now = new Date(); + + // Find items due + const dueItems = sys.items.filter((i: any) => new Date(i.nextReviewDate) <= now); + + // Requirements: "Creates a ReviewBundle... Limit: MaxBundles (e.g. 3)" + // Check existing pending bundles + const pendingCount = await reviewBundle.countDocuments({ refId: userId, status: 'pending' }); + if (pendingCount >= 3) { + // "Oldest unstarted bundle is replaced" + // Wait, "unstarted". status 'pending' implies unstarted? + // "In-progress bundles are kept". We don't distinguish pending vs in-progress in schema yet. + // Using 'pending' as unstarted. + const oldest = await reviewBundle.find({ refId: userId, status: 'pending' }, null, { sort: { createdAt: 1 }, limit: 1 }); + if (oldest.length > 0) { + await reviewBundle.deleteOne({ _id: oldest[0]._id }); + } + } + + if (dueItems.length === 0) continue; + + const selection = dueItems.slice(0, limit); + + const bundleItems = selection.map((i: any) => ({ + phraseId: i.phraseId, + boxLevelAtGeneration: i.boxLevel + })); + + await reviewBundle.create({ + refId: userId, + type: 'daily', + status: 'pending', + items: bundleItems, + createdAt: new Date() // Schema has timestamps but explicit is fine + }); + } + return { success: true }; + } + + static async getStats(userId: string) { + await this.ensureInitialized(userId); + const leitnerSystem = await getCollection( + DATABASE_GENERATIVE, + LEITNER_SYSTEM_COLLECTION + ); + const doc = await leitnerSystem.findOne({ refId: userId }) as any; + if (!doc) return { boxes: {} }; + + const boxes: Record = {}; + // Initialize standard boxes 1-5 (or doc.settings.totalBoxes) + for (let i = 1; i <= (doc.settings?.totalBoxes || 5); i++) { + boxes[i] = 0; + } + + doc.items.forEach((item: any) => { + const box = item.boxLevel || 1; + boxes[box] = (boxes[box] || 0) + 1; + }); + + return { boxes, totalPhrases: doc.items.length, settings: doc.settings }; + } + + static async updateSettings(userId: string, settings: { dailyLimit?: number, totalBoxes?: number }) { + await this.ensureInitialized(userId); + const leitnerSystem = await getCollection(DATABASE_GENERATIVE, LEITNER_SYSTEM_COLLECTION); + const doc = await leitnerSystem.findOne({ refId: userId }) as any; + + if (!doc) return { success: false }; + + const newSettings = { ...doc.settings, ...settings }; + + // Logic for reducing totalBoxes + if (settings.totalBoxes && settings.totalBoxes < doc.settings.totalBoxes) { + const newMax = settings.totalBoxes; + + await leitnerSystem.updateMany( + { refId: userId, "items.boxLevel": { $gt: newMax } }, + { $set: { "items.$[elem].boxLevel": newMax } }, + { arrayFilters: [{ "elem.boxLevel": { $gt: newMax } }] } + ); + } + + await leitnerSystem.updateOne( + { refId: userId }, + { $set: { settings: newSettings } } + ); + + return { success: true }; + } +} + diff --git a/server/src/modules/phrase_bundle/db.ts b/server/src/modules/phrase_bundle/db.ts index b697914..59a436e 100644 --- a/server/src/modules/phrase_bundle/db.ts +++ b/server/src/modules/phrase_bundle/db.ts @@ -129,7 +129,7 @@ const phraseBundleSchema = new Schema( phrases: [ { type: Schema.Types.ObjectId, - ref: "phrase", + ref: PHRASE_COLLECTION, }, ], }, diff --git a/server/src/modules/phrase_bundle/triggers.ts b/server/src/modules/phrase_bundle/triggers.ts index 24dc097..e0fbab3 100644 --- a/server/src/modules/phrase_bundle/triggers.ts +++ b/server/src/modules/phrase_bundle/triggers.ts @@ -1,4 +1,5 @@ import { DatabaseTrigger } from "@modular-rest/server"; +import { LeitnerService } from "../leitner_box/service"; import { isUserOnFreemium, updateFreemiumAllocation, @@ -20,6 +21,19 @@ export const phraseBundleTriggers = [ }, }); } + + // When a new phrase is created, add it to the Leitner system + // + // Ensure this is a phrase being inserted, not a bundle + // We can check for specific phrase fields or just try-catch + // But typically this trigger file is attached to phraseCollection too + if (doc && doc.phrase && doc.translation && doc.refId) { + try { + await LeitnerService.addPhraseToBox(doc.refId, doc._id, 1); + } catch (e) { + console.error("Failed to add phrase to Leitner system", e); + } + } }), // When a phrase bundle is deleted, @@ -36,4 +50,6 @@ export const phraseBundleTriggers = [ }); } }), + + ]; diff --git a/server/src/modules/schedule/db.ts b/server/src/modules/schedule/db.ts new file mode 100644 index 0000000..962a622 --- /dev/null +++ b/server/src/modules/schedule/db.ts @@ -0,0 +1,42 @@ +import { Schema, defineCollection, Permission } from "@modular-rest/server"; +import { DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION } from "../../config"; + +interface ScheduleJobSchema { + name: string; + cronExpression: string; + routePath: string; + method: string; + lastRun?: Date; + status: "active" | "inactive" | "failed"; +} + +const scheduleJobSchema = new Schema( + { + name: { type: String, required: true, unique: true }, + cronExpression: { type: String, required: true }, + routePath: { type: String, required: true }, + method: { type: String, default: "POST" }, + lastRun: Date, + status: { + type: String, + enum: ["active", "inactive", "failed"], + default: "active", + }, + }, + { timestamps: true } +); + +const scheduleJobCollection = defineCollection({ + database: DATABASE_SCHEDULE, + collection: SCHEDULE_JOB_COLLECTION, + schema: scheduleJobSchema, + permissions: [ + new Permission({ + accessType: "public_access", // Internal service, but let's secure it later if needed. For now admin usage. + read: true, + write: true, + }), + ], +}); + +module.exports = [scheduleJobCollection]; diff --git a/server/src/modules/schedule/functions.ts b/server/src/modules/schedule/functions.ts new file mode 100644 index 0000000..2972591 --- /dev/null +++ b/server/src/modules/schedule/functions.ts @@ -0,0 +1,41 @@ +import { defineFunction } from "@modular-rest/server"; +import { ScheduleService } from "./service"; +import { getCollection } from "@modular-rest/server"; +import { DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION } from "../../config"; + +const createJob = defineFunction({ + name: "create-job", + permissionTypes: ["admin"], // Assuming admin usage + callback: async (params: any) => { + const { name, cronExpression, routePath, method } = params; + await ScheduleService.createJob( + name, + cronExpression, + routePath, + method || "POST" + ); + return { success: true }; + }, +}); + +const deleteJob = defineFunction({ + name: "delete-job", + permissionTypes: ["admin"], + callback: async (params: any) => { + const { name } = params; + await ScheduleService.deleteJob(name); + return { success: true }; + }, +}); + +const listJobs = defineFunction({ + name: "list-jobs", + permissionTypes: ["admin"], + callback: async () => { + const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); + const jobs = await collection.find({}); + return jobs; + }, +}); + +module.exports.functions = [createJob, deleteJob, listJobs]; diff --git a/server/src/modules/schedule/service.ts b/server/src/modules/schedule/service.ts new file mode 100644 index 0000000..a6357de --- /dev/null +++ b/server/src/modules/schedule/service.ts @@ -0,0 +1,115 @@ +import schedule from "node-schedule"; +import { getCollection } from "@modular-rest/server"; +import { DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION } from "../../config"; + +const PORT = process.env.PORT || "8080"; +const BASE_URL = `http://localhost:${PORT}`; +const SYSTEM_SECRET = process.env.SYSTEM_SECRET || "default_system_secret_key"; + +export class ScheduleService { + static async init() { + try { + const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); + const jobs = await collection.find({ status: "active" }); + console.log(`[ScheduleService] Initializing ${jobs.length} jobs...`); + for (const job of jobs) { + this.scheduleJobInternal(job as any); + } + } catch (error) { + console.error("[ScheduleService] Init failed", error); + } + } + + static async createJob( + name: string, + cronExpression: string, + routePath: string, + method: string = "POST" + ) { + const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); + const existing = await collection.findOne({ name }); + + const jobData = { + name, + cronExpression, + routePath, + method, + status: "active" as const, + }; + + if (existing) { + await collection.updateOne( + { name }, + { $set: { cronExpression, routePath, method, status: "active" } } + ); + this.cancelJob(name); + } else { + await collection.create(jobData); + } + + this.scheduleJobInternal(jobData); + return jobData; + } + + static cancelJob(name: string) { + const job = schedule.scheduledJobs[name]; + if (job) { + job.cancel(); + } + } + + static async deleteJob(name: string) { + this.cancelJob(name); + const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); + await collection.deleteOne({ name }); + } + + static scheduleJobInternal(jobData: { + name: string; + cronExpression: string; + routePath: string; + method: string; + }) { + // Cancel existing if any (safety check) + if (schedule.scheduledJobs[jobData.name]) { + schedule.scheduledJobs[jobData.name].cancel(); + } + + schedule.scheduleJob(jobData.name, jobData.cronExpression, async () => { + console.log(`[ScheduleService] Triggering job: ${jobData.name}`); + try { + const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); + await collection.updateOne( + { name: jobData.name }, + { $set: { lastRun: new Date() } } + ); + + // Append base url if relative path + const url = jobData.routePath.startsWith("http") + ? jobData.routePath + : `${BASE_URL}${jobData.routePath}`; + + const response = await fetch(url, { + method: jobData.method, + headers: { + "Content-Type": "application/json", + "x-system-secret": SYSTEM_SECRET, + }, + body: ["POST", "PUT", "PATCH"].includes(jobData.method.toUpperCase()) + ? JSON.stringify({ system_secret: SYSTEM_SECRET }) + : undefined + }); + + if (!response.ok) { + console.error( + `[ScheduleService] Job ${jobData.name} failed: ${response.status} ${response.statusText}` + ); + } else { + console.log(`[ScheduleService] Job ${jobData.name} executed successfully.`); + } + } catch (e) { + console.error(`[ScheduleService] Job ${jobData.name} execution error`, e); + } + }); + } +} diff --git a/server/src/triggers.ts b/server/src/triggers.ts index 981dbde..d4bc770 100644 --- a/server/src/triggers.ts +++ b/server/src/triggers.ts @@ -1,9 +1,11 @@ import { CmsTrigger, getCollection } from "@modular-rest/server"; +import { LeitnerService } from "./modules/leitner_box/service"; +import { DATABASE, BUNDLE_COLLECTION, PHRASE_COLLECTION } from "./config"; export const authTriggers: CmsTrigger[] = [ new CmsTrigger("insert-one", async (context) => { - const bundleCollection = getCollection("user_content", "phrase_bundle"); - const phraseCollection = getCollection("user_content", "phrase"); + const bundleCollection = getCollection(DATABASE, BUNDLE_COLLECTION); + const phraseCollection = getCollection(DATABASE, PHRASE_COLLECTION); const userId = context.queryResult._id; @@ -137,5 +139,8 @@ export const authTriggers: CmsTrigger[] = [ { $push: { phrases: { $each: phraseIds } } } ); } + + // Initialize Leitner System (for new users) + await LeitnerService.ensureInitialized(userId); }), ]; From 8a99fb87678dc6bfc872b66ec37d336d62660be8 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 22 Jan 2026 22:49:35 +0200 Subject: [PATCH 03/33] feat(schedule): implement schedule service and tests CU-86erqa08y --- .../schedule/__tests__/service.test.ts | 263 ++++++++++++++++++ server/src/modules/schedule/db.ts | 36 ++- server/src/modules/schedule/functions.ts | 22 +- server/src/modules/schedule/service.ts | 162 +++++++---- 4 files changed, 423 insertions(+), 60 deletions(-) create mode 100644 server/src/modules/schedule/__tests__/service.test.ts diff --git a/server/src/modules/schedule/__tests__/service.test.ts b/server/src/modules/schedule/__tests__/service.test.ts new file mode 100644 index 0000000..881fe70 --- /dev/null +++ b/server/src/modules/schedule/__tests__/service.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals"; +import { ScheduleService } from "../service"; +import schedule from "node-schedule"; +import { getCollection } from "@modular-rest/server"; + +// Mock modular-rest/server +jest.mock("@modular-rest/server", () => ({ + getCollection: jest.fn(), + Schema: class { }, + defineCollection: jest.fn(), + Permission: class { }, +})); + +// Mock node-schedule +jest.mock("node-schedule", () => ({ + scheduleJob: jest.fn(), + scheduledJobs: {}, +})); + +describe("ScheduleService", () => { + let mockCollection: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockCollection = { + find: jest.fn(), + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + updateOne: jest.fn(), + create: jest.fn(), + deleteOne: jest.fn(), + }; + (getCollection as any).mockResolvedValue(mockCollection); + + // Reset registry + (ScheduleService as any).registry = new Map(); + (ScheduleService as any).processingQueue = false; + }); + + describe("register", () => { + it("should add a function to the registry", () => { + const callback = jest.fn() as any; + ScheduleService.register("test-fn", callback); + expect((ScheduleService as any).registry.get("test-fn")).toBe(callback); + }); + }); + + describe("init", () => { + it("should initialize active jobs from the database", async () => { + const mockJobs = [ + { name: "job1", cronExpression: "* * * * *", functionId: "fn1", args: {}, status: "active", jobType: "recurrent" }, + { name: "job2", cronExpression: "0 0 * * *", functionId: "fn2", args: { x: 1 }, status: "active", jobType: "recurrent" }, + ]; + mockCollection.find.mockResolvedValue(mockJobs); + + await ScheduleService.init(); + + expect(mockCollection.find).toHaveBeenCalledWith({ status: "active" }); + expect(schedule.scheduleJob).toHaveBeenCalledTimes(2); + }); + }); + + describe("createJob", () => { + it("should create a new job if it doesn't exist", async () => { + mockCollection.findOne.mockResolvedValue(null); + const options = { + cronExpression: "* * * * *", + functionId: "test-fn", + args: { foo: "bar" }, + executionType: "normal" as const, + jobType: "recurrent" as const, + }; + + await ScheduleService.createJob("new-job", "test-fn", options); + + expect(mockCollection.create).toHaveBeenCalledWith(expect.objectContaining({ + name: "new-job", + functionId: "test-fn", + state: "scheduled", + status: "active", + })); + expect(schedule.scheduleJob).toHaveBeenCalled(); + }); + + it("should update an existing job", async () => { + mockCollection.findOne.mockResolvedValue({ name: "existing-job" }); + const options = { + cronExpression: "0 0 * * *", + functionId: "updated-fn", + }; + + await ScheduleService.createJob("existing-job", "updated-fn", options); + + expect(mockCollection.updateOne).toHaveBeenCalledWith( + { name: "existing-job" }, + expect.objectContaining({ $set: expect.objectContaining({ status: "active" }) }) + ); + expect(schedule.scheduleJob).toHaveBeenCalled(); + }); + }); + + describe("deleteJob", () => { + it("should cancel moving job and delete from database", async () => { + const mockJob = { cancel: jest.fn() }; + (schedule.scheduledJobs as any)["job-to-delete"] = mockJob; + + await ScheduleService.deleteJob("job-to-delete"); + + expect(mockJob.cancel).toHaveBeenCalled(); + expect(mockCollection.deleteOne).toHaveBeenCalledWith({ name: "job-to-delete" }); + }); + }); + + describe("Execution Logic", () => { + it("should execute Immediate jobs immediately after claiming", async () => { + const jobData = { + name: "immediate-job", + functionId: "fn", + executionType: "Immediate", + jobType: "recurrent", + cronExpression: "* * * * *", + state: "scheduled", + status: "active" + }; + + const callback = jest.fn() as any; + callback.mockResolvedValue(undefined); + ScheduleService.register("fn", callback); + + // Mock scheduleJob callback trigger + let jobCallback: any; + (schedule.scheduleJob as jest.Mock).mockImplementation((name, cron, cb) => { + jobCallback = cb; + return { cancel: jest.fn() }; + }); + + await ScheduleService.scheduleJobInternal(jobData); + + // Mock the findOneAndUpdate result for claiming + mockCollection.findOneAndUpdate.mockResolvedValue({ + _id: "id1", + ...jobData, + state: "executing" + }); + + // Trigger the scheduled job + await jobCallback(); + + expect(mockCollection.findOneAndUpdate).toHaveBeenCalled(); + expect(callback).toHaveBeenCalled(); + expect(mockCollection.updateOne).toHaveBeenCalledWith( + { _id: "id1" }, + expect.objectContaining({ $set: expect.objectContaining({ state: "scheduled" }) }) + ); + }); + + it("should handle 'once' jobs and mark them as executed", async () => { + const jobData = { + name: "once-job", + functionId: "fn", + executionType: "Immediate", + jobType: "once", + runAt: new Date(Date.now() + 10000), + state: "scheduled", + status: "active" + }; + + const callback = jest.fn() as any; + ScheduleService.register("fn", callback); + + let jobCallback: any; + (schedule.scheduleJob as jest.Mock).mockImplementation((name, time, cb) => { + jobCallback = cb; + return { cancel: jest.fn() }; + }); + + await ScheduleService.scheduleJobInternal(jobData); + mockCollection.findOneAndUpdate.mockResolvedValue({ _id: "id2", ...jobData, state: "executing" }); + + await jobCallback(); + + expect(mockCollection.updateOne).toHaveBeenCalledWith( + { _id: "id2" }, + expect.objectContaining({ $set: expect.objectContaining({ state: "executed" }) }) + ); + }); + + it("should mark job as failed if registered function throws error", async () => { + const jobData = { + name: "failing-job", + functionId: "fail-fn", + executionType: "Immediate", + jobType: "recurrent", + cronExpression: "* * * * *", + state: "scheduled", + status: "active" + }; + + const callback = jest.fn() as any; + callback.mockRejectedValue(new Error("Test error")); + ScheduleService.register("fail-fn", callback); + + let jobCallback: any; + (schedule.scheduleJob as jest.Mock).mockImplementation((name, cron, cb) => { + jobCallback = cb; + return { cancel: jest.fn() }; + }); + + await ScheduleService.scheduleJobInternal(jobData); + mockCollection.findOneAndUpdate.mockResolvedValue({ _id: "id3", ...jobData, state: "executing" }); + + await jobCallback(); + + expect(mockCollection.updateOne).toHaveBeenCalledWith( + { _id: "id3" }, + expect.objectContaining({ $set: expect.objectContaining({ state: "failed" }) }) + ); + }); + + it("should queue normal jobs and process them sequentially", async () => { + const jobData = { + name: "normal-job", + functionId: "fn", + executionType: "normal", + jobType: "recurrent", + cronExpression: "* * * * *", + state: "scheduled", + status: "active" + }; + + const callback = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 10))) as any; + ScheduleService.register("fn", callback); + + let jobCallback: any; + (schedule.scheduleJob as jest.Mock).mockImplementation((name, cron, cb) => { + jobCallback = cb; + return { cancel: jest.fn() }; + }); + + await ScheduleService.scheduleJobInternal(jobData); + + // 1. Trigger fires, moves to 'queued' + mockCollection.findOneAndUpdate + .mockResolvedValueOnce({ ...jobData, state: "queued" }) // Trigger claim + .mockResolvedValueOnce({ _id: "id1", ...jobData, state: "executing" }) // Queue processing find next + .mockResolvedValueOnce(null); // Queue processing find next (empty) + + await jobCallback(); + // Wait for the fire-and-forget processQueue to complete + for (let i = 0; i < 20; i++) { + if (!(ScheduleService as any).processingQueue) break; + await new Promise(resolve => setTimeout(resolve, 50)); + } + + expect(mockCollection.findOneAndUpdate).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenCalled(); + expect(mockCollection.updateOne).toHaveBeenCalledWith( + { _id: "id1" }, + expect.objectContaining({ $set: expect.objectContaining({ state: "scheduled" }) }) + ); + }); + }); +}); diff --git a/server/src/modules/schedule/db.ts b/server/src/modules/schedule/db.ts index 962a622..b50e828 100644 --- a/server/src/modules/schedule/db.ts +++ b/server/src/modules/schedule/db.ts @@ -3,23 +3,43 @@ import { DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION } from "../../config"; interface ScheduleJobSchema { name: string; - cronExpression: string; - routePath: string; - method: string; + cronExpression?: string; + runAt?: Date; + functionId: string; + args?: any; + executionType: "Immediate" | "normal"; + jobType: "recurrent" | "once"; lastRun?: Date; - status: "active" | "inactive" | "failed"; + state: "scheduled" | "queued" | "executing" | "executed" | "failed"; + status: "active" | "inactive"; } const scheduleJobSchema = new Schema( { name: { type: String, required: true, unique: true }, - cronExpression: { type: String, required: true }, - routePath: { type: String, required: true }, - method: { type: String, default: "POST" }, + cronExpression: { type: String }, + runAt: { type: Date }, + functionId: { type: String, required: true }, + args: { type: Schema.Types.Mixed, default: {} }, + executionType: { + type: String, + enum: ["Immediate", "normal"], + default: "normal" + }, + jobType: { + type: String, + enum: ["recurrent", "once"], + default: "recurrent" + }, lastRun: Date, + state: { + type: String, + enum: ["scheduled", "queued", "executing", "executed", "failed"], + default: "scheduled", + }, status: { type: String, - enum: ["active", "inactive", "failed"], + enum: ["active", "inactive"], default: "active", }, }, diff --git a/server/src/modules/schedule/functions.ts b/server/src/modules/schedule/functions.ts index 2972591..2ea014c 100644 --- a/server/src/modules/schedule/functions.ts +++ b/server/src/modules/schedule/functions.ts @@ -7,12 +7,26 @@ const createJob = defineFunction({ name: "create-job", permissionTypes: ["admin"], // Assuming admin usage callback: async (params: any) => { - const { name, cronExpression, routePath, method } = params; - await ScheduleService.createJob( + const { name, cronExpression, - routePath, - method || "POST" + runAt, + functionId, + args, + executionType, + jobType + } = params; + + await ScheduleService.createJob( + name, + functionId, + { + cronExpression, + runAt: runAt ? new Date(runAt) : undefined, + args: args || {}, + executionType, + jobType + } ); return { success: true }; }, diff --git a/server/src/modules/schedule/service.ts b/server/src/modules/schedule/service.ts index a6357de..68eb40b 100644 --- a/server/src/modules/schedule/service.ts +++ b/server/src/modules/schedule/service.ts @@ -2,11 +2,15 @@ import schedule from "node-schedule"; import { getCollection } from "@modular-rest/server"; import { DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION } from "../../config"; -const PORT = process.env.PORT || "8080"; -const BASE_URL = `http://localhost:${PORT}`; -const SYSTEM_SECRET = process.env.SYSTEM_SECRET || "default_system_secret_key"; - export class ScheduleService { + private static registry = new Map Promise>(); + private static processingQueue = false; + + static register(id: string, callback: (args: any) => Promise) { + console.log(`[ScheduleService] Registering function: ${id}`); + this.registry.set(id, callback); + } + static async init() { try { const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); @@ -15,6 +19,8 @@ export class ScheduleService { for (const job of jobs) { this.scheduleJobInternal(job as any); } + // Start processing queue in case there are stuck 'queued' jobs from previous sessions + this.processQueue(); } catch (error) { console.error("[ScheduleService] Init failed", error); } @@ -22,25 +28,42 @@ export class ScheduleService { static async createJob( name: string, - cronExpression: string, - routePath: string, - method: string = "POST" + functionId: string, + options: { + cronExpression?: string; + runAt?: Date; + args?: any; + executionType?: "Immediate" | "normal"; + jobType?: "recurrent" | "once"; + } ) { + const { + cronExpression, + runAt, + args = {}, + executionType = "normal", + jobType = "recurrent" + } = options; + const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); const existing = await collection.findOne({ name }); const jobData = { name, cronExpression, - routePath, - method, + runAt, + functionId, + args, + executionType, + jobType, + state: "scheduled" as const, status: "active" as const, }; if (existing) { await collection.updateOne( { name }, - { $set: { cronExpression, routePath, method, status: "active" } } + { $set: { cronExpression, runAt, functionId, args, executionType, jobType, state: "scheduled", status: "active" } } ); this.cancelJob(name); } else { @@ -64,52 +87,95 @@ export class ScheduleService { await collection.deleteOne({ name }); } - static scheduleJobInternal(jobData: { - name: string; - cronExpression: string; - routePath: string; - method: string; - }) { + static async scheduleJobInternal(jobData: any) { // Cancel existing if any (safety check) if (schedule.scheduledJobs[jobData.name]) { schedule.scheduledJobs[jobData.name].cancel(); } - schedule.scheduleJob(jobData.name, jobData.cronExpression, async () => { - console.log(`[ScheduleService] Triggering job: ${jobData.name}`); - try { - const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); + const scheduleParam = jobData.jobType === "once" ? jobData.runAt : jobData.cronExpression; + if (!scheduleParam) return; + + schedule.scheduleJob(jobData.name, scheduleParam, async () => { + console.log(`[ScheduleService] Triggered: ${jobData.name}`); + const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); + + // Atomic claim + const newState = jobData.executionType === "Immediate" ? "executing" : "queued"; + const claimed = await collection.findOneAndUpdate( + { + name: jobData.name, + state: { $in: ["scheduled", "executed", "failed"] } + }, + { $set: { state: newState } }, + { returnDocument: 'after' } + ) as any; + + if (!claimed) { + console.log(`[ScheduleService] Job ${jobData.name} already claimed or running.`); + return; + } + + if (jobData.executionType === "Immediate") { + await this.executeJob(claimed); + } else { + console.log(`[ScheduleService] Job ${jobData.name} queued for sequential processing.`); + this.processQueue(); + } + }); + } + + private static async executeJob(job: any) { + console.log(`[ScheduleService] Executing: ${job.name} (${job.functionId})`); + const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); + + try { + const callback = this.registry.get(job.functionId); + if (callback) { + await callback(job.args); + + const finalState = job.jobType === "once" ? "executed" : "scheduled"; await collection.updateOne( - { name: jobData.name }, - { $set: { lastRun: new Date() } } + { _id: job._id }, + { $set: { state: finalState, lastRun: new Date() } } ); + console.log(`[ScheduleService] Job ${job.name} completed.`); + } else { + throw new Error(`Function ${job.functionId} not registered`); + } + } catch (e) { + console.error(`[ScheduleService] Job ${job.name} failed:`, e); + await collection.updateOne( + { _id: job._id }, + { $set: { state: "failed", lastRun: new Date() } } + ); + } + } + + private static async processQueue() { + if (this.processingQueue) return; + this.processingQueue = true; - // Append base url if relative path - const url = jobData.routePath.startsWith("http") - ? jobData.routePath - : `${BASE_URL}${jobData.routePath}`; - - const response = await fetch(url, { - method: jobData.method, - headers: { - "Content-Type": "application/json", - "x-system-secret": SYSTEM_SECRET, - }, - body: ["POST", "PUT", "PATCH"].includes(jobData.method.toUpperCase()) - ? JSON.stringify({ system_secret: SYSTEM_SECRET }) - : undefined - }); - - if (!response.ok) { - console.error( - `[ScheduleService] Job ${jobData.name} failed: ${response.status} ${response.statusText}` - ); - } else { - console.log(`[ScheduleService] Job ${jobData.name} executed successfully.`); - } - } catch (e) { - console.error(`[ScheduleService] Job ${jobData.name} execution error`, e); + try { + const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); + + while (true) { + // Find next queued job (FIFO by claim time/updatedAt) + const job = await collection.findOneAndUpdate( + { executionType: "normal", state: "queued", status: "active" }, + { $set: { state: "executing" } }, + { sort: { updatedAt: 1 }, returnDocument: 'after' } + ) as any; + + if (!job) break; + + await this.executeJob(job); } - }); + } catch (e) { + console.error("[ScheduleService] Queue processing error:", e); + } finally { + this.processingQueue = false; + } } } + From e877a150d08554014d63f99e1d7209d532e246eb Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Fri, 23 Jan 2026 12:28:48 +0200 Subject: [PATCH 04/33] docs: #86erqa08y update leitner plans for dynamic board and refactor sidebar --- .../composables/useDashboardNavigatorItems.ts | 15 ++++-- frontend/locales/en.json | 6 ++- leitner-box-clarification.md | 50 +++++++++---------- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/frontend/composables/useDashboardNavigatorItems.ts b/frontend/composables/useDashboardNavigatorItems.ts index 6ea8663..57a5c08 100644 --- a/frontend/composables/useDashboardNavigatorItems.ts +++ b/frontend/composables/useDashboardNavigatorItems.ts @@ -12,16 +12,21 @@ export const useDashboardNavigatorItems = (): Array => { icon: 'IconMenuDashboard', to: '/statistic', }, + ], + }, + { + title: t('practice.title'), + children: [ + { + title: t('activities.title'), + icon: 'iconify solar--rocket-2-bold-duotone', + to: '/board', + }, { title: t('bundle.nav'), icon: 'IconMenuDatatables', to: '/bundles', }, - { - title: t('review.title'), - icon: 'IconMenuScrumboard', - to: '/practice/review', - }, ], }, { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 75ec5ea..51d5902 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -152,7 +152,11 @@ "signup": "Sign Up" }, "practice": { - "nav": "Practice" + "nav": "Practice", + "title": "Practice" + }, + "activities": { + "title": "Activities" }, "live-session": { "no-sessions": "No Sessions Yet", diff --git a/leitner-box-clarification.md b/leitner-box-clarification.md index 2f63118..34935f8 100644 --- a/leitner-box-clarification.md +++ b/leitner-box-clarification.md @@ -55,7 +55,7 @@ We will use a **separate database** `subturtle_leitner` for these collections. { user: ObjectId (ref: 'user'), settings: { - dailyLimit: Number, + dailyLimit: Number, // Soft limit for "suggested" count totalBoxes: Number, // Default 5 (min 3, max 10) }, items: [ @@ -68,19 +68,7 @@ We will use a **separate database** `subturtle_leitner` for these collections. ] } -// review_bundles collection (Database: subturtle_leitner) -{ - user: ObjectId (ref: 'user'), - createdAt: Date, - type: 'daily' | 'manual', - status: 'pending' | 'completed' | 'expired', - items: [ - { - phraseId: ObjectId, - boxLevelAtGeneration: Number // FREEZE-FRAMED: stores the box level when generated - } - ] -} +// No "review_bundles" collection. Review is dynamic. ``` *Note: Using cross-database populate to link to phrases in `user_content`.* @@ -133,13 +121,24 @@ This keeps the initialization logic completely handled on the server side withou - **If Phrase Exists**: Update box level based on result. - **If Phrase is New**: Add to **Box 1** and proceed with standard logic. -### Review Bundles -- **Generation**: - - `node-schedule` triggers daily generation (webhook). - - Finds items where `nextReviewDate <= Now`. - - Creates a `ReviewBundle` document. - - **Freeze-Framed**: The bundle stores a snapshot of the `boxLevel` for each item at the moment of generation. This ensures that even if box settings change mid-bundle, the review session remains consistent. - - Limit: `MaxBundles` (e.g., 3). If full, oldest *unstarted* bundle is replaced. *In-progress* bundles are kept. +### Dynamic Board & Activities +- **Board Concept**: A central page presenting available "Activities". +- **Activities**: + 1. **Review Leitner Box**: Primary activity. + 2. **Take AI Lecture** (Future). + 3. **Take AI Practice** (Future). +- **"Toasting"**: The Board logic determines which activity is "hot" or "due" and suggests it to the user (e.g., via a Dashboard widget or Notification). + +### Dynamic Review Logic +- **No Freeze-Frames**: We do NOT store "Bundles" in the DB. +- **On Click "Review"**: + - Frontend requests `leitner/review-items`. + - Server queries `leitner_items` where `nextReviewDate <= Now`. + - Server returns the list (up to `DailyLimit` or more if user requests). +- **Daily Limit**: + - Acts as a "Target" or "Soft Limit". + - The Board says "You have X items due". + - If `Due > DailyLimit`, we show `DailyLimit` first, but allow user to continue reviewing the rest ("Overtime"). ### Settings Change Logic - **Changing Total Boxes**: @@ -150,19 +149,20 @@ This keeps the initialization logic completely handled on the server side withou ## 4. Frontend Implementation (Vue.js) **Pages:** +- `pages/board/index.vue`: **Activity Board**. + - Lists activities (Leitner, Lecture, Practice). + - Highlights due activities. - `pages/practice/flashcards-[id].vue`: **Universal Player**. - **Adaptation**: - Accepts optional `type=leitner` query param. + - If `type=leitner`, it fetches dynamic due items from `leitner/review-items`. - **UI Updates**: Add "Known" (Check) and "Unknown" (X) buttons. - **Logic**: Reports result to the Leitner system on every rating. -- `pages/practice/review.vue`: **Review Dashboard**. - - Shows current bundles and stats. - - Links to the Universal Player for review sessions. - `pages/settings/preferences.vue`: **User Preferences**. - Contains `LeitnerSettings` for "Daily Limit" and "Total Boxes". **Components:** -- `LeitnerDashboard`: Visualization of box distribution. +- `LeitnerDashboard`: Visualization of box distribution (can be on Board). - `LeitnerSettings`: Configuration form. **Initialization**: From 63ef0af67ebef3d536d44cf4dda43d3639133d7d Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Fri, 23 Jan 2026 15:54:56 +0200 Subject: [PATCH 05/33] feat: #86erqa08y Implement Leitner System and Board Activity --- frontend/components/Leitner/BoxStats.vue | 89 ++++ frontend/locales/en.json | 9 + frontend/pages/board/index.vue | 94 ++++ frontend/pages/practice/review.vue | 278 +++++------ frontend/stores/leitner.ts | 85 ++++ frontend/types/database.type.ts | 38 ++ leitner-box-clarification.md | 143 ++++-- server/package.json | 4 +- server/src/config.ts | 11 +- server/src/index.ts | 11 +- server/src/modules/board/db.ts | 46 ++ server/src/modules/board/functions.ts | 28 ++ server/src/modules/board/service.ts | 88 ++++ server/src/modules/leitner_box/db.ts | 117 ++--- server/src/modules/leitner_box/functions.ts | 115 +++-- server/src/modules/leitner_box/service.ts | 452 ++++++++--------- server/src/modules/phrase_bundle/triggers.ts | 53 +- server/src/triggers.ts | 4 +- server/yarn.lock | 495 +++++++++++-------- 19 files changed, 1364 insertions(+), 796 deletions(-) create mode 100644 frontend/components/Leitner/BoxStats.vue create mode 100644 frontend/pages/board/index.vue create mode 100644 frontend/stores/leitner.ts create mode 100644 server/src/modules/board/db.ts create mode 100644 server/src/modules/board/functions.ts create mode 100644 server/src/modules/board/service.ts diff --git a/frontend/components/Leitner/BoxStats.vue b/frontend/components/Leitner/BoxStats.vue new file mode 100644 index 0000000..41f44de --- /dev/null +++ b/frontend/components/Leitner/BoxStats.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 51d5902..9e5ca11 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -228,5 +228,14 @@ "title": "Review", "recent-lectures": "Recent AI Lectures", "view-all": "View All" + }, + "board": { + "no_activities": "No activities right now", + "all_caught_up": "You are all caught up! Check back later.", + "due": "Due", + "items_due": "{count} items due for review", + "activity_ready": "Ready to start", + "start_activity": "Start Activity", + "back_to_board": "Back to Board" } } \ No newline at end of file diff --git a/frontend/pages/board/index.vue b/frontend/pages/board/index.vue new file mode 100644 index 0000000..adc5781 --- /dev/null +++ b/frontend/pages/board/index.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/pages/practice/review.vue b/frontend/pages/practice/review.vue index 100413f..08ce76c 100644 --- a/frontend/pages/practice/review.vue +++ b/frontend/pages/practice/review.vue @@ -1,180 +1,134 @@ diff --git a/frontend/stores/leitner.ts b/frontend/stores/leitner.ts new file mode 100644 index 0000000..ee55f2f --- /dev/null +++ b/frontend/stores/leitner.ts @@ -0,0 +1,85 @@ +import { defineStore } from 'pinia'; +import { functionProvider } from '@modular-rest/client'; +import { type BoardActivityType, type LeitnerItemType } from '~/types/database.type'; +import { analytic } from '~/plugins/mixpanel'; + +export const useLeitnerStore = defineStore('leitner', () => { + // State + const boardActivities = ref([]); + const reviewSessionItems = ref([]); + + // Actions + + async function fetchBoard() { + try { + // functionProvider.run calls the server-side function 'get-board' + // Using kebab-case as defined in functions.ts permissions/name + const activities = await functionProvider.run({ + name: 'get-board', + args: { + userId: authUser.value?.id + } + }) as BoardActivityType[]; + + boardActivities.value = activities || []; + } catch (error) { + console.error('Failed to fetch board:', error); + boardActivities.value = []; + } + } + + async function consumeActivity(type: string, refId?: string) { + try { + await functionProvider.run({ + name: 'consume-activity', + args: { type, refId, userId: authUser.value?.id } + }); + // Optimistic update: remove from local state + boardActivities.value = boardActivities.value.filter(a => + !(a.type === type && a.refId === refId) + ); + } catch (error) { + console.error('Failed to consume activity:', error); + } + } + + async function fetchReviewSession(limit: number = 20) { + try { + const items = await functionProvider.run({ + name: 'get-review-session', + args: { limit } + }) as LeitnerItemType[]; + + reviewSessionItems.value = items || []; + return items; + } catch (error) { + console.error('Failed to fetch review session:', error); + return []; + } + } + + async function submitReview(phraseId: string, isCorrect: boolean) { + try { + await functionProvider.run({ + name: 'submit-review', + args: { phraseId, isCorrect } + }); + + // Remove from local session to show progress (if needed) + // Or just keep it. Typically we move to next card. + + analytic.track('leitner_review_submitted', { isCorrect }); + } catch (error) { + console.error('Failed to submit review:', error); + } + } + + return { + boardActivities, + reviewSessionItems, + fetchBoard, + consumeActivity, + fetchReviewSession, + submitReview + }; +}); diff --git a/frontend/types/database.type.ts b/frontend/types/database.type.ts index 1540f36..32edcda 100644 --- a/frontend/types/database.type.ts +++ b/frontend/types/database.type.ts @@ -3,6 +3,8 @@ import { type PhraseSchema, type LinguisticData } from '../../server/src/modules export const DATABASE = { USER_CONTENT: 'user_content', + LEITNER: 'subturtle_leitner', + BOARD: 'subturtle_board', }; export const COLLECTIONS = { @@ -10,6 +12,8 @@ export const COLLECTIONS = { PHRASE_BUNDLE: 'phrase_bundle', PROFILE: 'profile', LIVE_SESSION: 'live_session', + LEITNER_SYSTEM: 'leitner_system', + BOARD_ACTIVITY: 'board_activity', }; // Re-export LinguisticData type for frontend use @@ -61,3 +65,37 @@ export interface SubscriptionType extends Subscription { export interface FreemiumAllocationType extends FreeCredit { is_freemium: true; } + +// Leitner & Board Types +export interface BoardActivityType { + _id: string; + user: string; + type: string; + toastType: 'singleton' | 'unique'; + refId?: string; + state: 'idle' | 'toasted'; + lastUpdated: string; // Dates often come as strings from JSON + meta: any; +} + +export interface LeitnerItemType { + phraseId: string | PhraseType; + boxLevel: number; + nextReviewDate: string; + lastAttemptDate: string; + consecutiveIncorrect: number; + phrase?: PhraseType; // Populated +} + +// Override or extend DATABASE/COLLECTIONS +export const DATABASE_EXT = { + ...DATABASE, + LEITNER: 'subturtle_leitner', + BOARD: 'subturtle_board' +}; + +export const COLLECTIONS_EXT = { + ...COLLECTIONS, + LEITNER_SYSTEM: 'leitner_system', + BOARD_ACTIVITY: 'board_activity' +}; diff --git a/leitner-box-clarification.md b/leitner-box-clarification.md index 34935f8..456f75c 100644 --- a/leitner-box-clarification.md +++ b/leitner-box-clarification.md @@ -7,8 +7,8 @@ Develop a Leitner Box-based flashcard review system capable of handling a large #### Requirements: 1. **Dynamic Box Structure:** * Implement a dynamic Leitner box system where the number of boxes can be expanded or split as needed to accommodate large volumes of phrases. - * Categories of phrases should have their own independent set of Leitner boxes. - * Ensure scalability for systems handling over 1,000 phrases. + * Categories of phrases play as a parallel review phrase beside the actual review list, any correct answer push the phrase to the next box and any incorrect answer push the phrase to the previous box. + * Ensure scalability for systems handling over 5,000 phrases. 2. **Phrase Review & Movement Logic:** * Implement logic for moving phrases between boxes based on user performance. * Correctly answered phrases progress to higher-numbered boxes with longer intervals. @@ -36,66 +36,99 @@ The final implementation should provide an efficient and scalable Leitner box sy ## 1. Implamentation Overview This document outlines the engineering plan for implementing the Leitner Box Review System within the `Subturtle` application, using `@modular-rest/server` on the backend and Vue.js on the frontend. -## 2. Server-Side Architecture -We will implement two new modules in the `modules/` directory: +## 2. Server-Side Architecture & Module Usecases +The system relies on the interplay of three distinct modules. -### A. Module: `leitner_box` -This module handles all the core logic, data storage, and review calculations. +### A. Module: `leitner_box` (The Logic Engine) +**Role**: Handles the *mathematics* and *storage* of spaced repetition. It doesn't care *when* or *how* the user reviews, only *what* is due and *how* to update progress. +**Usecase**: "I performed a review on Phrase X, result: Correct. Update its level." +**Relation**: +- **To Board**: Provides data ("User has 15 items due"). +- **To Schedule**: None directly (passive module). -**Files:** -- `db.ts`: Defines the separate database schema. -- `service.ts`: Contains the business logic (movement, intervals, bundle generation). -- `functions.ts`: Exposes API endpoints for the frontend. - -**Database Schema (Unified System):** -We will use a **separate database** `subturtle_leitner` for these collections. - -``` -// leitner_system collection (Database: subturtle_leitner) +**Database Schema (`leitner_system` collection in `subturtle_leitner`):** +```typescript { + /** Reference to the User owner of this system */ user: ObjectId (ref: 'user'), + + /** User-configured settings affecting the algo */ settings: { - dailyLimit: Number, // Soft limit for "suggested" count - totalBoxes: Number, // Default 5 (min 3, max 10) + /** Soft limit for suggested daily reviews (e.g., 20) */ + dailyLimit: Number, + /** Max box level before 'Mastered'. Default 5 (min 3, max 10) */ + totalBoxes: Number, }, + + /** Array of all phrases currently tracked in the system */ items: [ { - phraseId: ObjectId, // Cross-database ref to subturtle_user_content.phrase - boxLevel: Number, // 1, 2, 3... + /** Link to the Phrase in `subturtle_user_content` DB */ + phraseId: ObjectId, + /** Current Box Level (1 = Daily, 5 = Mastered) */ + boxLevel: Number, + /** The exact timestamp when this item becomes due for review */ nextReviewDate: Date, + /** Timestamp of the last review attempt */ lastAttemptDate: Date } ] } +``` -// No "review_bundles" collection. Review is dynamic. +### B. Module: `board` (The State/Presentation Layer) +**Role**: Manages the "Activity Board" UI state. It acts as the *Brain* that decides what the user should focus on. It persists notification states (toasts) so they survive refreshes. +**Usecase**: "The user logged in. Show them a 'Hot' toast for Leitner Review because they have due items." +**Relation**: +- **To Leitner**: Queries `LeitnerService.getDueCount()` to determine if a toast is needed. +- **To Schedule**: Receives "Wake Up" triggers to refresh its state. + +**Database Schema (`board_activities` collection in `subturtle_board`):** +```typescript +{ + /** Reference to the User */ + user: ObjectId (ref: 'user'), + + /** List of tracked activities on the board */ + activities: [ + { + /** The identifier for the type of activity */ + type: 'leitner_review', // | 'ai_lecture' | 'ai_practice' + + /** + * Controls notification duplication behavior: + * - 'singleton': Only one active toast of this type (e.g. Leitner Review) + * - 'unique': Multiple toasts allowed if refIds differ (e.g. Course A, Course B) + */ + toastType: 'singleton' | 'unique', + + /** Optional ID for 'unique' types (e.g., Lecture ID) */ + refId: String, + + /** Current UI State: 'toasted' = show alert, 'idle' = normal display */ + state: 'idle' | 'toasted', + + /** When this state was last computed */ + lastUpdated: Date, + + /** Snapshot data for the UI (e.g., preventing re-querying Leitner DB) */ + meta: { + dueCount: Number, + nextCheck: Date + } + } + ] +} ``` -*Note: Using cross-database populate to link to phrases in `user_content`.* - -**Functions (`functions.ts`):** -- `getReviewBundle()`: Returns the current batch of phrases to review. -- `submitReviewResult(results: { phraseId: string, correct: boolean }[])`: Updates the box levels for reviewed items. -- `addPhraseToBox(phraseId: string)`: Manually adds a phrase to Box 1. -- `resetBox()`: Resets all progress. -- `getStats()`: Returns box distribution stats for the Dashboard. - -### B. Module: `schedule` (General Purpose) -This module manages background tasks using `node-schedule`. It is designed as a **shared service** that triggers API endpoints (internal webhooks) on a schedule. - -**Files:** -- `db.ts`: Stores scheduled jobs metadata (name, cronExpression, routePath, method, lastRun, status) for persistence and execution. -- `service.ts`: Wraps `node-schedule` and provides methods like `scheduleJob(name, cronExpression, routePath, method)`. It handles the HTTP request execution. -- `functions.ts`: API to create, list, delete, or manually triggers jobs. - -**Mechanism (Webhook/Route Based):** -- **Persistence**: The database stores the **Route Path** (e.g., `/api/v1/leitner-box/functions/generateDailyBundle`), HTTP method, and cron expression. -- **Execution**: When the schedule triggers, the service makes an internal HTTP request (Webhook) to the stored path. -- **Pros**: - - Solves the serialization issue (strings are easy to store). - - Decouples the schedule module from specific implementation details. - - Allows triggering any API endpoint in the system. - -### C. Initialization & Hooks + +### C. Module: `schedule` (The Pulse/Trigger) +**Role**: A general-purpose background job runner. It knows *nothing* about business logic; it only knows *endpoints* and *times*. +**Usecase**: "Every hour, trigger the `board/refresh` webhook for all active users." +**Relation**: +- **To Board**: Calls `BoardService.refresh()` (via webhook). +- **To Leitner**: No direct relation. + +### D. Initialization & Hooks **Criterion**: "on login step the leitner system is initiated for user". **Implementation Strategy:** @@ -122,12 +155,16 @@ This keeps the initialization logic completely handled on the server side withou - **If Phrase is New**: Add to **Box 1** and proceed with standard logic. ### Dynamic Board & Activities -- **Board Concept**: A central page presenting available "Activities". -- **Activities**: - 1. **Review Leitner Box**: Primary activity. - 2. **Take AI Lecture** (Future). - 3. **Take AI Practice** (Future). -- **"Toasting"**: The Board logic determines which activity is "hot" or "due" and suggests it to the user (e.g., via a Dashboard widget or Notification). +- **Persistence**: Activity states are stored in the DB. +- **Toast Types**: + - **Singleton**: Only one active toast of this kind (e.g., "Leitner Review"). If triggered again, it just updates metadata (e.g., due count). + - **Unique**: Can have multiple distinct toasts (e.g., "Lecture: Intro to AI", "Lecture: Advanced JS"). +- **"Toasting"**: + - **Trigger**: Schedule runs daily (or hourly) -> `BoardService.refresh()`. + - **Logic**: Checks `LeitnerService`. If items are due, sets Activity State to `toasted` with metadata (e.g., "15 items due"). +- **"Consumption" (State Change)**: + - User clicks "Review" or enters the activity -> **Toast is Removed** (State -> `consumed/idle`). + - **Logic**: The act of *starting* the activity satisfies the prompt. ### Dynamic Review Logic - **No Freeze-Frames**: We do NOT store "Bundles" in the DB. diff --git a/server/package.json b/server/package.json index c072414..1fcc8b9 100644 --- a/server/package.json +++ b/server/package.json @@ -16,7 +16,7 @@ "license": "MIT", "dependencies": { "@google-cloud/text-to-speech": "^6.1.0", - "@modular-rest/server": "1.15.0", + "@modular-rest/server": "/Users/navid-shad/Projects/CodeBridger/modular-rest/packages/server-ts", "@types/koa-router": "^7.4.8", "date-and-time": "^3.6.0", "decimal.js-light": "^2.5.1", @@ -24,6 +24,7 @@ "googleapis": "^129.0.0", "koa-router": "^13.0.1", "node-fetch": "2", + "node-schedule": "^2.1.1", "stripe": "^18.0.0", "zod": "^3.24.3", "zod-to-json-schema": "^3.24.5" @@ -32,6 +33,7 @@ "@types/jest": "^30.0.0", "@types/koa-router": "^7.4.8", "@types/node": "^22.7.5", + "@types/node-schedule": "^2.1.8", "jest": "^30.0.5", "mongodb-memory-server": "^10.2.0", "ts-jest": "^29.4.0", diff --git a/server/src/config.ts b/server/src/config.ts index c09c565..b9ebdc8 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -23,9 +23,12 @@ export const FREEMIUM_DURATION_DAYS = 30; // 1 month // Schedule export const DATABASE_SCHEDULE = "cms"; -export const SCHEDULE_JOB_COLLECTION = "job"; +export const SCHEDULE_JOB_COLLECTION = "scheduled_job"; -// Leitner System (Generative) -export const DATABASE_GENERATIVE = "generative"; +// Leitner System +export const DATABASE_LEITNER = DATABASE; export const LEITNER_SYSTEM_COLLECTION = "leitner_system"; -export const LEITNER_REVIEW_BUNDLE_COLLECTION = "leitner_review_bundle"; + +// Board Module +export const DATABASE_BOARD = DATABASE; +export const BOARD_ACTIVITY_COLLECTION = "board_activity"; diff --git a/server/src/index.ts b/server/src/index.ts index d8ba8fc..dab8da7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -83,8 +83,8 @@ const app = createRest({ dbPrefix: process.env.MONGO_DB_PREFIX || "subturtle_", }, staticPath: { - actualPath: path.join(__dirname, "public"), - path: "/", + directory: path.join(__dirname, "public"), + urlPath: "/", }, adminUser: { email: process.env.ADMIN_EMAIL || "", @@ -100,6 +100,13 @@ const app = createRest({ const { ScheduleService, } = require("./modules/schedule/service"); + const { LeitnerService } = require("./modules/leitner_box/service"); + + ScheduleService.register("generate-daily-bundles", async (args: any) => { + console.log("[Schedule] Running generate-daily-bundles..."); + await LeitnerService.generateDailyBundles(); + }); + ScheduleService.init(); }).catch((err) => { console.error(err); diff --git a/server/src/modules/board/db.ts b/server/src/modules/board/db.ts new file mode 100644 index 0000000..5e5be35 --- /dev/null +++ b/server/src/modules/board/db.ts @@ -0,0 +1,46 @@ +import { Schema, defineCollection, Permission } from "@modular-rest/server"; +import { DATABASE_BOARD, BOARD_ACTIVITY_COLLECTION } from "../../config"; + +export interface BoardActivity { + _id?: string; + userId: string; + type: string; // 'leitner_review' | 'ai_lecture' | 'ai_practice' + toastType: "singleton" | "unique"; + refId?: string; // e.g. lecture ID + state: "idle" | "toasted"; + lastUpdated: Date; + meta: any; +} + +const boardActivitySchema = new Schema( + { + userId: { type: String, required: true, index: true }, + type: { type: String, required: true }, + toastType: { type: String, required: true, enum: ["singleton", "unique"] }, + refId: { type: String }, + state: { type: String, required: true, enum: ["idle", "toasted"] }, + lastUpdated: { type: Date, default: Date.now }, + meta: { type: Schema.Types.Mixed }, // Meta can be anything + }, + { timestamps: true } +); + +export const boardActivityCollection = defineCollection({ + database: DATABASE_BOARD, + collection: BOARD_ACTIVITY_COLLECTION, + schema: boardActivitySchema, + permissions: [ + new Permission({ + accessType: "owner", + read: true, + write: true, // User 'consumes' activity + }), + new Permission({ + accessType: "admin", + read: true, + write: true, + }) + ], +}); + +module.exports = [boardActivityCollection]; diff --git a/server/src/modules/board/functions.ts b/server/src/modules/board/functions.ts new file mode 100644 index 0000000..c9bcaf8 --- /dev/null +++ b/server/src/modules/board/functions.ts @@ -0,0 +1,28 @@ +import { defineFunction } from "@modular-rest/server"; +import { BoardService } from "./service"; + +const getBoard = defineFunction({ + name: "get-board", // modular-rest prefers kebab-case often + permissionTypes: ["user_access"], + callback: async (context) => { + const { userId } = context; + if (!userId) throw new Error("Unauthorized"); + return BoardService.getBoard(userId); + }, +}); + +const consumeActivity = defineFunction({ + name: "consume-activity", + permissionTypes: ["user_access"], + callback: async (context) => { + const { type, refId, userId } = context; + + if (!userId) throw new Error("Unauthorized"); + if (!type) throw new Error("Type is required"); + + await BoardService.consumeActivity(userId, type, refId); + return { success: true }; + }, +}); + +module.exports.functions = [getBoard, consumeActivity]; diff --git a/server/src/modules/board/service.ts b/server/src/modules/board/service.ts new file mode 100644 index 0000000..4ec80e6 --- /dev/null +++ b/server/src/modules/board/service.ts @@ -0,0 +1,88 @@ +import { BoardActivity } from "./db"; +import { getCollection } from "@modular-rest/server"; +import { DATABASE_BOARD, BOARD_ACTIVITY_COLLECTION } from "../../config"; +import { Document } from "mongoose"; + +type BoardActivityDoc = BoardActivity & Document; + +export class BoardService { + /** + * Refreshes the state of a specific activity type for a user. + * This is typically called by a scheduled job or an event hook. + * @param userId The user ID + * @param type The activity type (e.g., 'leitner_review') + * @param meta The updated metadata (e.g., dueCount). If null, implies no update needed. + * @param shouldToast Whether this update should trigger a 'toasted' state. + */ + static async refreshActivity( + userId: string, + type: string, + meta: any, + shouldToast: boolean, + toastType: "singleton" | "unique" = "singleton", + refId?: string + ) { + const col = await getCollection(DATABASE_BOARD, BOARD_ACTIVITY_COLLECTION); + const query: any = { userId, type }; + if (toastType === "unique" && refId) { + query.refId = refId; + } + + const existing = (await col.findOne(query)) as BoardActivityDoc | null; + + if (existing) { + // Update existing activity + const updateData: Partial = { + lastUpdated: new Date(), + meta: { ...existing.meta, ...meta }, + }; + + // Only re-toast if explicitly requested. + // E.g., if it was 'idle' and now we have new due items, toast it. + // If it was already 'toasted', we just update meta (silent update). + if (shouldToast && existing.state === "idle") { + updateData.state = "toasted"; + } + + await col.updateOne({ _id: existing._id }, { $set: updateData }); + } else if (shouldToast) { + // Create new activity only if there's something to toast + await col.create({ + userId, + type, + toastType, + refId, + state: "toasted", + lastUpdated: new Date(), + meta, + }); + } + } + + /** + * consumes an activity (sets it to idle) + * @param userId + * @param type + * @param refId + */ + static async consumeActivity(userId: string, type: string, refId?: string) { + const col = await getCollection(DATABASE_BOARD, BOARD_ACTIVITY_COLLECTION); + const query: any = { userId, type }; + if (refId) { + query.refId = refId; + } + + // We only find "toasted" ones to save writes, but generally just set to idle. + await col.updateOne(query, { + $set: { state: "idle", lastUpdated: new Date() }, + }); + } + + /** + * Get all active (toasted) activities for a user + */ + static async getBoard(userId: string) { + const col = await getCollection(DATABASE_BOARD, BOARD_ACTIVITY_COLLECTION); + return col.find({ userId, state: "toasted" }); + } +} diff --git a/server/src/modules/leitner_box/db.ts b/server/src/modules/leitner_box/db.ts index 694a8b3..fd94669 100644 --- a/server/src/modules/leitner_box/db.ts +++ b/server/src/modules/leitner_box/db.ts @@ -1,98 +1,65 @@ -import { Schema, defineCollection } from "@modular-rest/server"; -import { DATABASE_GENERATIVE, LEITNER_SYSTEM_COLLECTION, LEITNER_REVIEW_BUNDLE_COLLECTION } from "../../config"; +import { Schema, defineCollection, Permission } from "@modular-rest/server"; +import { DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION } from "../../config"; -interface LeitnerSystemSchema { - refId: string; // user id +export interface LeitnerItem { + phraseId: string; + boxLevel: number; + nextReviewDate: Date; + lastAttemptDate: Date; + consecutiveIncorrect: number; +} + +export interface LeitnerSystem { + userId: string; settings: { dailyLimit: number; - totalBoxes: number; // Default 5 (min 3, max 10) + totalBoxes: number; }; - items: Array<{ - phraseId: string; // ref to phrase refId or string ID - boxLevel: number; - nextReviewDate: Date; - lastAttemptDate: Date; - }>; + items: LeitnerItem[]; } -interface ReviewBundleSchema { - refId: string; // user id - createdAt: Date; - type: "daily" | "manual"; - status: "pending" | "completed" | "expired"; - items: Array<{ - phraseId: string; - boxLevelAtGeneration: number; - }>; -} - -const leitnerSystemSchema = new Schema( +const leitnerSystemSchema = new Schema( { - refId: { type: String, required: true, unique: true }, + userId: { type: String, required: true }, settings: { - dailyLimit: { type: Number, default: 15 }, - totalBoxes: { type: Number, default: 5, min: 3, max: 10 }, - }, - items: [ - { - phraseId: { type: String, required: true }, - boxLevel: { type: Number, default: 1 }, - nextReviewDate: Date, - lastAttemptDate: Date, - consecutiveFailures: { type: Number, default: 0 }, + type: { + dailyLimit: { type: Number, default: 20 }, + totalBoxes: { type: Number, default: 5 }, }, - ], - }, - { timestamps: true } -); - -const reviewBundleSchema = new Schema( - { - refId: { type: String, required: true }, - type: { type: String, enum: ["daily", "manual"], default: "daily" }, - status: { - type: String, - enum: ["pending", "completed", "expired"], - default: "pending", + required: true, + }, + items: { + type: [ + { + phraseId: { type: String, ref: "phrase" }, // Cross-DB reference + boxLevel: { type: Number, required: true }, + nextReviewDate: { type: Date, required: true }, + lastAttemptDate: { type: Date, required: true }, + consecutiveIncorrect: { type: Number, default: 0 }, + }, + ], + default: [], }, - items: [ - { - phraseId: String, - boxLevelAtGeneration: Number, - }, - ], }, { timestamps: true } ); -const leitnerSystemCollection = defineCollection({ - database: DATABASE_GENERATIVE, +export const leitnerSystemCollection = defineCollection({ + database: DATABASE_LEITNER, collection: LEITNER_SYSTEM_COLLECTION, schema: leitnerSystemSchema, permissions: [ - { - accessType: "user_access", + new Permission({ + accessType: "owner", read: true, - write: false, // Only modified via service/functions - onlyOwnData: true, - ownerIdField: "refId", - }, - ], -}); - -const reviewBundleCollection = defineCollection({ - database: DATABASE_GENERATIVE, - collection: LEITNER_REVIEW_BUNDLE_COLLECTION, - schema: reviewBundleSchema, - permissions: [ - { - accessType: "user_access", + write: true, // User can update their own settings via service + }), + new Permission({ + accessType: "admin", read: true, write: true, - onlyOwnData: true, - ownerIdField: "refId", - }, + }) ], }); -module.exports = [leitnerSystemCollection, reviewBundleCollection]; +module.exports = [leitnerSystemCollection]; diff --git a/server/src/modules/leitner_box/functions.ts b/server/src/modules/leitner_box/functions.ts index da5ca21..afc60cf 100644 --- a/server/src/modules/leitner_box/functions.ts +++ b/server/src/modules/leitner_box/functions.ts @@ -1,65 +1,102 @@ import { defineFunction } from "@modular-rest/server"; import { LeitnerService } from "./service"; +import { BoardService } from "../board/service"; -const SYSTEM_SECRET = process.env.SYSTEM_SECRET || "default_system_secret_key"; +// Frontend API: Get items to review +const getReviewSession = defineFunction({ + name: "get-review-session", + permissionTypes: ["user"], + callback: async (context) => { + if (!context.user) throw new Error("Unauthorized"); + const { limit } = context.params; -const getReviewBundle = defineFunction({ - name: "get-review-bundle", - permissionTypes: ["user_access"], - callback: async (params: any) => { - // Assuming params contains userId if user_access is enabled/injected - // or we might need to rely on the fact that for user_access, the caller should pass userId? - // Actually, secure way is ctx.user. - // If modular-rest injects user into params, we use it. - // Based on profile/functions.ts, it uses { userId } = params. - const { userId } = params; - return await LeitnerService.getReviewBundle(userId); + // Ensure initialized (lazy init) + await LeitnerService.ensureInitialized(context.user._id); + + const items = await LeitnerService.getDueItems(context.user._id, limit ? parseInt(limit) : 20); + return items; }, }); -const submitReviewResult = defineFunction({ - name: "submit-review-result", - permissionTypes: ["user_access"], - callback: async (params: any) => { - const { userId, results } = params; - await LeitnerService.submitReviewResult(userId, results); +// Frontend API: Submit a review result +const submitReview = defineFunction({ + name: "submit-review", + permissionTypes: ["user"], + callback: async (context) => { + if (!context.user) throw new Error("Unauthorized"); + const { phraseId, isCorrect } = context.params; + + if (!phraseId) throw new Error("Phrase ID is required"); + if (typeof isCorrect !== "boolean") throw new Error("isCorrect boolean is required"); + + // Submit review (also triggers init if needed) + await LeitnerService.submitReview(context.user._id, phraseId, isCorrect); + return { success: true }; }, }); -const generateDailyBundles = defineFunction({ - name: "generate-daily-bundles", - permissionTypes: ["anonymous_access"], // Cron triggers this - callback: async (params: any) => { - // Security check - const { system_secret } = params; - if (system_secret !== SYSTEM_SECRET) { - throw new Error("Unauthorized system call"); +// Internal/Cron API: Check status and update board +const refreshBoardStatus = defineFunction({ + name: "refresh-board-status", + permissionTypes: ["admin", "system"], + callback: async (context) => { + const { userId } = context.params; + if (!userId) { + if (context.user) { + await _syncUser(context.user._id); + return { success: true }; + } + throw new Error("UserId required for refresh-board-status"); } - - await LeitnerService.generateDailyBundles(); + + await _syncUser(userId); return { success: true }; }, }); +async function _syncUser(userId: string) { + const dueCount = await LeitnerService.getDueCount(userId); + await BoardService.refreshActivity( + userId, + "leitner_review", + { dueCount }, + dueCount > 0, + "singleton" + ); +} + +// Admin/System API: Initialize for a user +const initLeitner = defineFunction({ + name: "init-leitner", + permissionTypes: ["admin", "system", "user"], + callback: async (context) => { + const userId = context.params.userId || (context.user ? context.user._id : null); + if (!userId) throw new Error("UserId required"); + + await LeitnerService.ensureInitialized(userId); + return { success: true }; + } +}); + const getStats = defineFunction({ name: "get-stats", - permissionTypes: ["user_access"], - callback: async (params: any) => { - const { userId } = params; - return await LeitnerService.getStats(userId); + permissionTypes: ["user"], + callback: async (context) => { + if (!context.user) throw new Error("Unauthorized"); + return LeitnerService.getStats(context.user._id); } }); const updateSettings = defineFunction({ name: "update-settings", - permissionTypes: ["user_access"], - callback: async (params: any) => { - const { userId, settings } = params; - // settings: { dailyLimit, totalBoxes } - await LeitnerService.updateSettings(userId, settings); - return { success: true }; + permissionTypes: ["user"], + callback: async (context) => { + if (!context.user) throw new Error("Unauthorized"); + const { settings } = context.params; + await LeitnerService.updateSettings(context.user._id, settings); + return { success: true }; } }); -module.exports.functions = [getReviewBundle, submitReviewResult, generateDailyBundles, getStats, updateSettings]; +module.exports.functions = [getReviewSession, submitReview, refreshBoardStatus, initLeitner, getStats, updateSettings]; diff --git a/server/src/modules/leitner_box/service.ts b/server/src/modules/leitner_box/service.ts index f9d6579..10fef5c 100644 --- a/server/src/modules/leitner_box/service.ts +++ b/server/src/modules/leitner_box/service.ts @@ -1,289 +1,239 @@ -import { getCollection, defineFunction } from "@modular-rest/server"; -import { DATABASE as USER_DB, PHRASE_COLLECTION, DATABASE_GENERATIVE, LEITNER_REVIEW_BUNDLE_COLLECTION, LEITNER_SYSTEM_COLLECTION } from "../../config"; +import { LeitnerItem } from "./db"; +import { DATABASE, PHRASE_COLLECTION, DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION } from "../../config"; +import { getCollection } from "@modular-rest/server"; +import { Document } from "mongoose"; +import { BoardService } from "../board/service"; + +// Helper type since modular-rest types are opaque sometimes +type LeitnerSystemDoc = Document & { + userId: string; + settings: { + dailyLimit: number; + totalBoxes: number; + }; + items: LeitnerItem[]; +}; export class LeitnerService { - static async ensureInitialized(userId: string) { - const leitnerSystem = await getCollection( - DATABASE_GENERATIVE, - LEITNER_SYSTEM_COLLECTION - ); - const existing = await leitnerSystem.findOne({ refId: userId }); + private static async getSystem(userId: string): Promise { + const col = await getCollection(DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION); + return (await col.findOne({ userId })) as unknown as LeitnerSystemDoc; + } + static async ensureInitialized(userId: string) { + const existing = await this.getSystem(userId); if (!existing) { - await leitnerSystem.create({ - refId: userId, - settings: { dailyLimit: 20, totalBoxes: 5 }, + const col = await getCollection(DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION); + await col.create({ + userId, + settings: { + dailyLimit: 20, + totalBoxes: 5, + }, items: [], }); } } - static async getReviewBundle(userId: string) { - await this.ensureInitialized(userId); - const reviewBundle = await getCollection(DATABASE_GENERATIVE, LEITNER_REVIEW_BUNDLE_COLLECTION); - // Find pending bundle - // Sort by createdAt desc to get latest? - const pending = await reviewBundle.findOne( - { refId: userId, status: "pending" }, - null, - { sort: { createdAt: -1 } } - ) as any; - - if (!pending) return null; - - // Populate phrases logic - // We have phraseIds. We need to fetch phrase details from USER_DB. - const phraseCollection = await getCollection(USER_DB, PHRASE_COLLECTION); - const phraseIds = pending.items.map((i: any) => i.phraseId); - - // Manual pseudo-population - // Assuming phraseId is the _id of the phrase doc + static async getDueItems(userId: string, limit: number = 20) { + const system = await this.getSystem(userId); + if (!system) return []; + + const now = new Date(); + + // Sort items (in-memory) + let dueItems = system.items.filter((item: LeitnerItem) => new Date(item.nextReviewDate) <= now); + dueItems.sort((a: LeitnerItem, b: LeitnerItem) => new Date(a.nextReviewDate).getTime() - new Date(b.nextReviewDate).getTime()); + + const selectedItems = dueItems.slice(0, limit); + + const phraseIds = selectedItems.map((i: LeitnerItem) => i.phraseId); + if (phraseIds.length === 0) return []; + + const phraseCollection = await getCollection(DATABASE, PHRASE_COLLECTION); const phrases = await phraseCollection.find({ _id: { $in: phraseIds } }); - - // Map phrases back to items - const populatedItems = pending.items.map((item: any) => { - const phraseParams = phrases.find((p: any) => String(p._id) === String(item.phraseId)); - return { - ...item, - phrase: phraseParams - }; - }); - return { - ...pending.toJSON(), - items: populatedItems - }; + // Join + return selectedItems.map((item: LeitnerItem) => { + const phrase = phrases.find((p: any) => p._id.toString() === item.phraseId.toString()); + return { + ...item, + phrase + } + }).filter((item: any) => item.phrase); } - static async addPhraseToBox( - userId: string, - phraseId: string, - boxLevel: number = 1 - ) { - const leitnerSystem = await getCollection( - DATABASE_GENERATIVE, - LEITNER_SYSTEM_COLLECTION - ); - await this.ensureInitialized(userId); - - const doc = await leitnerSystem.findOne({ refId: userId }) as any; - const exists = doc.items.find((i: any) => i.phraseId === phraseId); - - if (exists) { - if (boxLevel === 1) { - // Reset logic - await leitnerSystem.updateOne( - { refId: userId, "items.phraseId": phraseId }, - { - $set: { - "items.$.boxLevel": 1, - "items.$.nextReviewDate": new Date(), - "items.$.consecutiveFailures": 0, - }, - } - ); - } - } else { - await leitnerSystem.updateOne( - { refId: userId }, - { - $push: { - items: { - phraseId, - boxLevel, - nextReviewDate: new Date(), - lastAttemptDate: null, - consecutiveFailures: 0, - }, - }, - } - ); - } + static async getDueCount(userId: string): Promise { + const system = await this.getSystem(userId); + if (!system) return 0; + const now = new Date(); + return system.items.filter((item: LeitnerItem) => new Date(item.nextReviewDate) <= now).length; } - static async submitReviewResult( - userId: string, - results: { phraseId: string; correct: boolean }[] - ) { - const leitnerSystem = await getCollection( - DATABASE_GENERATIVE, - LEITNER_SYSTEM_COLLECTION - ); - const doc = await leitnerSystem.findOne({ refId: userId }) as any; - - if (!doc) return; // Should not happen if initialized - - const updates = []; - - for (const res of results) { - let item = doc.items.find((i: any) => i.phraseId === res.phraseId); - - let currentBox = item ? item.boxLevel : 1; - let consecutiveFailures = item ? item.consecutiveFailures || 0 : 0; - let newBox = currentBox; - let newFailures = 0; - let nextDate = new Date(); - - if (res.correct) { - newBox = Math.min(doc.settings.totalBoxes, currentBox + 1); - newFailures = 0; - const days = Math.pow(2, newBox - 1); - nextDate.setDate(nextDate.getDate() + days); + static async submitReview(userId: string, phraseId: string, isCorrect: boolean): Promise { + const system = await this.getSystem(userId); + if (!system) { + await this.ensureInitialized(userId); + await this.submitReview(userId, phraseId, isCorrect); + return; + } + + const col = await getCollection(DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION); + const itemIndex = system.items.findIndex((i: LeitnerItem) => i.phraseId.toString() === phraseId.toString()); + const item = itemIndex >= 0 ? system.items[itemIndex] : null; + + let newItem: LeitnerItem; + const now = new Date(); + + if (!item) { + const nextBox = isCorrect ? 2 : 1; + const nextDate = this.calculateNextDate(nextBox); + + newItem = { + phraseId, + boxLevel: nextBox, + nextReviewDate: nextDate, + lastAttemptDate: now, + consecutiveIncorrect: isCorrect ? 0 : 1 + }; + + await col.updateOne( + { _id: system._id }, + { $push: { items: newItem } } + ); + } else { + let nextBox = item.boxLevel; + let nextConsecutiveIncorrect = item.consecutiveIncorrect || 0; + + if (isCorrect) { + nextBox = Math.min(item.boxLevel + 1, system.settings.totalBoxes); + nextConsecutiveIncorrect = 0; } else { - if (consecutiveFailures > 0) { - newBox = 1; - newFailures = 0; + nextConsecutiveIncorrect++; + if (nextConsecutiveIncorrect === 1) { + nextBox = Math.max(1, item.boxLevel - 1); } else { - newBox = Math.max(1, currentBox - 1); - newFailures = 1; + nextBox = 1; } - // Review ASAP (tomorrow?) or same logic? - // Logic: "Move phrase to previous box". - // Implicitly it adopts the schedule of the new box. - const days = Math.pow(2, newBox - 1); - nextDate.setDate(nextDate.getDate() + days); } - if (item) { - await leitnerSystem.updateOne( - { refId: userId, "items.phraseId": res.phraseId }, - { - $set: { - "items.$.boxLevel": newBox, - "items.$.consecutiveFailures": newFailures, - "items.$.nextReviewDate": nextDate, - "items.$.lastAttemptDate": new Date(), - }, - } - ); - } else { - await leitnerSystem.updateOne( - { refId: userId }, - { - $push: { - items: { - phraseId: res.phraseId, - boxLevel: newBox, - consecutiveFailures: newFailures, - nextReviewDate: nextDate, - lastAttemptDate: new Date(), - }, - }, + const nextDate = this.calculateNextDate(nextBox); + + const updateField = `items.${itemIndex}`; + await col.updateOne( + { _id: system._id }, + { + $set: { + [`${updateField}.boxLevel`]: nextBox, + [`${updateField}.nextReviewDate`]: nextDate, + [`${updateField}.lastAttemptDate`]: now, + [`${updateField}.consecutiveIncorrect`]: nextConsecutiveIncorrect } - ); - } + } + ); } - - // Complete the pending bundle if any - const reviewBundle = await getCollection(DATABASE_GENERATIVE, LEITNER_REVIEW_BUNDLE_COLLECTION); - await reviewBundle.updateOne( - { refId: userId, status: 'pending' }, - { $set: { status: 'completed' } } - ); - } - static async generateDailyBundles() { - // Iterate all leitner systems - const leitnerSystem = await getCollection( - DATABASE_GENERATIVE, - LEITNER_SYSTEM_COLLECTION + // Sync Board State after review + const dueCount = await this.getDueCount(userId); + await BoardService.refreshActivity( + userId, + "leitner_review", + { dueCount }, + dueCount > 0, // only toast if items remain + "singleton" ); - const reviewBundle = await getCollection(DATABASE_GENERATIVE, LEITNER_REVIEW_BUNDLE_COLLECTION); - - // This might be heavy if many users. For now, find all. - const allSystems = await leitnerSystem.find({}) as any[]; - - for (const sys of allSystems) { - const userId = sys.refId; - const limit = sys.settings.dailyLimit; - const now = new Date(); - - // Find items due - const dueItems = sys.items.filter((i: any) => new Date(i.nextReviewDate) <= now); - - // Requirements: "Creates a ReviewBundle... Limit: MaxBundles (e.g. 3)" - // Check existing pending bundles - const pendingCount = await reviewBundle.countDocuments({ refId: userId, status: 'pending' }); - if (pendingCount >= 3) { - // "Oldest unstarted bundle is replaced" - // Wait, "unstarted". status 'pending' implies unstarted? - // "In-progress bundles are kept". We don't distinguish pending vs in-progress in schema yet. - // Using 'pending' as unstarted. - const oldest = await reviewBundle.find({ refId: userId, status: 'pending' }, null, { sort: { createdAt: 1 }, limit: 1 }); - if (oldest.length > 0) { - await reviewBundle.deleteOne({ _id: oldest[0]._id }); - } - } + } - if (dueItems.length === 0) continue; - - const selection = dueItems.slice(0, limit); - - const bundleItems = selection.map((i: any) => ({ - phraseId: i.phraseId, - boxLevelAtGeneration: i.boxLevel - })); - - await reviewBundle.create({ - refId: userId, - type: 'daily', - status: 'pending', - items: bundleItems, - createdAt: new Date() // Schema has timestamps but explicit is fine - }); - } - return { success: true }; + private static calculateNextDate(boxLevel: number): Date { + const now = new Date(); + const days = Math.pow(2, boxLevel - 1); + now.setDate(now.getDate() + days); + return now; } static async getStats(userId: string) { - await this.ensureInitialized(userId); - const leitnerSystem = await getCollection( - DATABASE_GENERATIVE, - LEITNER_SYSTEM_COLLECTION - ); - const doc = await leitnerSystem.findOne({ refId: userId }) as any; - if (!doc) return { boxes: {} }; - - const boxes: Record = {}; - // Initialize standard boxes 1-5 (or doc.settings.totalBoxes) - for (let i = 1; i <= (doc.settings?.totalBoxes || 5); i++) { - boxes[i] = 0; - } + const system = await this.getSystem(userId); + if (!system) return null; - doc.items.forEach((item: any) => { - const box = item.boxLevel || 1; - boxes[box] = (boxes[box] || 0) + 1; + // Distribution + const distribution: Record = {}; + system.items.forEach((item) => { + distribution[item.boxLevel] = (distribution[item.boxLevel] || 0) + 1; }); - return { boxes, totalPhrases: doc.items.length, settings: doc.settings }; + return { + settings: system.settings, + distribution, + totalItems: system.items.length + }; + } + + static async updateSettings(userId: string, settings: { dailyLimit?: number; totalBoxes?: number }): Promise { + const system = await this.getSystem(userId); + if (!system) { + await this.ensureInitialized(userId); + return this.updateSettings(userId, settings); + } + + const col = await getCollection(DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION); + + const updatePayload: any = {}; + if (settings.dailyLimit) updatePayload["settings.dailyLimit"] = settings.dailyLimit; + if (settings.totalBoxes) updatePayload["settings.totalBoxes"] = settings.totalBoxes; + + await col.updateOne({ _id: system._id }, { $set: updatePayload }); + + // If totalBoxes reduced, we might need to clamp items + if (settings.totalBoxes && settings.totalBoxes < system.settings.totalBoxes) { + // Clamp logic: Items in box > newTotal -> newTotal + // This is complex to do atomically with one update if items are just an array. + // We might need to iterate or use array filters if possible. + // For MVF, we can leave them "stranded" or lazily correct them on next review. + // "Lazy correction" in submitReview handles it: Math.min(item.boxLevel + 1, system.settings.totalBoxes) + // But what if it's currently 5 and max becomes 3? + // When reviewed, it will use new max. + // But `nextReviewDate` calculation logic might differ. + // It's acceptable for now. + } } - static async updateSettings(userId: string, settings: { dailyLimit?: number, totalBoxes?: number }) { - await this.ensureInitialized(userId); - const leitnerSystem = await getCollection(DATABASE_GENERATIVE, LEITNER_SYSTEM_COLLECTION); - const doc = await leitnerSystem.findOne({ refId: userId }) as any; - - if (!doc) return { success: false }; - - const newSettings = { ...doc.settings, ...settings }; - - // Logic for reducing totalBoxes - if (settings.totalBoxes && settings.totalBoxes < doc.settings.totalBoxes) { - const newMax = settings.totalBoxes; - - await leitnerSystem.updateMany( - { refId: userId, "items.boxLevel": { $gt: newMax } }, - { $set: { "items.$[elem].boxLevel": newMax } }, - { arrayFilters: [{ "elem.boxLevel": { $gt: newMax } }] } - ); + static async addPhraseToBox(userId: string, phraseId: string, initialBox: number = 1): Promise { + const system = await this.getSystem(userId); + if (!system) { + await this.ensureInitialized(userId); + return this.addPhraseToBox(userId, phraseId, initialBox); } - await leitnerSystem.updateOne( - { refId: userId }, - { $set: { settings: newSettings } } + const col = await getCollection(DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION); + const exists = system.items.some(i => i.phraseId.toString() === phraseId.toString()); + + if (exists) return; // Idempotent + + const now = new Date(); + + const newItem: LeitnerItem = { + phraseId, + boxLevel: initialBox, + nextReviewDate: now, + lastAttemptDate: now, + consecutiveIncorrect: 0 + }; + + await col.updateOne( + { _id: system._id }, + { $push: { items: newItem } } + ); + + // Sync Board State + // We can assume at least 1 is due now (the one we just added) + // plus any others. + const dueCount = await this.getDueCount(userId); + await BoardService.refreshActivity( + userId, + "leitner_review", + { dueCount }, + dueCount > 0, + "singleton" ); - - return { success: true }; } } - diff --git a/server/src/modules/phrase_bundle/triggers.ts b/server/src/modules/phrase_bundle/triggers.ts index e0fbab3..1ca2d66 100644 --- a/server/src/modules/phrase_bundle/triggers.ts +++ b/server/src/modules/phrase_bundle/triggers.ts @@ -23,10 +23,6 @@ export const phraseBundleTriggers = [ } // When a new phrase is created, add it to the Leitner system - // - // Ensure this is a phrase being inserted, not a bundle - // We can check for specific phrase fields or just try-catch - // But typically this trigger file is attached to phraseCollection too if (doc && doc.phrase && doc.translation && doc.refId) { try { await LeitnerService.addPhraseToBox(doc.refId, doc._id, 1); @@ -36,6 +32,55 @@ export const phraseBundleTriggers = [ } }), + new DatabaseTrigger("insert-many", async (context) => { + const { docs } = context; + if (!docs || docs.length === 0) return; + + // 1. Update Freemium Allocation + // Assuming all docs belong to the same user if inserted in batch? + // Usually yes for client-side bundle creation, but modular-rest batch insert might mix? + // Safest is to group by user or iterate. + // Given the request context "phrase bundle", typically one user creates a bundle. + // However, robust code should handle mixed. + + // Group by user for efficiency + const userIncrements: Record = {}; + const leitnerAdditions: { userId: string; docId: string }[] = []; + + for (const doc of docs) { + const user_id = doc.refId; // Owner ID + if (user_id) { + userIncrements[user_id] = (userIncrements[user_id] || 0) + 1; + + if (doc.phrase && doc.translation) { + leitnerAdditions.push({ userId: user_id, docId: doc._id }); + } + } + } + + // Process Freemium Allocations + for (const [userId, count] of Object.entries(userIncrements)) { + const isFreemium = await isUserOnFreemium(userId); + if (isFreemium) { + await updateFreemiumAllocation({ + userId, + increment: { allowed_save_words_used: count } + }); + } + } + + // Process Leitner Additions + // This could spam simple addPhraseToBox calls which do DB writes. + // ideally addPhraseToBox could take a batch, but for now we loop. + for (const item of leitnerAdditions) { + try { + await LeitnerService.addPhraseToBox(item.userId, item.docId, 1); + } catch (e) { + console.error("Failed to add phrase to Leitner system", e); + } + } + }), + // When a phrase bundle is deleted, // we need to update the freemium allocation new DatabaseTrigger("remove-one", async (context) => { diff --git a/server/src/triggers.ts b/server/src/triggers.ts index d4bc770..e972860 100644 --- a/server/src/triggers.ts +++ b/server/src/triggers.ts @@ -131,7 +131,7 @@ export const authTriggers: CmsTrigger[] = [ const insertedPhrases = await phraseCollection.insertMany( bundleData.phrases ); - const phraseIds = insertedPhrases.map((phrase) => phrase._id); + const phraseIds = insertedPhrases.map((phrase: any) => phrase._id); // Update the bundle to include the phrase IDs await bundleCollection.updateOne( @@ -139,7 +139,7 @@ export const authTriggers: CmsTrigger[] = [ { $push: { phrases: { $each: phraseIds } } } ); } - + // Initialize Leitner System (for new users) await LeitnerService.ensureInitialized(userId); }), diff --git a/server/yarn.lock b/server/yarn.lock index bfd5be2..ce24d27 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -24,7 +24,7 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz" integrity sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw== -"@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.11.0", "@babel/core@^7.23.9", "@babel/core@^7.27.4", "@babel/core@>=7.0.0-beta.0 <8": +"@babel/core@^7.23.9", "@babel/core@^7.27.4": version "7.28.0" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz" integrity sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ== @@ -285,6 +285,28 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@emnapi/core@^1.4.3": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" + integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" + integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== + dependencies: + tslib "^2.4.0" + "@google-cloud/text-to-speech@^6.1.0": version "6.1.0" resolved "https://registry.npmjs.org/@google-cloud/text-to-speech/-/text-to-speech-6.1.0.tgz" @@ -529,7 +551,7 @@ jest-haste-map "30.0.5" slash "^3.0.0" -"@jest/transform@^29.0.0 || ^30.0.0", "@jest/transform@30.0.5": +"@jest/transform@30.0.5": version "30.0.5" resolved "https://registry.npmjs.org/@jest/transform/-/transform-30.0.5.tgz" integrity sha512-Vk8amLQCmuZyy6GbBht1Jfo9RSdBtg7Lks+B0PecnjI8J+PCLQPGh7uI8Q/2wwpW2gLdiAfiHNsmekKlywULqg== @@ -550,7 +572,7 @@ slash "^3.0.0" write-file-atomic "^5.0.1" -"@jest/types@^29.0.0 || ^30.0.0", "@jest/types@30.0.5": +"@jest/types@30.0.5": version "30.0.5" resolved "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz" integrity sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ== @@ -581,39 +603,15 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== -"@jridgewell/trace-mapping@^0.3.12": - version "0.3.29" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" - integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@jridgewell/trace-mapping@^0.3.23": - version "0.3.29" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" - integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@jridgewell/trace-mapping@^0.3.24": - version "0.3.29" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" - integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@jridgewell/trace-mapping@^0.3.25": - version "0.3.29" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" - integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.28": +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": version "0.3.29" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== @@ -621,14 +619,6 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@jridgewell/trace-mapping@0.3.9": - version "0.3.9" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" - integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== - dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@js-sdsl/ordered-map@^4.4.2": version "4.4.2" resolved "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz" @@ -641,10 +631,24 @@ dependencies: vary "^1.1.2" -"@modular-rest/server@1.15.0": - version "1.15.0" - resolved "https://registry.npmjs.org/@modular-rest/server/-/server-1.15.0.tgz" - integrity sha512-OJejPiM5aceZkIkEj1pnhAOpJM/eg/7EKOw8SseBJjiMWy/1ku5xXKDQ2XOUh0njcmYvCGR5zZn7Z83qVXRwzA== +"@modular-rest/server@/Users/navid-shad/Projects/CodeBridger/modular-rest/packages/server-ts": + version "1.20.1" + dependencies: + "@koa/cors" "^3.1.0" + colog "^1.0.4" + file-system "^2.2.2" + jsonwebtoken "^8.5.1" + keypair "^1.0.4" + koa "^2.5.3" + koa-body "6.0.1" + koa-mount "^4.0.0" + koa-router "^7.4.0" + koa-static "^5.0.0" + mongoose "^5.10.9" + nested-property "^4.0.0" + +"@modular-rest/server@file:../../../modular-rest/packages/server-ts": + version "1.20.1" dependencies: "@koa/cors" "^3.1.0" colog "^1.0.4" @@ -666,6 +670,15 @@ dependencies: sparse-bitfield "^3.0.3" +"@napi-rs/wasm-runtime@^0.2.11": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" + integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.10.0" + "@noble/hashes@^1.1.5": version "1.7.2" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz" @@ -785,6 +798,13 @@ resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== +"@tybys/wasm-util@^0.10.0": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + "@types/accepts@*": version "1.3.7" resolved "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz" @@ -999,6 +1019,13 @@ "@types/bson" "*" "@types/node" "*" +"@types/node-schedule@^2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@types/node-schedule/-/node-schedule-2.1.8.tgz#138e73c9301335d044f33015d1342a602d849ae4" + integrity sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@^22.7.5": version "22.13.10" resolved "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz" @@ -1096,11 +1123,103 @@ resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== +"@unrs/resolver-binding-android-arm-eabi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" + integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== + +"@unrs/resolver-binding-android-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" + integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== + "@unrs/resolver-binding-darwin-arm64@1.11.1": version "1.11.1" resolved "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz" integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== +"@unrs/resolver-binding-darwin-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" + integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== + +"@unrs/resolver-binding-freebsd-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" + integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== + +"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" + integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== + +"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" + integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== + +"@unrs/resolver-binding-linux-arm64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" + integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== + +"@unrs/resolver-binding-linux-arm64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" + integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== + +"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" + integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== + +"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" + integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== + +"@unrs/resolver-binding-linux-riscv64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" + integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== + +"@unrs/resolver-binding-linux-s390x-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" + integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== + +"@unrs/resolver-binding-linux-x64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" + integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== + +"@unrs/resolver-binding-linux-x64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" + integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== + +"@unrs/resolver-binding-wasm32-wasi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" + integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ== + dependencies: + "@napi-rs/wasm-runtime" "^0.2.11" + +"@unrs/resolver-binding-win32-arm64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" + integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== + +"@unrs/resolver-binding-win32-ia32-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" + integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== + +"@unrs/resolver-binding-win32-x64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" + integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" @@ -1128,11 +1247,6 @@ acorn@^8.11.0, acorn@^8.4.1: resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz" integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== -agent-base@^7.1.2: - version "7.1.3" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz" - integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== - agent-base@6: version "6.0.2" resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" @@ -1140,6 +1254,11 @@ agent-base@6: dependencies: debug "4" +agent-base@^7.1.2: + version "7.1.3" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz" + integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== + ansi-escapes@^4.3.2: version "4.3.2" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz" @@ -1226,7 +1345,7 @@ b4a@^1.6.4: resolved "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz" integrity sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg== -"babel-jest@^29.0.0 || ^30.0.0", babel-jest@30.0.5: +babel-jest@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.5.tgz" integrity sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg== @@ -1343,7 +1462,7 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0, "browserslist@>= 4.21.0": +browserslist@^4.24.0: version "4.25.1" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz" integrity sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw== @@ -1568,6 +1687,13 @@ create-require@^1.1.0: resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cron-parser@^4.2.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" + integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== + dependencies: + luxon "^3.2.1" + cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" @@ -1587,26 +1713,26 @@ date-and-time@^3.6.0: resolved "https://registry.npmjs.org/date-and-time/-/date-and-time-3.6.0.tgz" integrity sha512-V99gLaMqNQxPCObBumb31Bfy3OByXnpqUM0yHPi/aBQE61g42A2rGk6Z2CDnpLrWsOFLQwOgl4Vgshw6D44ebw== -debug@^3.1.0: - version "3.2.7" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== +debug@3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== dependencies: - ms "^2.1.1" + ms "2.0.0" -debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1, debug@4: +debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1: version "4.4.1" resolved "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz" integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== dependencies: ms "^2.1.3" -debug@3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== +debug@^3.1.0: + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: - ms "2.0.0" + ms "^2.1.1" decimal.js-light@^2.5.1: version "2.5.1" @@ -1643,7 +1769,7 @@ denque@^1.4.1: resolved "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz" integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== -depd@^2.0.0, depd@~2.0.0, depd@2.0.0: +depd@2.0.0, depd@^2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -1705,7 +1831,7 @@ eastasianwidth@^0.2.0: resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -ecdsa-sig-formatter@^1.0.11, ecdsa-sig-formatter@1.0.11: +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -1835,7 +1961,7 @@ exit-x@^0.2.2: resolved "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz" integrity sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ== -expect@^30.0.0, expect@30.0.5: +expect@30.0.5, expect@^30.0.0: version "30.0.5" resolved "https://registry.npmjs.org/expect/-/expect-30.0.5.tgz" integrity sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ== @@ -1857,7 +1983,7 @@ fast-fifo@^1.2.0, fast-fifo@^1.3.2: resolved "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz" integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== -fast-json-stable-stringify@^2.1.0, fast-json-stable-stringify@2.x: +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -1984,16 +2110,6 @@ function-bind@^1.1.2: resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -gaxios@^5.0.0: - version "5.1.3" - resolved "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz" - integrity sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA== - dependencies: - extend "^3.0.2" - https-proxy-agent "^5.0.0" - is-stream "^2.0.0" - node-fetch "^2.6.9" - gaxios@^6.0.0, gaxios@^6.0.3, gaxios@^6.1.1: version "6.7.1" resolved "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz" @@ -2014,14 +2130,6 @@ gaxios@^7.0.0-rc.1, gaxios@^7.0.0-rc.4: https-proxy-agent "^7.0.1" node-fetch "^3.3.2" -gcp-metadata@^5.2.0: - version "5.3.0" - resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz" - integrity sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w== - dependencies: - gaxios "^5.0.0" - json-bigint "^1.0.0" - gcp-metadata@^6.1.0: version "6.1.1" resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz" @@ -2244,6 +2352,17 @@ http-assert@^1.3.0: deep-equal "~1.0.1" http-errors "~1.8.0" +http-errors@2.0.0, http-errors@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-errors@^1.3.1, http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0: version "1.8.1" resolved "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz" @@ -2255,17 +2374,6 @@ http-errors@^1.3.1, http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0: statuses ">= 1.5.0 < 2" toidentifier "1.0.1" -http-errors@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - http-errors@~1.6.2: version "1.6.3" resolved "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz" @@ -2276,17 +2384,6 @@ http-errors@~1.6.2: setprototypeof "1.1.0" statuses ">= 1.4.0 < 2" -http-errors@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz" @@ -2350,7 +2447,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@^2.0.3, inherits@~2.0.3, inherits@2, inherits@2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2405,16 +2502,16 @@ is-stream@^2.0.0: resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== - isarray@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" @@ -2681,7 +2778,7 @@ jest-resolve-dependencies@30.0.5: jest-regex-util "30.0.1" jest-snapshot "30.0.5" -jest-resolve@*, jest-resolve@30.0.5: +jest-resolve@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.5.tgz" integrity sha512-d+DjBQ1tIhdz91B79mywH5yYu76bZuE96sSbxj8MkjWVx5WNdt1deEFRONVL4UkKLSrAbMkdhb24XN691yDRHg== @@ -2778,7 +2875,7 @@ jest-snapshot@30.0.5: semver "^7.7.2" synckit "^0.11.8" -"jest-util@^29.0.0 || ^30.0.0", jest-util@30.0.5: +jest-util@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz" integrity sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g== @@ -2827,7 +2924,7 @@ jest-worker@30.0.5: merge-stream "^2.0.0" supports-color "^8.1.1" -"jest@^29.0.0 || ^30.0.0", jest@^30.0.5: +jest@^30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/jest/-/jest-30.0.5.tgz" integrity sha512-y2mfcJywuTUkvLm2Lp1/pFX8kTgMO5yyQGq/Sk/n2mN7XWYp4JsCZ/QXW34M8YScgk8bPZlREH04f6blPnoHnQ== @@ -3108,6 +3205,11 @@ lodash.once@^4.0.0: resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== +long-timeout@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/long-timeout/-/long-timeout-0.1.1.tgz#9721d788b47e0bcb5a24c2e2bee1a0da55dab514" + integrity sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w== + long@*, long@^5.0.0: version "5.3.2" resolved "https://registry.npmjs.org/long/-/long-5.3.2.tgz" @@ -3125,6 +3227,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +luxon@^3.2.1: + version "3.7.2" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.7.2.tgz#d697e48f478553cca187a0f8436aff468e3ba0ba" + integrity sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew== + make-dir@^3.0.2: version "3.1.0" resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz" @@ -3201,14 +3308,7 @@ mimic-fn@^2.1.0: resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -minimatch@^3.0.4, minimatch@^3.1.1: - version "3.1.2" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimatch@^3.1.2: +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -3268,15 +3368,6 @@ mongodb-memory-server@^10.2.0: mongodb-memory-server-core "10.2.0" tslib "^2.8.1" -mongodb@^6.9.0: - version "6.18.0" - resolved "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz" - integrity sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ== - dependencies: - "@mongodb-js/saslprep" "^1.1.9" - bson "^6.10.4" - mongodb-connection-string-url "^3.0.0" - mongodb@3.7.4: version "3.7.4" resolved "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz" @@ -3290,12 +3381,21 @@ mongodb@3.7.4: optionalDependencies: saslprep "^1.0.0" +mongodb@^6.9.0: + version "6.18.0" + resolved "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz" + integrity sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ== + dependencies: + "@mongodb-js/saslprep" "^1.1.9" + bson "^6.10.4" + mongodb-connection-string-url "^3.0.0" + mongoose-legacy-pluralize@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz" integrity sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ== -mongoose@*, mongoose@^5.10.9: +mongoose@^5.10.9: version "5.13.23" resolved "https://registry.npmjs.org/mongoose/-/mongoose-5.13.23.tgz" integrity sha512-Q5bo1yYOcH2wbBPP4tGmcY5VKsFkQcjUDh66YjrbneAFB3vNKQwLvteRFLuLiU17rA5SDl3UMcMJLD9VS8ng2Q== @@ -3331,11 +3431,6 @@ mquery@3.2.5: safe-buffer "5.1.2" sliced "1.0.1" -ms@^2.1.1, ms@^2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -3346,6 +3441,11 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + napi-postinstall@^0.3.0: version "0.3.2" resolved "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz" @@ -3378,7 +3478,7 @@ node-domexception@^1.0.0: resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== -node-fetch@^2.6.9, node-fetch@2: +node-fetch@2, node-fetch@^2.6.9: version "2.7.0" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -3404,6 +3504,15 @@ node-releases@^2.0.19: resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz" integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== +node-schedule@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/node-schedule/-/node-schedule-2.1.1.tgz#6958b2c5af8834954f69bb0a7a97c62b97185de3" + integrity sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ== + dependencies: + cron-parser "^4.2.0" + long-timeout "0.1.1" + sorted-array-functions "^1.3.0" + normalize-path@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" @@ -3452,6 +3561,11 @@ only@~0.0.2: resolved "https://registry.npmjs.org/only/-/only-0.0.2.tgz" integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ== +optional-require@1.0.x: + version "1.0.3" + resolved "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz" + integrity sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA== + optional-require@^1.1.8: version "1.1.8" resolved "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz" @@ -3459,11 +3573,6 @@ optional-require@^1.1.8: dependencies: require-at "^1.0.6" -optional-require@1.0.x: - version "1.0.3" - resolved "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz" - integrity sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA== - p-limit@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz" @@ -3515,7 +3624,7 @@ path-exists@^4.0.0: resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== -path-is-absolute@^1.0.0, path-is-absolute@1.0.1: +path-is-absolute@1.0.1, path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== @@ -3577,7 +3686,7 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pretty-format@^30.0.0, pretty-format@30.0.5: +pretty-format@30.0.5, pretty-format@^30.0.0: version "30.0.5" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz" integrity sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw== @@ -3688,7 +3797,7 @@ readable-stream@^3.1.1: string_decoder "^1.1.1" util-deprecate "^1.0.1" -regexp-clone@^1.0.0, regexp-clone@1.0.0: +regexp-clone@1.0.0, regexp-clone@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz" integrity sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw== @@ -3732,25 +3841,15 @@ retry-request@^8.0.0: extend "^3.0.2" teeny-request "^10.0.0" -safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0, safe-buffer@5.2.1: - version "5.2.1" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safe-buffer@~5.1.0: - version "5.1.2" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -safe-buffer@~5.1.1: +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.1.2: - version "5.1.2" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== safe-regex-test@^1.1.0: version "1.1.0" @@ -3778,27 +3877,12 @@ semver@^5.6.0: resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@^6.0.0: +semver@^6.0.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^6.3.1: - version "6.3.1" - resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^7.5.3: - version "7.7.2" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - -semver@^7.5.4: - version "7.7.2" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - -semver@^7.7.2: +semver@^7.5.3, semver@^7.5.4, semver@^7.7.2: version "7.7.2" resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== @@ -3890,6 +3974,11 @@ sliced@1.0.1: resolved "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz" integrity sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA== +sorted-array-functions@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz#8605695563294dffb2c9796d602bd8459f7a0dd5" + integrity sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA== + source-map-support@0.5.13: version "0.5.13" resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz" @@ -3922,16 +4011,16 @@ stack-utils@^2.0.6: dependencies: escape-string-regexp "^2.0.0" -statuses@^1.5.0, "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2": - version "1.5.0" - resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" - integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== - statuses@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0: + version "1.5.0" + resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + stream-events@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz" @@ -3954,20 +4043,6 @@ streamx@^2.15.0: optionalDependencies: bare-events "^2.2.0" -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - string-length@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz" @@ -4003,6 +4078,20 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -4152,7 +4241,7 @@ ts-jest@^29.4.0: type-fest "^4.41.0" yargs-parser "^21.1.1" -ts-node@^10.9.2, ts-node@>=9.0.0: +ts-node@^10.9.2: version "10.9.2" resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz" integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== @@ -4204,7 +4293,7 @@ type-is@^1.6.16: media-typer "0.3.0" mime-types "~2.1.24" -typescript@^5.6.3, typescript@>=2.7, "typescript@>=4.3 <6": +typescript@^5.6.3: version "5.8.2" resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz" integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ== @@ -4444,7 +4533,7 @@ zod-to-json-schema@^3.24.5: resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz" integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== -zod@^3.19.1, zod@^3.24.1, zod@^3.24.3: +zod@^3.19.1, zod@^3.24.3: version "3.24.3" resolved "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz" integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg== From dcb92c44dbe0740ddacd4f1ba869702d61201c36 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Fri, 23 Jan 2026 17:10:36 +0200 Subject: [PATCH 06/33] feat: #86erqa08y sync phrase removal and refine user context in Leitner system --- frontend/stores/leitner.ts | 4 +- server/package.json | 2 +- server/src/modules/leitner_box/functions.ts | 45 +++++++++----------- server/src/modules/leitner_box/service.ts | 23 ++++++++++ server/src/modules/phrase_bundle/triggers.ts | 35 ++++++++++++--- server/yarn.lock | 22 ++-------- 6 files changed, 80 insertions(+), 51 deletions(-) diff --git a/frontend/stores/leitner.ts b/frontend/stores/leitner.ts index ee55f2f..83c0e8b 100644 --- a/frontend/stores/leitner.ts +++ b/frontend/stores/leitner.ts @@ -47,7 +47,7 @@ export const useLeitnerStore = defineStore('leitner', () => { try { const items = await functionProvider.run({ name: 'get-review-session', - args: { limit } + args: { limit, userId: authUser.value?.id } }) as LeitnerItemType[]; reviewSessionItems.value = items || []; @@ -62,7 +62,7 @@ export const useLeitnerStore = defineStore('leitner', () => { try { await functionProvider.run({ name: 'submit-review', - args: { phraseId, isCorrect } + args: { phraseId, isCorrect, userId: authUser.value?.id } }); // Remove from local session to show progress (if needed) diff --git a/server/package.json b/server/package.json index 1fcc8b9..2368762 100644 --- a/server/package.json +++ b/server/package.json @@ -16,7 +16,7 @@ "license": "MIT", "dependencies": { "@google-cloud/text-to-speech": "^6.1.0", - "@modular-rest/server": "/Users/navid-shad/Projects/CodeBridger/modular-rest/packages/server-ts", + "@modular-rest/server": "^1.21.0", "@types/koa-router": "^7.4.8", "date-and-time": "^3.6.0", "decimal.js-light": "^2.5.1", diff --git a/server/src/modules/leitner_box/functions.ts b/server/src/modules/leitner_box/functions.ts index afc60cf..c220c4f 100644 --- a/server/src/modules/leitner_box/functions.ts +++ b/server/src/modules/leitner_box/functions.ts @@ -5,15 +5,15 @@ import { BoardService } from "../board/service"; // Frontend API: Get items to review const getReviewSession = defineFunction({ name: "get-review-session", - permissionTypes: ["user"], + permissionTypes: ["user_access"], callback: async (context) => { - if (!context.user) throw new Error("Unauthorized"); - const { limit } = context.params; + const { limit, userId } = context; + if (!userId) throw new Error("Unauthorized"); // Ensure initialized (lazy init) - await LeitnerService.ensureInitialized(context.user._id); + await LeitnerService.ensureInitialized(userId); - const items = await LeitnerService.getDueItems(context.user._id, limit ? parseInt(limit) : 20); + const items = await LeitnerService.getDueItems(userId, limit ? parseInt(limit) : 20); return items; }, }); @@ -21,16 +21,16 @@ const getReviewSession = defineFunction({ // Frontend API: Submit a review result const submitReview = defineFunction({ name: "submit-review", - permissionTypes: ["user"], + permissionTypes: ["user_access"], callback: async (context) => { - if (!context.user) throw new Error("Unauthorized"); - const { phraseId, isCorrect } = context.params; + const { phraseId, isCorrect, userId } = context; + if (!userId) throw new Error("Unauthorized"); if (!phraseId) throw new Error("Phrase ID is required"); if (typeof isCorrect !== "boolean") throw new Error("isCorrect boolean is required"); // Submit review (also triggers init if needed) - await LeitnerService.submitReview(context.user._id, phraseId, isCorrect); + await LeitnerService.submitReview(userId, phraseId, isCorrect); return { success: true }; }, @@ -39,14 +39,10 @@ const submitReview = defineFunction({ // Internal/Cron API: Check status and update board const refreshBoardStatus = defineFunction({ name: "refresh-board-status", - permissionTypes: ["admin", "system"], + permissionTypes: ["user_access"], callback: async (context) => { - const { userId } = context.params; + const { userId } = context; if (!userId) { - if (context.user) { - await _syncUser(context.user._id); - return { success: true }; - } throw new Error("UserId required for refresh-board-status"); } @@ -69,9 +65,9 @@ async function _syncUser(userId: string) { // Admin/System API: Initialize for a user const initLeitner = defineFunction({ name: "init-leitner", - permissionTypes: ["admin", "system", "user"], + permissionTypes: ["user_access"], callback: async (context) => { - const userId = context.params.userId || (context.user ? context.user._id : null); + const { userId } = context; if (!userId) throw new Error("UserId required"); await LeitnerService.ensureInitialized(userId); @@ -81,20 +77,21 @@ const initLeitner = defineFunction({ const getStats = defineFunction({ name: "get-stats", - permissionTypes: ["user"], + permissionTypes: ["user_access"], callback: async (context) => { - if (!context.user) throw new Error("Unauthorized"); - return LeitnerService.getStats(context.user._id); + const { userId } = context; + if (!userId) throw new Error("Unauthorized"); + return LeitnerService.getStats(userId); } }); const updateSettings = defineFunction({ name: "update-settings", - permissionTypes: ["user"], + permissionTypes: ["user_access"], callback: async (context) => { - if (!context.user) throw new Error("Unauthorized"); - const { settings } = context.params; - await LeitnerService.updateSettings(context.user._id, settings); + const { settings, userId } = context; + if (!userId) throw new Error("Unauthorized"); + await LeitnerService.updateSettings(userId, settings); return { success: true }; } }); diff --git a/server/src/modules/leitner_box/service.ts b/server/src/modules/leitner_box/service.ts index 10fef5c..25e321b 100644 --- a/server/src/modules/leitner_box/service.ts +++ b/server/src/modules/leitner_box/service.ts @@ -236,4 +236,27 @@ export class LeitnerService { "singleton" ); } + + static async removePhraseFromBox(userId: string, phraseId: string): Promise { + const system = await this.getSystem(userId); + if (!system) return; + + const col = await getCollection(DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION); + + // Pull the item from the items array + await col.updateOne( + { _id: system._id }, + { $pull: { items: { phraseId: phraseId } } } + ); + + // Sync Board State + const dueCount = await this.getDueCount(userId); + await BoardService.refreshActivity( + userId, + "leitner_review", + { dueCount }, + dueCount > 0, + "singleton" + ); + } } diff --git a/server/src/modules/phrase_bundle/triggers.ts b/server/src/modules/phrase_bundle/triggers.ts index 1ca2d66..f3df15b 100644 --- a/server/src/modules/phrase_bundle/triggers.ts +++ b/server/src/modules/phrase_bundle/triggers.ts @@ -86,15 +86,38 @@ export const phraseBundleTriggers = [ new DatabaseTrigger("remove-one", async (context) => { const { query } = context; const user_id = query?.refId; - const isFreemium = await isUserOnFreemium(user_id); + const phrase_id = query?._id; - if (isFreemium) { - await updateFreemiumAllocation({ - userId: user_id, - increment: { allowed_save_words_used: -1 }, - }); + if (user_id) { + const isFreemium = await isUserOnFreemium(user_id); + if (isFreemium) { + await updateFreemiumAllocation({ + userId: user_id, + increment: { allowed_save_words_used: -1 }, + }); + } + + if (phrase_id) { + try { + await LeitnerService.removePhraseFromBox(user_id, phrase_id.toString()); + } catch (e) { + console.error("Failed to remove phrase from Leitner box", e); + } + } } }), + new DatabaseTrigger("find-one-and-delete", async (context) => { + const { query } = context; + const user_id = query?.refId; + const phrase_id = query?._id; + if (user_id && phrase_id) { + try { + await LeitnerService.removePhraseFromBox(user_id, phrase_id.toString()); + } catch (e) { + console.error("Failed to remove phrase from Leitner box (find-one-and-delete)", e); + } + } + }), ]; diff --git a/server/yarn.lock b/server/yarn.lock index ce24d27..ce5c627 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -631,24 +631,10 @@ dependencies: vary "^1.1.2" -"@modular-rest/server@/Users/navid-shad/Projects/CodeBridger/modular-rest/packages/server-ts": - version "1.20.1" - dependencies: - "@koa/cors" "^3.1.0" - colog "^1.0.4" - file-system "^2.2.2" - jsonwebtoken "^8.5.1" - keypair "^1.0.4" - koa "^2.5.3" - koa-body "6.0.1" - koa-mount "^4.0.0" - koa-router "^7.4.0" - koa-static "^5.0.0" - mongoose "^5.10.9" - nested-property "^4.0.0" - -"@modular-rest/server@file:../../../modular-rest/packages/server-ts": - version "1.20.1" +"@modular-rest/server@^1.21.0": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@modular-rest/server/-/server-1.21.0.tgz#1b955ab15f88632471cda187eb1a68e6dd15180a" + integrity sha512-PIN3utAu9t1q21jKZuqzRmc86rlSiHPVLUkVclL9uapzcyhG5wKEWbRLrscCY9X19kS4AU2CtA+7icxmR3kSEQ== dependencies: "@koa/cors" "^3.1.0" colog "^1.0.4" From 8f8f7bf18ff0f81a902664cfdd02b1ef429c0034 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sat, 24 Jan 2026 00:14:33 +0200 Subject: [PATCH 07/33] feat: #86erqa08y set up documentation download and conversion script in server/scripts --- .agent/modular-rest/SKILL.md | 9 + .agent/modular-rest/client.md | 1352 +++++++++++ .agent/modular-rest/server.md | 2029 +++++++++++++++++ .../rules/clickup.mdc => .agent/task/SKILL.md | 3 +- server/package-lock.json | 756 +++++- server/package.json | 7 +- server/scripts/download_docs.js | 55 + server/yarn.lock | 732 ++++-- 8 files changed, 4696 insertions(+), 247 deletions(-) create mode 100644 .agent/modular-rest/SKILL.md create mode 100644 .agent/modular-rest/client.md create mode 100644 .agent/modular-rest/server.md rename .cursor/rules/clickup.mdc => .agent/task/SKILL.md (96%) create mode 100644 server/scripts/download_docs.js diff --git a/.agent/modular-rest/SKILL.md b/.agent/modular-rest/SKILL.md new file mode 100644 index 0000000..87027e1 --- /dev/null +++ b/.agent/modular-rest/SKILL.md @@ -0,0 +1,9 @@ +--- +name: Modular Rest NPM Package +description: when ever you see ClickUp rules word in the prompt +--- +Here is the official documentation of 2 @modular-rest packages: +- @modular-rest/server: ./server.md +- @modular-rest/client: ./client.md + +You must read the documentation and understand the packages for any relevant implementation. \ No newline at end of file diff --git a/.agent/modular-rest/client.md b/.agent/modular-rest/client.md new file mode 100644 index 0000000..e41fed4 --- /dev/null +++ b/.agent/modular-rest/client.md @@ -0,0 +1,1352 @@ +# Javascript Client [​](#javascript-client) + +Thank you for choosing `modular-rest` to build your app. You can install the client by installing the package from npm. + +## Install Client App [​](#install-client-app) + +it assumed you already have a fronted project based on javascript, it dose not matter what framework you are using, you can use `modular-rest` with any javascript framework. + +Just use below command: + +npmyarn + +sh + +``` +npm install @modular-rest/client +``` + +sh + +``` +yarn add @modular-rest/client +``` + +## Setup the client [​](#setup-the-client) + +You need to setup the global configuration for the client in any initialization file of your project, for example in `src/index.js` file. + +js + +``` +import { GlobalOptions } from "@modular-rest/client"; + +GlobalOptions.set({ + // the base url of the server, it should match with the server address + host: 'http://localhost:8080', +}); +``` + +## Use the client [​](#use-the-client) + +Now you can use the client in your project, for example to use the `AuthService` service, import it as follows: + +js + +``` +import { authentication, dataProvider } from '@modular-rest/client'; + +// first login with any available methods. +authentication.loginWithLastSession() + +// After login, you can use either the dataProvider service or other available services of the client. +const cities = await dataProvider.find({ + database: 'geography', + collection: 'cities', + query: { population: { $gt: 1000000 } }, + options: { limit: 10, sort: { population: -1 } } +}); +``` + +# AuthService Service [​](#authservice-service) + +The `AuthService` class handles the authentication process, including login, token management, and user session handling. + +## Importing the Service [​](#importing-the-service) + +To use the `AuthService` service, import it as follows: + +typescript + +``` +import { authentication } from '@modular-rest/client'; +``` + +## Public Properties [​](#public-properties) + +Property + +Description + +user + +The currently authenticated user, or null if no user is authenticated. + +isLogin + +A boolean indicating if the user is currently logged in. + +## `login()` [​](#login) + +Logs in the user with the provided credentials. + +### Arguments [​](#arguments) + +Argument + +Type + +Description + +`identity` + +`IdentityType` + +The identity of the user (e.g., username or email). + +`password` + +`string` + +The user's password. + +`options` + +`LoginOptionsType` + +Additional login options. + +### Return and Throw [​](#return-and-throw) + +Returns + +Description + +`Promise` + +The login response data. + +Throws + +`Error` if the login fails. + +### Example [​](#example) + +typescript + +``` +authentication.login('user@example.com', 'password123', { rememberMe: true }) + .then(response => { + console.log('Login successful:', response); + }) + .catch(error => { + console.error('Login failed:', error); + }); +``` + +## `loginWithLastSession()` [​](#loginwithlastsession) + +Logs in with the last session if you pass `allowSave=true` in the last login. + +### Arguments [​](#arguments-1) + +Argument + +Type + +Description + +`token` + +`string` + +The token for the last session (optional). + +### Return and Throw [​](#return-and-throw-1) + +Returns + +Description + +`Promise` + +The logged-in user data. + +Throws + +`Error` if the login fails. + +### Example [​](#example-1) + +typescript + +``` +authentication.loginWithLastSession() + .then(user => { + console.log('Logged in with last session:', user); + }) + .catch(error => { + console.error('Login with last session failed:', error); + }); +``` + +## `loginAsAnonymous()` [​](#loginasanonymous) + +Logs in as an anonymous user and retrieves a token. + +Return and Throw Table: + +Returns + +Description + +`Promise` + +The login response data containing the token. + +Throws + +`Error` if the login fails. + +### Example [​](#example-2) + +typescript + +``` +authService.loginAsAnonymous() + .then(response => { + console.log('Anonymous login successful:', response); + }) + .catch(error => { + console.error('Anonymous login failed:', error); + }); +``` + +## `logout()` [​](#logout) + +Logs out the current user and clears the session. + +### Return and Throw [​](#return-and-throw-2) + +Returns + +Description + +`void` + +No return value. + +Throws + +None + +### Example [​](#example-3) + +typescript + +``` +authentication.logout(); +``` + +## `verifyToken()` [​](#verifytoken) + +Verifies the provided token. + +### Arguments [​](#arguments-2) + +Argument + +Type + +Description + +`token` + +`string` + +The token to verify. + +### Return and Throw [​](#return-and-throw-3) + +Returns + +Description + +`Promise` + +The token verification response data. + +Throws + +`Error` if the token verification fails. + +### Example [​](#example-4) + +typescript + +``` +authentication.verifyToken('some-jwt-token') + .then(response => { + console.log('Token verification successful:', response); + }) + .catch(error => { + console.error('Token verification failed:', error); + }); +``` + +## `registerIdentity()` [​](#registeridentity) + +Registers a user identity, the first step for creating a new account. + +### Arguments [​](#arguments-3) + +Argument + +Type + +Description + +`identity` + +`IdentityType` + +The identity of the user. + +### Return and Throw [​](#return-and-throw-4) + +Returns + +Description + +`Promise` + +The registration response data. + +Throws + +`Error` if the registration fails. + +### Example [​](#example-5) + +typescript + +``` +authentication.registerIdentity({ idType: 'email', id: 'user@example.com' }) + .then(response => { + console.log('Identity registered:', response); + }) + .catch(error => { + console.error('Identity registration failed:', error); + }); +``` + +## `validateCode()` [​](#validatecode) + +Validates the provided code. + +### Arguments [​](#arguments-4) + +Argument + +Type + +Description + +`code` + +`string` + +The code to validate. + +### Return and Throw [​](#return-and-throw-5) + +Returns + +Description + +`Promise` + +The validation response data. + +Throws + +`Error` if the validation fails. + +### Example [​](#example-6) + +typescript + +``` +authentication.validateCode('123456') + .then(response => { + console.log('Code validation successful:', response); + }) + .catch(error => { + console.error('Code validation failed:', error); + }); +``` + +## `submitPassword()` [​](#submitpassword) + +Submits a password, the third step for creating a new account. + +### Arguments [​](#arguments-5) + +Argument + +Type + +Description + +`options` + +`object` + +The password submission options. + +`options.id` + +`string` + +The user identity. + +`options.password` + +`string` + +The user's password. + +`options.code` + +`string` + +The verification code. + +### Return and Throw [​](#return-and-throw-6) + +Returns + +Description + +`Promise` + +The password submission response data. + +Throws + +`Error` if the submission fails. + +### Example [​](#example-7) + +typescript + +``` +authentication.submitPassword({ id: 'user@example.com', password: 'newpassword', code: '123456' }) + .then(response => { + console.log('Password submitted successfully:', response); + }) + .catch(error => { + console.error('Password submission failed:', error); + }); +``` + +## `changePassword()` [​](#changepassword) + +Changes the user's password. + +### Arguments [​](#arguments-6) + +Argument + +Type + +Description + +`options` + +`object` + +The password change options. + +`options.id` + +`string` + +The user identity. + +`options.password` + +`string` + +The new password. + +`options.code` + +`string` + +The verification code. + +### Return and Throw [​](#return-and-throw-7) + +Returns + +Description + +`Promise` + +The password change response data. + +Throws + +`Error` if the change fails. + +### Example [​](#example-8) + +typescript + +``` +authentication.changePassword({ id: 'user@example.com', password: 'newpassword', code: '123456' }) + .then(response => { + console.log('Password changed successfully:', response); + }) + .catch(error => { + console.error('Password change failed:', error); + }); +``` + +# DataProvider Service Documentation [​](#dataprovider-service-documentation) + +The DataProvider service is a singleton class that provides methods for interacting with a database through HTTP requests. It offers various operations such as finding, updating, inserting, and aggregating data. + +To use the DataProvider service, import it as follows: + +typescript + +``` +import { dataProvider } from '@modular-rest/client' +``` + +## `list()` [​](#list) + +Returns an object containing pagination information and controller methods for fetching paginated data. + +### Arguments [​](#arguments-7) + +Name + +Type + +Description + +findOption + +FindQueryType + +Query options for finding data + +paginationOption + +Object + +Options for pagination (limit, page, onFetched) + +### Returns/Throws [​](#returns-throws) + +Type + +Description + +`PaginatedResponseType` + +Object with pagination info and control methods + +Error + +Throws if the HTTP request fails + +### Example [​](#example-9) + +typescript + +``` +// Initialize a paginated list of red flowers +const flowerList = dataProvider.list( + { + database: 'botany', + collection: 'flowers', + query: { color: 'red' } + }, + { limit: 20, page: 1, onFetched: (flowers) => console.log(flowers) } +); + +// Need Update pagination after initialization +await flowerList.updatePagination(); + +// Fetch the first page +await flowerList.fetchPage(1); +``` + +## `find()` [​](#find) + +Retrieves an array of documents from the specified database and collection. + +### Arguments [​](#arguments-8) + +Name + +Type + +Description + +options + +FindQueryType + +Query options for finding data + +### Returns/Throws [​](#returns-throws-1) + +Type + +Description + +`Promise>` + +Resolves to an array of found documents + +Error + +Throws if the HTTP request fails + +### Example [​](#example-10) + +typescript + +``` +const cities = await dataProvider.find({ + database: 'geography', + collection: 'cities', + query: { population: { $gt: 1000000 } }, + options: { limit: 10, sort: { population: -1 } } +}); +``` + +## `findByIds()` [​](#findbyids) + +Retrieves documents by their IDs from the specified database and collection. + +### Arguments [​](#arguments-9) + +Name + +Type + +Description + +options + +FindByIdsQueryType + +Options for finding documents by IDs + +### Returns/Throws [​](#returns-throws-2) + +Type + +Description + +`Promise>` + +Resolves to an array of found documents + +Error + +Throws if the HTTP request fails + +### Example [​](#example-11) + +typescript + +``` +const specificCities = await dataProvider.findByIds({ + database: 'geography', + collection: 'cities', + ids: ['city123', 'city456', 'city789'], + accessQuery: { country: 'USA' } +}); +``` + +## `findOne()` [​](#findone) + +Retrieves a single document from the specified database and collection. + +### Arguments [​](#arguments-10) + +Name + +Type + +Description + +options + +FindQueryType + +Query options for finding a single document + +### Returns/Throws [​](#returns-throws-3) + +Type + +Description + +`Promise` + +Resolves to the found document + +Error + +Throws if the HTTP request fails + +### Example [​](#example-12) + +typescript + +``` +const capital = await dataProvider.findOne({ + database: 'geography', + collection: 'cities', + query: { isCapital: true, country: 'France' } +}); +``` + +## `count()` [​](#count) + +Counts the number of documents matching the specified query. + +### Arguments [​](#arguments-11) + +Name + +Type + +Description + +options + +FindQueryType + +Query options for counting documents + +### Returns/Throws [​](#returns-throws-4) + +Type + +Description + +`Promise` + +Resolves to the count of matching documents + +Error + +Throws if the HTTP request fails + +### Example [​](#example-13) + +typescript + +``` +const roseCount = await dataProvider.count({ + database: 'botany', + collection: 'flowers', + query: { genus: 'Rosa' } +}); +``` + +## `updateOne()` [​](#updateone) + +Updates a single document in the specified database and collection. + +### Arguments [​](#arguments-12) + +Name + +Type + +Description + +options + +UpdateQueryType + +Query and update options for modifying a document + +### Returns/Throws [​](#returns-throws-5) + +Type + +Description + +`Promise` + +Resolves to the result of the update operation + +Error + +Throws if the HTTP request fails + +### Example [​](#example-14) + +typescript + +``` +const updateResult = await dataProvider.updateOne({ + database: 'geography', + collection: 'cities', + query: { name: 'New York' }, + update: { $set: { population: 8500000 } } +}); +``` + +## `insertOne()` [​](#insertone) + +Inserts a single document into the specified database and collection. + +### Arguments [​](#arguments-13) + +Name + +Type + +Description + +options + +InsertQueryType + +Options for inserting a new document + +### Returns/Throws [​](#returns-throws-6) + +Type + +Description + +`Promise` + +Resolves to the result of the insert operation + +Error + +Throws if the HTTP request fails + +### Example [​](#example-15) + +typescript + +``` +const newFlower = await dataProvider.insertOne({ + database: 'botany', + collection: 'flowers', + doc: { name: 'Sunflower', genus: 'Helianthus', color: 'yellow' } +}); +``` + +## `removeOne()` [​](#removeone) + +Removes a single document from the specified database and collection. + +### Arguments [​](#arguments-14) + +Name + +Type + +Description + +options + +FindQueryType + +Query options for removing a document + +### Returns/Throws [​](#returns-throws-7) + +Type + +Description + +`Promise` + +Resolves to the result of the remove operation + +Error + +Throws if the HTTP request fails + +### Example [​](#example-16) + +typescript + +``` +const removeResult = await dataProvider.removeOne({ + database: 'geography', + collection: 'cities', + query: { name: 'Ghost Town', population: 0 } +}); +``` + +## `aggregate()` [​](#aggregate) + +Performs an aggregation operation on the specified database and collection. + +### Arguments [​](#arguments-15) + +Name + +Type + +Description + +options + +AggregateQueryType + +Options for the aggregation pipeline + +### Returns/Throws [​](#returns-throws-8) + +Type + +Description + +`Promise>` + +Resolves to the result of the aggregation + +Error + +Throws if the HTTP request fails + +### Example [​](#example-17) + +typescript + +``` +const flowerStats = await dataProvider.aggregate({ + database: 'botany', + collection: 'flowers', + pipelines: [ + { $group: { _id: '$color', count: { $sum: 1 } } }, + { $sort: { count: -1 } } + ], + accessQuery: { genus: { $in: ['Rosa', 'Tulipa'] } } +}); +``` + +# FileProvider Service Documentation [​](#fileprovider-service-documentation) + +The FileProvider service is responsible for managing file operations such as uploading, removing, and retrieving file information. It provides methods to interact with files on the server and manage file metadata. + +To use the FileProvider service, import it as follows: + +javascript + +``` +import { fileProvider } from '@modular-rest/client' +``` + +## `uploadFile()` [​](#uploadfile) + +Uploads a file to the server. + +### Arguments [​](#arguments-16) + +Name + +Type + +Description + +file + +string | Blob + +The file to be uploaded + +onProgress + +(progressEvent: ProgressEvent) => void + +Callback function to track upload progress + +tag + +string (optional) + +Tag for the file, defaults to "untagged" + +### Return and Throw [​](#return-and-throw-8) + +Type + +Description + +`Promise` + +Resolves with the uploaded file document + +Error + +Throws an error if the upload fails + +### Example [​](#example-18) + +javascript + +``` +const file = new Blob(['Hello, World!'], { type: 'text/plain' }); +const onProgress = (event) => console.log(`Upload progress: ${event.loaded / event.total * 100}%`); + +fileProvider.uploadFile(file, onProgress, 'documents') + .then(fileDoc => console.log('Uploaded file:', fileDoc)) + .catch(error => console.error('Upload failed:', error)); +``` + +## `uploadFileToURL()` [​](#uploadfiletourl) + +Uploads a file to a specific URL. + +### Arguments [​](#arguments-17) + +Name + +Type + +Description + +url + +string + +The URL to upload the file to + +file + +string + +Blob + +body + +any (optional) + +Additional data to be sent with the request + +onProgress + +(progressEvent: ProgressEvent) => void + +Callback function to track upload progress + +tag + +string + +Tag for the file + +### Return and Throw [​](#return-and-throw-9) + +Type + +Description + +`Promise` + +Resolves with the response from the server + +Error + +Throws an error if the upload fails + +### Example [​](#example-19) + +javascript + +``` +const url = 'https://api.example.com/upload'; +const file = new Blob(['Flower data'], { type: 'text/plain' }); +const body = { category: 'flora' }; +const onProgress = (event) => console.log(`Upload progress: ${event.loaded / event.total * 100}%`); + +fileProvider.uploadFileToURL(url, file, body, onProgress, 'botanical') + .then(response => console.log('Upload response:', response)) + .catch(error => console.error('Upload failed:', error)); +``` + +## `removeFile()` [​](#removefile) + +Removes a file from the server. + +### Arguments [​](#arguments-18) + +Name + +Type + +Description + +id + +string + +The ID of the file to be removed + +### Return and Throw [​](#return-and-throw-10) + +Type + +Description + +`Promise` + +Resolves with the response from the server + +Error + +Throws an error if the removal fails + +### Example [​](#example-20) + +javascript + +``` +const fileId = '123456789'; + +fileProvider.removeFile(fileId) + .then(response => console.log('File removed successfully:', response)) + .catch(error => console.error('File removal failed:', error)); +``` + +## `getFileLink()` [​](#getfilelink) + +Generates a URL for accessing a file. + +### Arguments [​](#arguments-19) + +Name + +Type + +Description + +fileDoc + +`{ fileName: string; format: string; tag: string }` + +File document object + +overrideUrl + +string (optional) + +Optional URL to override the default + +rootPath + +string (optional) + +Root path for the file, defaults to "assets" + +### Return and Throw [​](#return-and-throw-11) + +Type + +Description + +string + +The generated URL for the file + +### Example [​](#example-21) + +javascript + +``` +const fileDoc = { + fileName: 'city_map.jpg', + format: 'images', + tag: 'maps' +}; + +const fileUrl = fileProvider.getFileLink(fileDoc); +console.log('File URL:', fileUrl); +``` + +## `getFileDoc()` [​](#getfiledoc) + +Retrieves a file document by its ID and user ID. + +### Arguments [​](#arguments-20) + +Name + +Type + +Description + +id + +string + +The ID of the file + +userId + +string + +The ID of the user who owns the file + +### Return and Throw [​](#return-and-throw-12) + +Type + +Description + +`Promise` + +Resolves with the file document + +Error + +Throws an error if the file document cannot be found + +### Example [​](#example-22) + +javascript + +``` +const fileId = '987654321'; +const userId = 'user123'; + +fileProvider.getFileDoc(fileId, userId) + .then(fileDoc => console.log('File document:', fileDoc)) + .catch(error => console.error('Failed to retrieve file document:', error)); +``` + +## `getFileDocsByTag()` [​](#getfiledocsbytag) + +Retrieves file documents by tag and user ID. + +### Arguments [​](#arguments-21) + +Name + +Type + +Description + +tag + +string + +The tag to search for + +userId + +string + +The ID of the user who owns the files + +### Return and Throw [​](#return-and-throw-13) + +Type + +Description + +`Promise` + +Resolves with an array of file documents + +Error + +Throws an error if the file documents cannot be found + +### Example [​](#example-23) + +javascript + +``` +const tag = 'flowers'; +const userId = 'user456'; + +fileProvider.getFileDocsByTag(tag, userId) + .then(fileDocs => console.log('File documents:', fileDocs)) + .catch(error => console.error('Failed to retrieve file documents:', error)); +``` + +# FunctionProvider Service Documentation [​](#functionprovider-service-documentation) + +The FunctionProvider service allows executing server-side functions from the client. It provides a simple interface to run named functions with arguments and receive the result. + +TIP + +To learn how to define functions on the server, check out the [Server-side Functions documentation](/modular-rest/server-client-ts/modules/functions.html). + +To use the FunctionProvider service, import it as follows: + +javascript + +``` +import { functionProvider } from '@modular-rest/client' +``` + +## `run()` [​](#run) + +Executes a server-side function. + +### Arguments [​](#arguments-22) + +Name + +Type + +Description + +options + +`{ name: string; args: any }` + +Object containing function name and arguments + +### Return and Throw [​](#return-and-throw-14) + +Type + +Description + +`Promise` + +Resolves with the result returned by the server-side function + +Error + +Throws an error if the function execution fails or returns an error + +### Example [​](#example-24) + +javascript + +``` +const options = { + name: 'calculateSum', + args: { a: 10, b: 20 } +}; + +functionProvider.run(options) + .then(result => console.log('Function result:', result)) + .catch(error => console.error('Function execution failed:', error)); +``` + +This would be the HttpClient service documentation \ No newline at end of file diff --git a/.agent/modular-rest/server.md b/.agent/modular-rest/server.md new file mode 100644 index 0000000..e1d8852 --- /dev/null +++ b/.agent/modular-rest/server.md @@ -0,0 +1,2029 @@ +# Key Concepts [​](#key-concepts) + +Modular-rest is designed to minimize the amount of code needed to create a RESTful backend. With just a single call to the `createRest` function, you can have a fully functional RESTful backend up and running. To make it flexible and customizable, modular-rest introduces several key concepts that you should understand to effectively build your own logic on top of it. + +## Configuration [​](#configuration) + +The configuration object passed to the `createRest` function is the foundation of modular-rest. This object contains all the necessary information for modular-rest to set up the server. You can learn more about configuring your server in the [Quick Start](./quick-start.html) section. + +## Modules [​](#modules) + +Modules are the building blocks for implementing your business logic on top of modular-rest. Each module has its own dedicated directory where all relevant files and data structures are organized. This modular approach allows you to add unlimited modules to your project and scale it according to your needs. Learn more about working with modules in the [Modules](./modules/intro.html) section. + +# Install Server App [​](#install-server-app) + +Thank you for choosing `modular-rest` to build your app. You can install the server app in two ways: + +## 1\. Create a new project [​](#_1-create-a-new-project) + +In this way, you can create a new project with `modular-rest` server app. it will create a new folder with your project name and setup the project for you. + +Just use below command: + +npmyarn + +sh + +``` +npm create @modular-rest/server@latest my-project +``` + +sh + +``` +yarn create @modular-rest/server my-project +``` + +And now you can start your project with below commands: + +sh + +``` +cd my-project +npm install +npm start +``` + +## 2\. Add modular-rest server client to your project [​](#_2-add-modular-rest-server-client-to-your-project) + +It assumed that you have initialized a project with npm, then use below command to install modular-rest server client. + +npmyarn + +sh + +``` +npm i @modular-rest/server --save +``` + +sh + +``` +yarn add @modular-rest/server +``` + +Now you can use modular-rest server client in your project. + +ts + +``` +import { createRest } from '@modular-rest/server'; + +const app = createRest({ + port: '80', + mongo: { + mongoBaseAddress: 'mongodb://localhost:27017', + dbPrefix: 'mrest_' + }, + onBeforeInit: (koaApp) => { + // do something before init with the koa app + } +}) +``` + +# Configuration Overview [​](#configuration-overview) + +This guide provides an overview and detailed explanation of the configuration options available for `@modular-rest/server`. + +## Quick Start [​](#quick-start) + +To get started, you need to require `@modular-rest/server` and call `createRest` with your configuration object: + +javascript + +``` +const { createRest } = require("@modular-rest/server"); + +const app = createRest({ + port: "80", + // Additional configuration options... +}); +``` + +### Health Chek [​](#health-chek) + +You may need to check the server health, just request to below endpoint: + +bash + +``` +GET:[base_url]/verify/ready +# {"status":"success"} +``` + +## Configuration Summary Table [​](#configuration-summary-table) + +## Server and Middleware Configuration [​](#server-and-middleware-configuration) + +* **`cors`**: Defines Cross-Origin Resource Sharing (CORS) options to control how resources on your server can be requested from another domain. +* **`port`**: Specifies the port number on which the server will listen for requests. +* **`dontListen`**: If set to `true`, the server setup is done but it won't start listening. This is useful for cases where you want to perform tests or when integrating with another server. + +## Modules and Upload Directory [​](#modules-and-upload-directory) + +* **`modulesPath`**: The directory path where your module files (`router.js`, `db.js`) are located. +* **`uploadDirectory`**: The root directory where uploaded files are stored, you can mount this directory to a CDN or a cloud storage service. + +## Static Files [​](#static-files) + +* **`staticPath`**: Provides detailed options for serving static files from your server, such as the root directory, caching options, and whether to serve gzipped content. + +## Initialization Hooks [​](#initialization-hooks) + +* **`onBeforeInit`**: A function that is called before the Koa application is initialized. This allows you to add or configure middleware and routes. +* **`onAfterInit`**: Similar to `onBeforeInit`, but this function is called after the application has been initialized. + +## Database Configuration [​](#database-configuration) + +* **`mongo`**: Contains MongoDB configuration details like the database address, prefix for database names, and more. + +## Security and Authentication [​](#security-and-authentication) + +* **`keypair`**: RSA keys used for authentication purposes. +* **`adminUser`**: Credentials for an admin user, typically used for initial setup or administrative tasks. + +## Customization and Extensions [​](#customization-and-extensions) + +* **`verificationCodeGeneratorMethod`**, +* **`collectionDefinitions`**, +* **`permissionGroups`**, +* **`authTriggers`** + +These properties allow for extending the functionality of `@modular-rest/server` by adding custom verification code generation logic, defining additional database collections, setting up permission groups, and specifying triggers for database operations. + +## Example Configuration [​](#example-configuration) + +Here's an example demonstrating how to configure some of these properties: + +javascript + +``` +const { createRest } = require("@modular-rest/server"); + +const app = createRest({ + port: 3000, + modulesPath: "./modules", + staticPath: { + rootDir: "./public", + notFoundFile: "404.html", + log: true, + }, + mongo: { + mongoBaseAddress: "mongodb://localhost:27017", + dbPrefix: "myApp_", + }, + onBeforeInit: (koaApp) => { + // Custom middleware + koaApp.use(customMiddleware()); + }, + adminUser: { + email: "admin@example.com", + password: "securepassword", + }, +}); +``` + +This guide should give you a clear understanding of how to configure `@modular-rest/server` for your project, along with some examples to get you started. + +# Modules [​](#modules-1) + +Modules are the building blocks your logics on top of modular-rest. each module has a specific directory and all relevant files and data structure are placed in that directory. hence you can add unlimited modules to your project and scale it as much as you want. + +## Structure [​](#structure) + +All modules should be placed in the `modules` directory that you define and [introduce in the configuration object](/modular-rest/server-client-ts/quick-start.html#modules-path). Each module should have its own directory with the following structure, and all files should be placed in that directory but none of theme are required. + +* `db.js`: to define the database models and their relationships. +* `functions`: to define functions that you want to be invoked by client library. +* `router.js`: to define the routes and their handlers. + +You can add more files and directories to your module based on your needs, but the above files are be recognized by modular-rest and will be imported to the server logic automatically on startup. + +## Use Cases [​](#use-cases) + +Let's see some examples to understand the concept of modules better. + +#### **Blog Website**: [​](#blog-website) + +Assume you want to create a blog website where people come and write their own blog posts, read other people's posts, and comment on them. + +To modularize this project, you can create three modules: + +* `users`: to manage users and their profiles. +* `posts`: to manage blog posts and their categories, tags, and content. +* `comments`: to manage comments on blog posts and their replies. + +#### **E-commerce Website**: [​](#e-commerce-website) + +Assume you want to create an e-commerce website where people come and buy products, add them to their cart, and pay for them. + +To modularize this project, you can create three modules: + +* `users`: to manage users and their profiles. +* `products`: to manage products and their categories, prices, and descriptions. +* `orders`: to manage orders and their statuses, like pending, shipped, and delivered. + +#### **Video Editing Platform**: [​](#video-editing-platform) + +Assume you want to create a video editing platform where people come and upload their videos, edit them, and share them with others. + +To modularize this project, you can create three modules: + +* `users`: to manage users and their profiles. +* `media-library`: to manage media files like videos, images, and audio files. +* `editor-engine`: to manage the video editing process, like trimming, cropping, and adding effects to the videos. + +# Concept [​](#concept) + +In Modular-rest you have mongodb database support out of the box. you just need to define your data models in `db.[js|ts]` files. they have to be located in modules directory. for example if you have a module named `user` you have to create a file named `db.[js|ts]` in `modules/user` directory. + +## How to Define a Collection [​](#how-to-define-a-collection) + +> **defineCollection**(`options`): [`CollectionDefinition`](/modular-rest/server-client-ts/generative/classes/CollectionDefinition.html) & `object` + +To have define any collection in your database you haveto use below method in your `db.[js|ts]` file and export an array of CollectionDefinition instances. + +## Example [​](#example) + +typescript + +``` +import { defineCollection } from '@modular-rest/server'; + +export default [ + defineCollection({ + database: 'users', + collection: 'info', + // schema: Schema, + // permissions: Permission[] + // trigger: DatabaseTrigger[] + }) +] + +// Access the model directly: +const userCollection = defineCollection({...}); +const UserModel = userCollection.model; +const users = await UserModel.find(); +``` + +## Parameters [​](#parameters) + +Parameter + +Type + +Description + +`options` + +{ `collection`: `string`; `database`: `string`; `mongoOption`: [`MongoOption`](/modular-rest/server-client-ts/generative/interfaces/_internal_.MongoOption.html); `permissions`: [`Permission`](/modular-rest/server-client-ts/generative/classes/Permission.html)\[\]; `schema`: `Schema`<`any`\>; `triggers`: [`DatabaseTrigger`](/modular-rest/server-client-ts/generative/classes/DatabaseTrigger.html)\[\]; } + +The options for the collection + +`options.collection` + +`string` + +The name of the collection to be configured + +`options.database` + +`string` + +The name of the database where the collection resides + +`options.mongoOption`? + +[`MongoOption`](/modular-rest/server-client-ts/generative/interfaces/_internal_.MongoOption.html) + +Optional MongoDB connection options. If not provided, will use config.mongo if available. This is used to pre-create the model before server startup. + +`options.permissions` + +[`Permission`](/modular-rest/server-client-ts/generative/classes/Permission.html)\[\] + +List of permissions controlling access to the collection + +`options.schema` + +`Schema`<`any`\> + +Mongoose schema definition for the collection **See** [https://mongoosejs.com/docs/5.x/docs/guide.html](https://mongoosejs.com/docs/5.x/docs/guide.html) + +`options.triggers`? + +[`DatabaseTrigger`](/modular-rest/server-client-ts/generative/classes/DatabaseTrigger.html)\[\] + +Optional database triggers for custom operations + +## Returns [​](#returns) + +[`CollectionDefinition`](/modular-rest/server-client-ts/generative/classes/CollectionDefinition.html) & `object` + +A CollectionDefinition instance with a model property that returns the mongoose model + +## Schema [​](#schema) + +You can define data stracture for your collection by passing a [mongoose schema](https://mongoosejs.com/docs/5.x/docs/guide.html) to `schema` option. + +typescript + +``` +import { Schema } from '@modular-rest/server'; + +const userSchema = new Schema({ + name: String, + age: Number +}); + +defineCollection({ + database: 'users', + collection: 'info', + schema: userSchema, + permissions: Permission[] + trigger: DatabaseTrigger[] +}) +``` + +### File Schema [​](#file-schema) + +Modular-rest has a predefined file schema that you it is necessary to use this schema if your collection needs to store files. + +**Note**: Modular-rest does not store the file directly in the database. Instead, it places the file in the [upload directory](/modular-rest/server-client-ts/quick-start.html#modules-and-upload-directory) specified in the [config object](/modular-rest/server-client-ts/quick-start.html#configuration-summary-table). The file information is then recorded in the database. + +typescript + +``` +import { schemas } from '@modular-rest/server'; + +const userSchema = new Schema({ + name: String, + age: Number, + + // Added this file to the parent schema + avatar: schemas.file +}); +``` + +## Permissions [​](#permissions) + +The permission system in this framework provides a robust way to control access to your application's resources. It works by matching permission types that users have against those required by different parts of the system. [Read more](/modular-rest/server-client-ts/advanced/permission-and-user-access.html) + +## Triggers [​](#triggers) + +In a complex application, you may need to perform additional actions after a database operation. This is where triggers come in. + +### Database Triggers [​](#database-triggers) + +Database triggers allow you to define callbacks for specific database operations on a collection. + +### CMS Triggers [​](#cms-triggers) + +CMS triggers allow you to define callbacks for operations performed via the CMS. Defines a callback to be executed on specific CMS operations CmsTrigger + +## Example [​](#example-1) + +typescript + +``` +const trigger = new CmsTrigger('insert-one', (context) => { + console.log('New CMS document inserted:', context.queryResult); + // Perform additional actions after CMS document insertion. +}); + +// Use the trigger in RestOptions +const { app } = await createRest({ + authTriggers: [trigger], + // ... other options +}); +``` + +## Extends [​](#extends) + +* [`DatabaseTrigger`](/modular-rest/server-client-ts/generative/classes/DatabaseTrigger.html) + +## Constructors [​](#constructors) + +### Constructor [​](#constructor) + +> **new CmsTrigger**(`operation`, `callback`?): `CmsTrigger` + +Creates a new CmsTrigger instance + +#### Example [​](#example-2) + +typescript + +``` +// Log all CMS updates +const updateTrigger = new CmsTrigger('update-one', (context) => { + console.log('CMS document updated:', context.queryResult); +}); + +// Track CMS document removals +const removeTrigger = new CmsTrigger('remove-one', (context) => { + console.log('CMS document removed:', context.queryResult); +}); +``` + +#### Parameters [​](#parameters-1) + +Parameter + +Type + +Description + +`operation` + +[`CmsOperation`](/modular-rest/server-client-ts/generative/types/_internal_.CmsOperation.html) + +The CMS operation to trigger on + +`callback`? + +(`context`) => `void` + +The callback function to execute + +#### Returns [​](#returns-1) + +`CmsTrigger` + +#### Overrides [​](#overrides) + +`DatabaseTrigger.constructor` + +## Properties [​](#properties) + +Property + +Type + +Description + +Inherited from + +`callback` + +(`context`) => `void` + +The callback function to be executed + +[`DatabaseTrigger`](/modular-rest/server-client-ts/generative/classes/DatabaseTrigger.html).[`callback`](/modular-rest/server-client-ts/generative/classes/DatabaseTrigger.html#callback) + +`operation` + +[`DatabaseOperation`](/modular-rest/server-client-ts/generative/types/_internal_.DatabaseOperation.html) + +The CMS operation that triggers the callback + +[`DatabaseTrigger`](/modular-rest/server-client-ts/generative/classes/DatabaseTrigger.html).[`operation`](/modular-rest/server-client-ts/generative/classes/DatabaseTrigger.html#operation) + +## Methods [​](#methods) + +### applyToSchema() [​](#applytoschema) + +> **applyToSchema**(`schema`): `void` + +Applies the trigger to a Mongoose schema + +#### Parameters [​](#parameters-2) + +Parameter + +Type + +Description + +`schema` + +`any` + +The mongoose schema to apply the trigger to + +#### Returns [​](#returns-2) + +`void` + +#### Inherited from [​](#inherited-from) + +[`DatabaseTrigger`](/modular-rest/server-client-ts/generative/classes/DatabaseTrigger.html).[`applyToSchema`](/modular-rest/server-client-ts/generative/classes/DatabaseTrigger.html#applytoschema) + +## Linking Collections [​](#linking-collections) + +You can link any collection from same database into an schema to perform `populate queries`, but let me tell you what it is simply: + +`Populate query` is a query that you can use to get data from linked collections. for example if you have a collection named `user` and you have a collection named `post` that each post has an author. you can link `user` collection into `post` collection and then you can use populate query to get author of each post, it you the user data in author field of each post. + +More info on [populate queries](https://mongoosejs.com/docs/5.x/docs/populate.html). + +typescript + +``` +import { Schema } from '@modular-rest/server'; + +const userSchema = new Schema({ + name: String, + age: Number +}); + +const postSchema = new Schema({ + title: String, + content: String, + author: { + type: Schema.Types.ObjectId, + ref: 'user' + } +}); +``` + +## Cross Database Populate [​](#cross-database-populate) + +While Mongoose's standard `populate()` works within the same database, Modular-rest enables cross-database population by leveraging the `modelRegistry`. This is useful when you have collections in different MongoDB databases that need to reference each other. + +### Schema Level Reference [​](#schema-level-reference) + +The most efficient way to handle cross-database population is to provide the model directly in the schema definition. + +typescript + +``` +import { modelRegistry, Schema } from '@modular-rest/server'; + +// 1. Get the model from the other database +const userModel = modelRegistry.getModel('auth_db', 'users'); + +// 2. Define the schema using the model as a reference +const postSchema = new Schema({ + title: String, + author: { + type: Schema.Types.ObjectId, + ref: userModel + } +}); +``` + +### Query Level Reference [​](#query-level-reference) + +You can also provide the model at the query level if it wasn't defined in the schema. + +typescript + +``` +// Use the model in your query +const posts = await postModel.find().populate({ + path: 'author', + model: userModel +}); +``` + +For more details on accessing models, see the [Model Registry documentation](/modular-rest/server-client-ts/utility/model-registry.html). + +## Full Example [​](#full-example) + +Let's see a full example of `db.ts` file: + +typescript + +``` +import { Schema, schemas, CollectionDefinition, Permission, DatabaseTrigger } from '@modular-rest/server'; + +const userSchema = new Schema({ + name: String, + age: Number, + + // Added this file to the parent schema + avatar: schemas.file +}); + +const postSchema = new Schema({ + title: String, + content: String, + author: { + type: Schema.Types.ObjectId, + ref: 'user' + } +}); + +const userPermissions = [ + new Permission({ + new Permission({ + type: 'god_access', + read: true, + write: true, + }), + new Permission({ + type: 'user_access', + read: true, + write: true, + onlyOwnData: true, + }), + new Permission({ + type: 'anonymous_access', + read: true, + }), + }) +]; + +const userTriggers = [ + new DatabaseTrigger('insert-one', + (data) => { + // send email to user + } + }) +]; + +module.exports = [ + new CollectionDefinition({ + db: 'user', + name: 'info', + schema: userSchema, + permissions: userPermissions, + trigger: userTriggers + }), + new CollectionDefinition({ + db: 'user', + name: 'post', + schema: postSchema, + permissions: userPermissions, + trigger: userTriggers + }) +] +``` + +# Concept [​](#concept-1) + +In the Modular-rest, functions are a powerful feature that allows developers to define and manage custom logic seamlessly within their APIs. Functions serve as a mechanism to remove traditional API development, eliminating the need to write routers directly. + +By defining a function, a router will be generated for it automatically, allowing the client library to focus solely on the API call. This feature supports more dynamic and flexible application designs, ensuring that specific actions can be encapsulated and reused while enforcing permissions and maintaining security. + +TIP + +For information on how to call these functions from the client, see the [FunctionProvider client documentation](/modular-rest/js-client/function-provider.html). + +## Define a Function [​](#define-a-function) + +> **defineFunction**(`options`): `object` + +To define a function you need to create a `functions.[js|ts]` in each module of your app and return am array called `functions`, and then define all your functions with calling the `defineFunction` method. + +The `defineFunction` method serves as a core utility for creating custom functions dynamically. This method allows you to specify various parameters, including the name of the function, the permissions required for access, and the corresponding logic that should be executed when the function is invoked. + +## Example [​](#example-3) + +Here is an example illustrating how to use the `defineFunction` method effectively: + +typescript + +``` +// /modules/myModule/functions.ts + +import { defineFunction } from "@modular-rest/server"; + +const getServerTime = defineFunction({ + name: "getServerTime", + permissionTypes: ["anonymous_access"], + callback: (params) => { + // return your data only + return ` + Welcome, ${params.username}! + The current server time is ${new Date().toLocaleString()}. + `; + + // error handling, + // client gets error code 400, and the message + // throw new Error('An error occurred'); + }, +}); + +module.exports.functions = [getServerTime]; +``` + +In this example, we define a function named `getServerTime` that requires the `user` permission type to access. When the function is called, it will return a message containing the current server time and the username of the user who invoked the function. + +* * * + +By utilizing the `defineFunction` method, developers are empowered to create custom functionality effortlessly within the Modular REST framework, enhancing both the versatility and security of their applications. + +## Parameters [​](#parameters-3) + +Parameter + +Type + +Description + +`options` + +{ `callback`: (`args`) => `any`; `name`: `string`; `permissionTypes`: `string`\[\]; } + +The function definition options. See [DefinedFunction](/modular-rest/server-client-ts/generative/interfaces/RestOptions.html#functions) for detailed parameter descriptions. + +`options.callback` + +(`args`) => `any` + +The actual function implementation + +`options.name` + +`string` + +Unique name of the function + +`options.permissionTypes` + +`string`\[\] + +List of permission types required to run the function + +## Returns [​](#returns-3) + +`object` + +The defined function object which system will use to generate a router for the function, generall the client library will use the router to call the function. + +Name + +Type + +Description + +`callback()` + +(`args`) => `any` + +The actual function implementation + +`name` + +`string` + +Unique name of the function + +`permissionTypes` + +`string`\[\] + +List of permission types required to run the function + +## Throws [​](#throws) + +If function name already exists, permission types are missing, or callback is invalid + +# Custom Route [​](#custom-route) + +In modular rest you have still the traditional way to create a route, by creating a `router.js` file in your module directory. This file should export below properties and will be taken automatically by the modular rest on the startup. + +* `name`: the name of the route, mostly same as the module name. +* `main`: the main router object. + +Note: route system is based on [koa-router](https://github.com/koajs/router/blob/master/API.md) package which is a plugin for express.js framework. + +## Example [​](#example-4) + +Assume you have a module named `flowers` and you want to create a list/id route for it. You can create a `router.js` file in the `modules/flowers` directory with the following content: + +js + +``` +const Router = require('koa-router'); +const name = 'flowers'; + +const flowerRouter = new Router(); + +flowerRouter.get('/list', (ctx) => { + ctx.body = 'This is a list of flowers: Rose, Lily, Tulip'; +}); + +flowerRouter.post('/:id', (ctx) => { + const id = ctx.params.id; + ctx.body = `Request Body: ${JSON.stringify(ctx.request.body)} and id: ${id}`; + +}) + +module.exports.name = name; +module.exports.main = flowerRouter; +``` + +Now you can access your apis by sending a request to the following urls: + +* `GET http://localhost:80/flowers/list` +* `POST http://localhost:80/flowers/1` + +# Database Utilities [​](#database-utilities) + +Contains utilities related to database operations. + +## Get Collection [​](#get-collection) + +> **getCollection**<`T`\>(`db`, `collection`): `Model`<`T`\> + +**`Function`** + +Gets a Mongoose model for a specific collection getCollection + +## Example [​](#example-5) + +typescript + +``` +const userModel = getCollection('myapp', 'users'); +const users = await userModel.find(); +``` + +## Type Parameters [​](#type-parameters) + +Type Parameter + +`T` + +## Parameters [​](#parameters-4) + +Parameter + +Type + +Description + +`db` + +`string` + +Database name + +`collection` + +`string` + +Collection name + +## Returns [​](#returns-4) + +`Model`<`T`\> + +Mongoose model for the collection + +## Throws [​](#throws-1) + +If the collection doesn't exist + +# File Utilities [​](#file-utilities) + +File service class for handling file operations FileService + +## Description [​](#description) + +This class provides methods for managing file uploads, retrieval, and deletion. It handles physical file storage and database metadata management. + +## Methods [​](#methods-1) + +### getFileLink() [​](#getfilelink) + +> **getFileLink**(`fileId`): `Promise`<`string`\> + +Gets the public URL for a file + +#### Example [​](#example-6) + +typescript + +``` +import { fileService } from '@modular-rest/server'; + +const url = await fileService.getFileLink('file123'); +// Returns: '/assets/jpeg/profile/1234567890.jpeg' +``` + +#### Parameters [​](#parameters-5) + +Parameter + +Type + +Description + +`fileId` + +`string` + +ID of the file + +#### Returns [​](#returns-5) + +`Promise`<`string`\> + +The public URL + +* * * + +### getFilePath() [​](#getfilepath) + +> **getFilePath**(`fileId`): `Promise`<`string`\> + +Gets the physical path for a file + +#### Example [​](#example-7) + +typescript + +``` +import { fileService } from '@modular-rest/server'; + +const path = await fileService.getFilePath('file123'); +// Returns: '/uploads/jpeg/profile/1234567890.jpeg' +``` + +#### Parameters [​](#parameters-6) + +Parameter + +Type + +Description + +`fileId` + +`string` + +ID of the file + +#### Returns [​](#returns-6) + +`Promise`<`string`\> + +The physical path + +* * * + +### removeFile() [​](#removefile) + +> **removeFile**(`fileId`): `Promise`<`boolean`\> + +Deletes a file from disc and database + +#### Example [​](#example-8) + +typescript + +``` +import { fileService } from '@modular-rest/server'; + +await fileService.removeFile('file123'); +``` + +#### Parameters [​](#parameters-7) + +Parameter + +Type + +Description + +`fileId` + +`string` + +ID of the file to delete + +#### Returns [​](#returns-7) + +`Promise`<`boolean`\> + +True if deletion was successful + +#### Throws [​](#throws-2) + +If file is not found or deletion fails + +* * * + +### storeFile() [​](#storefile) + +> **storeFile**(`options`): `Promise`<[`IFile`](/modular-rest/server-client-ts/generative/interfaces/_internal_.IFile.html)\> + +Stores a file on disc and creates metadata in database + +#### Example [​](#example-9) + +typescript + +``` +import { fileService } from '@modular-rest/server'; + +const file = await fileService.storeFile({ + file: { + path: '/tmp/upload.jpg', + type: 'image/jpeg', + name: 'profile.jpg', + size: 1024 + }, + ownerId: 'user123', + tag: 'profile', + removeFileAfterStore: true +}); +``` + +#### Parameters [​](#parameters-8) + +Parameter + +Type + +Description + +`options` + +[`StoreFileOptions`](/modular-rest/server-client-ts/generative/interfaces/_internal_.StoreFileOptions.html) + +File storage options + +#### Returns [​](#returns-8) + +`Promise`<[`IFile`](/modular-rest/server-client-ts/generative/interfaces/_internal_.IFile.html)\> + +The created file document + +#### Throws [​](#throws-3) + +If upload directory is not set or storage fails + +# Router Utilities [​](#router-utilities) + +When you develop custom APIs in `router.[js|ts]` files, you might need to use some utilities to standardize your responses, handle errors, and manage pagination. The following utilities are available to help you with these tasks. + +## Reply [​](#reply) + +> **create**(`status`, `detail`): [`ResponseObject`](/modular-rest/server-client-ts/generative/interfaces/reply.ResponseObject.html) + +Creates a response object with the given status and detail. + +## Example [​](#example-10) + +typescript + +``` +import { reply } from '@modular-rest/server'; + +// inside the router +const response = reply.create("s", { message: "Hello, world!" }); +ctx.body = response; +ctx.status = 200; +``` + +## Parameters [​](#parameters-9) + +Parameter + +Type + +Description + +`status` + +[`ResponseStatus`](/modular-rest/server-client-ts/generative/types/reply.ResponseStatus.html) + +The status of the response. Can be "s" for success, "f" for fail, or "e" for error. + +`detail` + +`Record`<`string`, `any`\> + +The detail of the response. Can contain any additional information about the response. + +## Returns [​](#returns-9) + +[`ResponseObject`](/modular-rest/server-client-ts/generative/interfaces/reply.ResponseObject.html) + +The response object with the given status and detail. + +## Paginator Maker [​](#paginator-maker) + +> **create**(`count`, `perPage`, `page`): [`PaginationResult`](/modular-rest/server-client-ts/generative/interfaces/paginator.PaginationResult.html) + +Creates a pagination object based on the given parameters. + +## Example [​](#example-11) + +typescript + +``` +import { paginator } from '@modular-rest/server'; + +const pagination = paginator.create(100, 10, 1); +// json response will be like this +// { +// pages: 10, +// page: 1, +// from: 0, +// to: 10, +// } +``` + +## Parameters [​](#parameters-10) + +Parameter + +Type + +Description + +`count` + +`number` + +The total number of items to paginate. + +`perPage` + +`number` + +The number of items to display per page. + +`page` + +`number` + +The current page number. + +## Returns [​](#returns-10) + +[`PaginationResult`](/modular-rest/server-client-ts/generative/interfaces/paginator.PaginationResult.html) + +An object containing pagination information. + +## Auth Middleware [​](#auth-middleware) + +> **auth**(`ctx`, `next`): `Promise`<`void`\> + +Authentication middleware that secures routes by validating user tokens and managing access control. + +This middleware performs several key functions: + +1. Validates that the incoming request contains an authorization token in the header +2. Verifies the token is valid by checking against the user management service +3. Retrieves the associated user object if the token is valid +4. Attaches the authenticated [User](/modular-rest/server-client-ts/generative/classes/_internal_.User.html) object on ctx.state.user for use in subsequent middleware/routes +5. Throws appropriate HTTP errors (401, 412) if authentication fails + +The middleware integrates with the permission system to enable role-based access control. The attached user object provides methods like hasPermission() to check specific permissions. + +Common usage patterns: + +* Protecting sensitive API endpoints +* Implementing role-based access control +* Getting the current authenticated user +* Validating user permissions before allowing actions + +## Example [​](#example-12) + +typescript + +``` +// Inside the router.ts file +import { auth } from '@modular-rest/server'; +import { Router } from 'koa-router'; + +const name = 'flowers'; + +const flowerRouter = new Router(); + +flowerRouter.get('/list', auth, (ctx) => { + // Get the authenticated user + const user = ctx.state.user; + + // Then you can check the user's role and permission + if(user.hasPermission('get_flower')) { + ctx.body = 'This is a list of flowers: Rose, Lily, Tulip'; + } else { + ctx.status = 403; + ctx.body = 'You are not authorized to access this resource'; + } +}); + +module.exports.name = name; +module.exports.main = flowerRouter; +``` + +## Parameters [​](#parameters-11) + +Parameter + +Type + +Description + +`ctx` + +`Context` + +Koa Context object containing request/response data + +`next` + +`Next` + +Function to invoke next middleware + +## Returns [​](#returns-11) + +`Promise`<`void`\> + +## Throws [​](#throws-4) + +401 - If no authorization header is present + +## Throws [​](#throws-5) + +412 - If token validation fails + +# UserManager Service [​](#usermanager-service) + +User manager class for handling user operations + +This service provides functionality for managing users, including: + +* User registration and authentication +* Password management +* Token generation and verification +* Temporary ID handling for password reset and verification + +## Methods [​](#methods-2) + +### changePassword() [​](#changepassword) + +> **changePassword**(`query`, `newPass`): `Promise`<`void`\> + +Changes a user's password + +#### Example [​](#example-13) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +try { + await userManager.changePassword( + { email: 'user@example.com' }, + 'newpassword123' + ); + console.log('Password changed successfully'); +} catch (error) { + console.error('Failed to change password:', error); +} +``` + +#### Parameters [​](#parameters-12) + +Parameter + +Type + +Description + +`query` + +`Record`<`string`, `any`\> + +Query to find the user + +`newPass` + +`string` + +The new password + +#### Returns [​](#returns-12) + +`Promise`<`void`\> + +Promise resolving when password is changed + +#### Throws [​](#throws-6) + +If user is not found or password change fails + +* * * + +### changePasswordForTemporaryID() [​](#changepasswordfortemporaryid) + +> **changePasswordForTemporaryID**(`id`, `password`, `code`): `Promise`<`string`\> + +Changes password for a temporary ID + +#### Example [​](#example-14) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +try { + const token = await userManager.changePasswordForTemporaryID( + 'user@example.com', + 'newpassword123', + '123456' + ); + console.log('Password changed successfully'); +} catch (error) { + console.error('Failed to change password:', error); +} +``` + +#### Parameters [​](#parameters-13) + +Parameter + +Type + +Description + +`id` + +`string` + +The temporary ID + +`password` + +`string` + +The new password + +`code` + +`string` + +The verification code + +#### Returns [​](#returns-13) + +`Promise`<`string`\> + +Promise resolving to the JWT token + +#### Throws [​](#throws-7) + +If verification code is invalid or user is not found + +* * * + +### generateVerificationCode() [​](#generateverificationcode) + +> **generateVerificationCode**(`id`, `idType`): `string` + +Generates a verification code for a user + +#### Example [​](#example-15) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +const code = userManager.generateVerificationCode('user@example.com', 'email'); +// Returns: '123' (default) or custom generated code +``` + +#### Parameters [​](#parameters-14) + +Parameter + +Type + +Description + +`id` + +`string` + +User ID or identifier + +`idType` + +`string` + +Type of ID (email, phone) + +#### Returns [​](#returns-14) + +`string` + +Verification code + +* * * + +### getUserById() [​](#getuserbyid) + +> **getUserById**(`id`): `Promise`<[`User`](/modular-rest/server-client-ts/generative/classes/_internal_.User.html)\> + +Gets a user by their ID + +#### Example [​](#example-16) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +try { + const user = await userManager.getUserById('user123'); + console.log('User details:', user); +} catch (error) { + console.error('Failed to get user:', error); +} +``` + +#### Parameters [​](#parameters-15) + +Parameter + +Type + +Description + +`id` + +`string` + +The ID of the user + +#### Returns [​](#returns-15) + +`Promise`<[`User`](/modular-rest/server-client-ts/generative/classes/_internal_.User.html)\> + +Promise resolving to the user + +#### Throws [​](#throws-8) + +If user model is not found or user is not found + +* * * + +### getUserByIdentity() [​](#getuserbyidentity) + +> **getUserByIdentity**(`id`, `idType`): `Promise`<[`User`](/modular-rest/server-client-ts/generative/classes/_internal_.User.html)\> + +Gets a user by their identity (email or phone) + +#### Example [​](#example-17) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +// Get user by email +const user = await userManager.getUserByIdentity('user@example.com', 'email'); + +// Get user by phone +const user = await userManager.getUserByIdentity('+1234567890', 'phone'); +``` + +#### Parameters [​](#parameters-16) + +Parameter + +Type + +Description + +`id` + +`string` + +The identity of the user + +`idType` + +`string` + +The type of the identity (phone or email) + +#### Returns [​](#returns-16) + +`Promise`<[`User`](/modular-rest/server-client-ts/generative/classes/_internal_.User.html)\> + +Promise resolving to the user + +#### Throws [​](#throws-9) + +If user model is not found or user is not found + +* * * + +### getUserByToken() [​](#getuserbytoken) + +> **getUserByToken**(`token`): `Promise`<[`User`](/modular-rest/server-client-ts/generative/classes/_internal_.User.html)\> + +Gets a user by their JWT token + +#### Example [​](#example-18) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +try { + const user = await userManager.getUserByToken('jwt.token.here'); + console.log('Authenticated user:', user); +} catch (error) { + console.error('Invalid token:', error); +} +``` + +#### Parameters [​](#parameters-17) + +Parameter + +Type + +Description + +`token` + +`string` + +The JWT token of the user + +#### Returns [​](#returns-17) + +`Promise`<[`User`](/modular-rest/server-client-ts/generative/classes/_internal_.User.html)\> + +Promise resolving to the user + +#### Throws [​](#throws-10) + +If token is invalid or user is not found + +* * * + +### isCodeValid() [​](#iscodevalid) + +> **isCodeValid**(`id`, `code`): `boolean` + +Checks if a verification code is valid + +#### Example [​](#example-19) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +const isValid = userManager.isCodeValid('user123', '123'); +if (isValid) { + // Proceed with verification +} +``` + +#### Parameters [​](#parameters-18) + +Parameter + +Type + +Description + +`id` + +`string` + +The ID of the user + +`code` + +`string` + +The verification code + +#### Returns [​](#returns-18) + +`boolean` + +Whether the verification code is valid + +* * * + +### issueTokenForUser() [​](#issuetokenforuser) + +> **issueTokenForUser**(`email`): `Promise`<`string`\> + +Issues a JWT token for a user by email + +#### Example [​](#example-20) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +try { + const token = await userManager.issueTokenForUser('user@example.com'); + console.log('Issued token:', token); +} catch (error) { + console.error('Failed to issue token:', error); +} +``` + +#### Parameters [​](#parameters-19) + +Parameter + +Type + +Description + +`email` + +`string` + +The email of the user + +#### Returns [​](#returns-19) + +`Promise`<`string`\> + +Promise resolving to the JWT token + +#### Throws [​](#throws-11) + +If user is not found + +* * * + +### loginAnonymous() [​](#loginanonymous) + +> **loginAnonymous**(): `Promise`<`string`\> + +Logs in an anonymous user and returns their JWT token + +#### Example [​](#example-21) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +const token = await userManager.loginAnonymous(); +console.log('Anonymous token:', token); +``` + +#### Returns [​](#returns-20) + +`Promise`<`string`\> + +Promise resolving to the JWT token + +* * * + +### loginUser() [​](#loginuser) + +> **loginUser**(`id`?, `idType`?, `password`?): `Promise`<`string`\> + +Logs in a user and returns their JWT token + +#### Example [​](#example-22) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +try { + // Login with email + const token = await userManager.loginUser('user@example.com', 'email', 'password123'); + + // Login with phone + const token = await userManager.loginUser('+1234567890', 'phone', 'password123'); +} catch (error) { + console.error('Login failed:', error); +} +``` + +#### Parameters [​](#parameters-20) + +Parameter + +Type + +Default value + +Description + +`id`? + +`string` + +`''` + +The ID of the user (email or phone) + +`idType`? + +`string` + +`''` + +The type of the ID (phone or email) + +`password`? + +`string` + +`''` + +The password of the user + +#### Returns [​](#returns-21) + +`Promise`<`string`\> + +Promise resolving to the JWT token + +#### Throws [​](#throws-12) + +If user is not found or credentials are invalid + +* * * + +### registerTemporaryID() [​](#registertemporaryid) + +> **registerTemporaryID**(`id`, `type`, `code`): `string` + +Registers a temporary ID for verification or password reset + +#### Example [​](#example-23) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +const tempId = userManager.registerTemporaryID('user@example.com', 'password_reset', '123456'); +``` + +#### Parameters [​](#parameters-21) + +Parameter + +Type + +Description + +`id` + +`string` + +The ID to register + +`type` + +`string` + +The type of temporary ID + +`code` + +`string` + +The verification code + +#### Returns [​](#returns-22) + +`string` + +The registered ID + +* * * + +### registerUser() [​](#registeruser) + +> **registerUser**(`detail`): `Promise`<`string`\> + +Registers a new user + +#### Example [​](#example-24) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +try { + const token = await userManager.registerUser({ + email: 'user@example.com', + password: 'secure123', + permissionGroup: 'user', + phone: '+1234567890' + }); + console.log('User registered successfully'); +} catch (error) { + console.error('Registration failed:', error); +} +``` + +#### Parameters [​](#parameters-22) + +Parameter + +Type + +Description + +`detail` + +[`UserRegistrationDetail`](/modular-rest/server-client-ts/generative/interfaces/_internal_.UserRegistrationDetail.html) + +User registration details + +#### Returns [​](#returns-23) + +`Promise`<`string`\> + +Promise resolving to the JWT token + +#### Throws [​](#throws-13) + +If user model is not found or registration fails + +* * * + +### setCustomVerificationCodeGeneratorMethod() [​](#setcustomverificationcodegeneratormethod) + +> **setCustomVerificationCodeGeneratorMethod**(`generatorMethod`): `void` + +Sets a custom method for generating verification codes + +#### Example [​](#example-25) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +userManager.setCustomVerificationCodeGeneratorMethod((id, type) => { + return Math.random().toString(36).substring(2, 8).toUpperCase(); +}); +``` + +#### Parameters [​](#parameters-23) + +Parameter + +Type + +Description + +`generatorMethod` + +(`id`, `idType`) => `string` + +Function that generates verification codes + +#### Returns [​](#returns-24) + +`void` + +* * * + +### submitPasswordForTemporaryID() [​](#submitpasswordfortemporaryid) + +> **submitPasswordForTemporaryID**(`id`, `password`, `code`): `Promise`<`string`\> + +Submits a password for a temporary ID + +#### Example [​](#example-26) + +typescript + +``` +import { userManager } from '@modular-rest/server'; + +try { + const token = await userManager.submitPasswordForTemporaryID( + 'user@example.com', + 'newpassword123', + '123456' + ); + console.log('Password set successfully'); +} catch (error) { + console.error('Failed to set password:', error); +} +``` + +#### Parameters [​](#parameters-24) + +Parameter + +Type + +Description + +`id` + +`string` + +The temporary ID + +`password` + +`string` + +The new password + +`code` + +`string` + +The verification code + +#### Returns [​](#returns-25) + +`Promise`<`string`\> + +Promise resolving to the JWT token + +#### Throws [​](#throws-14) + +If verification code is invalid or user is not found + +# Concept [​](#concept-2) + +The permission system in this framework provides a robust way to control access to your application's resources. It works by matching permission types that users have against those required by different parts of the system. + +### How It Works? [​](#how-it-works) + +At its core, the permission system uses access types - special flags like `user_access`, `advanced_settings`, or custom types you define. These access types are used in two key places: + +1. **Permission**: When defining collections or functions, you provide a list of Permission instances that specify which access types are required. Each Permission instance defines what operations (read/write) are allowed for a specific access type. For example, one Permission might allow read access for `user_access`, while another Permission enables write access for `advanced_settings`. + +2. **Permission Group**: Each user is assigned certain access types through their permission group. When they try to access a resource, the system checks if they have the required permission types. + + +The system only allows an operation when there's a match between the permission types required by the resource and those assigned to the user making the request. + +## Permission [​](#permission) + +## Permission Types [​](#permission-types) + +## Access Types [​](#access-types) + +## Access Definition [​](#access-definition) + +## Permission Group [​](#permission-group) + +# CORS [​](#cors) + +Cross-Origin Resource Sharing (CORS) is a security feature implemented in web browsers to prevent malicious websites from accessing resources and data from another domain without permission. By default, web browsers enforce the same-origin policy, which restricts web pages from making requests to a different domain than the one that served the web page. CORS provides a way for servers to declare who can access their assets and under what conditions, enhancing security while enabling controlled cross-origin requests. + +## Understanding CORS [​](#understanding-cors) + +CORS is essential for modern web applications that integrate resources from different origins. For instance, if your web application hosted at `http://example.com` tries to request resources from `http://api.example.com`, the browser will block these requests unless the server at `http://api.example.com` includes the appropriate CORS headers in its responses to indicate that such requests are allowed. + +The CORS mechanism involves the browser sending an `Origin` header with the origin of the requesting site to the server. The server then decides whether to allow or deny the request based on its CORS policy. If allowed, the server includes the `Access-Control-Allow-Origin` header in its response, specifying which origins can access the resources. + +## CORS Configuration [​](#cors-configuration) + +The `@modular-rest/server` framework uses `koa/cors` middleware to configure CORS policies. Here's how you can set it up: + +### CORS Middleware Options [​](#cors-middleware-options) + +Below is a detailed explanation of the CORS configuration options provided by `koa/cors` in `@modular-rest/server`: + +### Example Configuration [​](#example-configuration-1) + +Here's an example of how to configure CORS in your `@modular-rest/server` application: + +javascript + +``` +import { createRest } from '@modular-rest/server'; + +const corsOptions = { + origin: 'https://www.example.com', + allowMethods: ['GET', 'POST'], + credentials: true, + secureContext: true, +}; + +const app = createRest({ + port: 3000, + cors: corsOptions, + // Other configuration options... +}); +``` + +In this configuration: + +* CORS requests are only allowed from `https://www.example.com`. +* Only `GET` and `POST` methods are permitted. +* Credentials are allowed in cross-origin requests. +* Secure context headers are enabled for added security. + +## Conclusion [​](#conclusion) + +Properly configuring CORS is crucial for securing your application and enabling necessary cross-origin requests. `@modular-rest/server` simplifies this process by integrating `koa/cors` middleware, providing a flexible and powerful way to define your CORS policy. By understanding and utilizing these settings, developers can ensure that their web applications are secure and functional across different domains. \ No newline at end of file diff --git a/.cursor/rules/clickup.mdc b/.agent/task/SKILL.md similarity index 96% rename from .cursor/rules/clickup.mdc rename to .agent/task/SKILL.md index 08939b4..014783c 100644 --- a/.cursor/rules/clickup.mdc +++ b/.agent/task/SKILL.md @@ -1,7 +1,6 @@ --- +name: Task relative description: when ever you see ClickUp rules word in the prompt -globs: -alwaysApply: false --- # Clickup Here are our general rules for working on clickup MCP. diff --git a/server/package-lock.json b/server/package-lock.json index 0a8dabe..6644047 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@google-cloud/text-to-speech": "^6.1.0", - "@modular-rest/server": "1.15.0", + "@modular-rest/server": "^1.21.0", "@types/koa-router": "^7.4.8", "date-and-time": "^3.6.0", "decimal.js-light": "^2.5.1", @@ -18,6 +18,7 @@ "googleapis": "^129.0.0", "koa-router": "^13.0.1", "node-fetch": "2", + "node-schedule": "^2.1.1", "stripe": "^18.0.0", "zod": "^3.24.3", "zod-to-json-schema": "^3.24.5" @@ -26,13 +27,23 @@ "@types/jest": "^30.0.0", "@types/koa-router": "^7.4.8", "@types/node": "^22.7.5", + "@types/node-schedule": "^2.1.8", "jest": "^30.0.5", + "jsdom": "^27.4.0", "mongodb-memory-server": "^10.2.0", "ts-jest": "^29.4.0", "ts-node": "^10.9.2", + "turndown": "^7.2.2", "typescript": "^5.6.3" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -56,6 +67,61 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -558,6 +624,141 @@ "node": ">=12" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz", + "integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", @@ -1183,10 +1384,17 @@ "node": ">= 8.0.0" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/@modular-rest/server": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@modular-rest/server/-/server-1.15.0.tgz", - "integrity": "sha512-OJejPiM5aceZkIkEj1pnhAOpJM/eg/7EKOw8SseBJjiMWy/1ku5xXKDQ2XOUh0njcmYvCGR5zZn7Z83qVXRwzA==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@modular-rest/server/-/server-1.21.0.tgz", + "integrity": "sha512-PIN3utAu9t1q21jKZuqzRmc86rlSiHPVLUkVclL9uapzcyhG5wKEWbRLrscCY9X19kS4AU2CtA+7icxmR3kSEQ==", "license": "MIT", "dependencies": { "@koa/cors": "^3.1.0", @@ -1732,6 +1940,16 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/node-schedule": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@types/node-schedule/-/node-schedule-2.1.8.tgz", + "integrity": "sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node/node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -2377,6 +2595,16 @@ ], "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", @@ -2830,6 +3058,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2844,6 +3084,46 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -2853,6 +3133,67 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/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/data-urls/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/data-urls/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/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/date-and-time": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.6.0.tgz", @@ -2875,6 +3216,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/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3098,6 +3446,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3929,6 +4290,52 @@ "node": ">= 0.4" } }, + "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-encoding-sniffer/node_modules/@exodus/bytes": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.9.0.tgz", + "integrity": "sha512-lagqsvnk09NKogQaN/XrtlWeUF8SRhT12odMvbTIIaVObqzwAogL6jhR4DAp0gPuKoM1AOVrKUshJpRdpMFrww==", + "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/html-encoding-sniffer/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4135,6 +4542,13 @@ "node": ">=0.12.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/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4916,6 +5330,140 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@exodus/bytes": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.9.0.tgz", + "integrity": "sha512-lagqsvnk09NKogQaN/XrtlWeUF8SRhT12odMvbTIIaVObqzwAogL6jhR4DAp0gPuKoM1AOVrKUshJpRdpMFrww==", + "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/jsdom/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/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/jsdom/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/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5285,6 +5833,12 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5294,6 +5848,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -5346,6 +5909,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -5897,6 +6467,20 @@ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true }, + "node_modules/node-schedule": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", + "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.2.0", + "long-timeout": "0.1.1", + "sorted-array-functions": "^1.3.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -6064,6 +6648,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6377,6 +6974,16 @@ "node": ">=0.10.0" } }, + "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-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -6517,6 +7124,19 @@ "node": ">=6" } }, + "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/semver": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", @@ -6658,6 +7278,12 @@ "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", "license": "MIT" }, + "node_modules/sorted-array-functions": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", + "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6667,6 +7293,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", @@ -6888,6 +7524,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/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -7026,6 +7669,26 @@ "b4a": "^1.6.4" } }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7053,6 +7716,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -7194,6 +7870,16 @@ "node": ">=0.6.x" } }, + "node_modules/turndown": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -7398,6 +8084,19 @@ "node": ">= 0.8" } }, + "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/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -7422,6 +8121,16 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -7501,6 +8210,45 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/server/package.json b/server/package.json index 2368762..9f60212 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,8 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "test:verbose": "jest --verbose" + "test:verbose": "jest --verbose", + "download:docs": "node scripts/download_docs.js" }, "author": "Navid Shad ", "license": "MIT", @@ -35,9 +36,11 @@ "@types/node": "^22.7.5", "@types/node-schedule": "^2.1.8", "jest": "^30.0.5", + "jsdom": "^27.4.0", "mongodb-memory-server": "^10.2.0", "ts-jest": "^29.4.0", "ts-node": "^10.9.2", + "turndown": "^7.2.2", "typescript": "^5.6.3" } -} +} \ No newline at end of file diff --git a/server/scripts/download_docs.js b/server/scripts/download_docs.js new file mode 100644 index 0000000..248eef3 --- /dev/null +++ b/server/scripts/download_docs.js @@ -0,0 +1,55 @@ +const fs = require('fs'); +const path = require('path'); +const TurndownService = require('turndown'); +const { JSDOM } = require('jsdom'); + +const docs = [ + { + name: 'server', + url: 'https://modular-rest.github.io/modular-rest/server-client-ts/llm-context.html', + path: '../../.agent/modular-rest/server.md' + }, + { + name: 'client', + url: 'https://modular-rest.github.io/modular-rest/js-client/llm-context.html', + path: '../../.agent/modular-rest/client.md' + } +]; + +async function downloadDocs() { + const turndownService = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced' + }); + + for (const doc of docs) { + try { + console.log(`Downloading and converting ${doc.name} from ${doc.url}...`); + const response = await fetch(doc.url); + if (!response.ok) { + throw new Error(`Failed to fetch ${doc.url}: ${response.statusText}`); + } + const html = await response.text(); + + const dom = new JSDOM(html); + const document = dom.window.document; + + const content = document.querySelector('.vp-doc') || document.querySelector('main') || document.body; + const markdown = turndownService.turndown(content.innerHTML); + + const targetPath = path.resolve(__dirname, doc.path); + const dir = path.dirname(targetPath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(targetPath, markdown); + console.log(`Successfully saved ${doc.name} as Markdown at ${doc.path}`); + } catch (error) { + console.error(`Error processing ${doc.name}:`, error.message); + } + } +} + +downloadDocs(); diff --git a/server/yarn.lock b/server/yarn.lock index ce5c627..9ce19bd 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@acemir/cssom@^0.9.28": + version "0.9.31" + resolved "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz" + integrity sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA== + "@ampproject/remapping@^2.2.0": version "2.3.0" resolved "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz" @@ -10,6 +15,33 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" +"@asamuzakjp/css-color@^4.1.1": + version "4.1.1" + resolved "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz" + integrity sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ== + dependencies: + "@csstools/css-calc" "^2.1.4" + "@csstools/css-color-parser" "^3.1.0" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + lru-cache "^11.2.4" + +"@asamuzakjp/dom-selector@^6.7.6": + version "6.7.6" + resolved "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz" + integrity sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg== + dependencies: + "@asamuzakjp/nwsapi" "^2.3.9" + bidi-js "^1.0.3" + css-tree "^3.1.0" + is-potential-custom-element-name "^1.0.1" + lru-cache "^11.2.4" + +"@asamuzakjp/nwsapi@^2.3.9": + version "2.3.9" + resolved "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz" + integrity sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q== + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz" @@ -24,7 +56,7 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz" integrity sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw== -"@babel/core@^7.23.9", "@babel/core@^7.27.4": +"@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.11.0", "@babel/core@^7.23.9", "@babel/core@^7.27.4", "@babel/core@>=7.0.0-beta.0 <8": version "7.28.0" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz" integrity sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ== @@ -285,27 +317,43 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@emnapi/core@^1.4.3": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" - integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== - dependencies: - "@emnapi/wasi-threads" "1.1.0" - tslib "^2.4.0" +"@csstools/color-helpers@^5.1.0": + version "5.1.0" + resolved "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz" + integrity sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA== -"@emnapi/runtime@^1.4.3": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" - integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== - dependencies: - tslib "^2.4.0" +"@csstools/css-calc@^2.1.4": + version "2.1.4" + resolved "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz" + integrity sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ== -"@emnapi/wasi-threads@1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" - integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== +"@csstools/css-color-parser@^3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz" + integrity sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA== dependencies: - tslib "^2.4.0" + "@csstools/color-helpers" "^5.1.0" + "@csstools/css-calc" "^2.1.4" + +"@csstools/css-parser-algorithms@^3.0.5": + version "3.0.5" + resolved "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz" + integrity sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ== + +"@csstools/css-syntax-patches-for-csstree@^1.0.21": + version "1.0.25" + resolved "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz" + integrity sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q== + +"@csstools/css-tokenizer@^3.0.4": + version "3.0.4" + resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz" + integrity sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw== + +"@exodus/bytes@^1.6.0": + version "1.9.0" + resolved "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.9.0.tgz" + integrity sha512-lagqsvnk09NKogQaN/XrtlWeUF8SRhT12odMvbTIIaVObqzwAogL6jhR4DAp0gPuKoM1AOVrKUshJpRdpMFrww== "@google-cloud/text-to-speech@^6.1.0": version "6.1.0" @@ -551,7 +599,7 @@ jest-haste-map "30.0.5" slash "^3.0.0" -"@jest/transform@30.0.5": +"@jest/transform@^29.0.0 || ^30.0.0", "@jest/transform@30.0.5": version "30.0.5" resolved "https://registry.npmjs.org/@jest/transform/-/transform-30.0.5.tgz" integrity sha512-Vk8amLQCmuZyy6GbBht1Jfo9RSdBtg7Lks+B0PecnjI8J+PCLQPGh7uI8Q/2wwpW2gLdiAfiHNsmekKlywULqg== @@ -572,7 +620,7 @@ slash "^3.0.0" write-file-atomic "^5.0.1" -"@jest/types@30.0.5": +"@jest/types@^29.0.0 || ^30.0.0", "@jest/types@30.0.5": version "30.0.5" resolved "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz" integrity sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ== @@ -603,15 +651,23 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== -"@jridgewell/trace-mapping@0.3.9": - version "0.3.9" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" - integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== +"@jridgewell/trace-mapping@^0.3.12": + version "0.3.29" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" + integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@jridgewell/trace-mapping@^0.3.23": + version "0.3.29" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" + integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": +"@jridgewell/trace-mapping@^0.3.24": version "0.3.29" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== @@ -619,6 +675,30 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@^0.3.25": + version "0.3.29" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" + integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@jridgewell/trace-mapping@^0.3.28": + version "0.3.29" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" + integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@js-sdsl/ordered-map@^4.4.2": version "4.4.2" resolved "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz" @@ -631,9 +711,14 @@ dependencies: vary "^1.1.2" +"@mixmark-io/domino@^2.2.0": + version "2.2.0" + resolved "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz" + integrity sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw== + "@modular-rest/server@^1.21.0": version "1.21.0" - resolved "https://registry.yarnpkg.com/@modular-rest/server/-/server-1.21.0.tgz#1b955ab15f88632471cda187eb1a68e6dd15180a" + resolved "https://registry.npmjs.org/@modular-rest/server/-/server-1.21.0.tgz" integrity sha512-PIN3utAu9t1q21jKZuqzRmc86rlSiHPVLUkVclL9uapzcyhG5wKEWbRLrscCY9X19kS4AU2CtA+7icxmR3kSEQ== dependencies: "@koa/cors" "^3.1.0" @@ -656,20 +741,16 @@ dependencies: sparse-bitfield "^3.0.3" -"@napi-rs/wasm-runtime@^0.2.11": - version "0.2.12" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" - integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== - dependencies: - "@emnapi/core" "^1.4.3" - "@emnapi/runtime" "^1.4.3" - "@tybys/wasm-util" "^0.10.0" - "@noble/hashes@^1.1.5": version "1.7.2" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz" integrity sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ== +"@noble/hashes@^1.8.0 || ^2.0.0": + version "2.0.1" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + "@paralleldrive/cuid2@^2.2.2": version "2.2.2" resolved "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz" @@ -784,13 +865,6 @@ resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== -"@tybys/wasm-util@^0.10.0": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" - integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== - dependencies: - tslib "^2.4.0" - "@types/accepts@*": version "1.3.7" resolved "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz" @@ -1007,7 +1081,7 @@ "@types/node-schedule@^2.1.8": version "2.1.8" - resolved "https://registry.yarnpkg.com/@types/node-schedule/-/node-schedule-2.1.8.tgz#138e73c9301335d044f33015d1342a602d849ae4" + resolved "https://registry.npmjs.org/@types/node-schedule/-/node-schedule-2.1.8.tgz" integrity sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA== dependencies: "@types/node" "*" @@ -1109,103 +1183,11 @@ resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== -"@unrs/resolver-binding-android-arm-eabi@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" - integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== - -"@unrs/resolver-binding-android-arm64@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" - integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== - "@unrs/resolver-binding-darwin-arm64@1.11.1": version "1.11.1" resolved "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz" integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== -"@unrs/resolver-binding-darwin-x64@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" - integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== - -"@unrs/resolver-binding-freebsd-x64@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" - integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== - -"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" - integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== - -"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" - integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== - -"@unrs/resolver-binding-linux-arm64-gnu@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" - integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== - -"@unrs/resolver-binding-linux-arm64-musl@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" - integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== - -"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" - integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== - -"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" - integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== - -"@unrs/resolver-binding-linux-riscv64-musl@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" - integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== - -"@unrs/resolver-binding-linux-s390x-gnu@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" - integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== - -"@unrs/resolver-binding-linux-x64-gnu@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" - integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== - -"@unrs/resolver-binding-linux-x64-musl@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" - integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== - -"@unrs/resolver-binding-wasm32-wasi@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" - integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ== - dependencies: - "@napi-rs/wasm-runtime" "^0.2.11" - -"@unrs/resolver-binding-win32-arm64-msvc@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" - integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== - -"@unrs/resolver-binding-win32-ia32-msvc@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" - integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== - -"@unrs/resolver-binding-win32-x64-msvc@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" - integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== - abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" @@ -1233,6 +1215,16 @@ acorn@^8.11.0, acorn@^8.4.1: resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz" integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== +agent-base@^7.1.0: + version "7.1.4" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + +agent-base@^7.1.2: + version "7.1.3" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz" + integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== + agent-base@6: version "6.0.2" resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" @@ -1240,11 +1232,6 @@ agent-base@6: dependencies: debug "4" -agent-base@^7.1.2: - version "7.1.3" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz" - integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== - ansi-escapes@^4.3.2: version "4.3.2" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz" @@ -1331,7 +1318,7 @@ b4a@^1.6.4: resolved "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz" integrity sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg== -babel-jest@30.0.5: +"babel-jest@^29.0.0 || ^30.0.0", babel-jest@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.5.tgz" integrity sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg== @@ -1408,6 +1395,13 @@ base64-js@^1.3.0: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +bidi-js@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz" + integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw== + dependencies: + require-from-string "^2.0.2" + bignumber.js@^9.0.0: version "9.1.2" resolved "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz" @@ -1448,7 +1442,7 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0: +browserslist@^4.24.0, "browserslist@>= 4.21.0": version "4.25.1" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz" integrity sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw== @@ -1675,7 +1669,7 @@ create-require@^1.1.0: cron-parser@^4.2.0: version "4.9.0" - resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" + resolved "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz" integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== dependencies: luxon "^3.2.1" @@ -1689,42 +1683,73 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6: shebang-command "^2.0.0" which "^2.0.1" +css-tree@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz" + integrity sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w== + dependencies: + mdn-data "2.12.2" + source-map-js "^1.0.1" + +cssstyle@^5.3.4: + version "5.3.7" + resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz" + integrity sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ== + dependencies: + "@asamuzakjp/css-color" "^4.1.1" + "@csstools/css-syntax-patches-for-csstree" "^1.0.21" + css-tree "^3.1.0" + lru-cache "^11.2.4" + data-uri-to-buffer@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz" integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== +data-urls@^6.0.0: + version "6.0.1" + resolved "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz" + integrity sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ== + dependencies: + whatwg-mimetype "^5.0.0" + whatwg-url "^15.1.0" + date-and-time@^3.6.0: version "3.6.0" resolved "https://registry.npmjs.org/date-and-time/-/date-and-time-3.6.0.tgz" integrity sha512-V99gLaMqNQxPCObBumb31Bfy3OByXnpqUM0yHPi/aBQE61g42A2rGk6Z2CDnpLrWsOFLQwOgl4Vgshw6D44ebw== -debug@3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== +debug@^3.1.0: + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: - ms "2.0.0" + ms "^2.1.1" -debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1: +debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1, debug@4: version "4.4.1" resolved "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz" integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== dependencies: ms "^2.1.3" -debug@^3.1.0: - version "3.2.7" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== +debug@3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== dependencies: - ms "^2.1.1" + ms "2.0.0" decimal.js-light@^2.5.1: version "2.5.1" resolved "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz" integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== +decimal.js@^10.6.0: + version "10.6.0" + resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz" + integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== + dedent@^1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz" @@ -1755,7 +1780,7 @@ denque@^1.4.1: resolved "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz" integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== -depd@2.0.0, depd@^2.0.0, depd@~2.0.0: +depd@^2.0.0, depd@~2.0.0, depd@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -1817,7 +1842,7 @@ eastasianwidth@^0.2.0: resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: +ecdsa-sig-formatter@^1.0.11, ecdsa-sig-formatter@1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -1868,6 +1893,11 @@ end-of-stream@^1.4.1: dependencies: once "^1.4.0" +entities@^6.0.0: + version "6.0.1" + resolved "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz" + integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz" @@ -1947,7 +1977,7 @@ exit-x@^0.2.2: resolved "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz" integrity sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ== -expect@30.0.5, expect@^30.0.0: +expect@^30.0.0, expect@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/expect/-/expect-30.0.5.tgz" integrity sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ== @@ -1969,7 +1999,7 @@ fast-fifo@^1.2.0, fast-fifo@^1.3.2: resolved "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz" integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== -fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.1.0: +fast-json-stable-stringify@^2.1.0, fast-json-stable-stringify@2.x: version "2.1.0" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -2096,6 +2126,16 @@ function-bind@^1.1.2: resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== +gaxios@^5.0.0: + version "5.1.3" + resolved "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz" + integrity sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA== + dependencies: + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.6.9" + gaxios@^6.0.0, gaxios@^6.0.3, gaxios@^6.1.1: version "6.7.1" resolved "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz" @@ -2116,6 +2156,14 @@ gaxios@^7.0.0-rc.1, gaxios@^7.0.0-rc.4: https-proxy-agent "^7.0.1" node-fetch "^3.3.2" +gcp-metadata@^5.2.0: + version "5.3.0" + resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz" + integrity sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w== + dependencies: + gaxios "^5.0.0" + json-bigint "^1.0.0" + gcp-metadata@^6.1.0: version "6.1.1" resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz" @@ -2325,6 +2373,13 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +html-encoding-sniffer@^6.0.0: + 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== + dependencies: + "@exodus/bytes" "^1.6.0" + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz" @@ -2338,17 +2393,6 @@ http-assert@^1.3.0: deep-equal "~1.0.1" http-errors "~1.8.0" -http-errors@2.0.0, http-errors@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - http-errors@^1.3.1, http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0: version "1.8.1" resolved "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz" @@ -2360,6 +2404,17 @@ http-errors@^1.3.1, http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0: statuses ">= 1.5.0 < 2" toidentifier "1.0.1" +http-errors@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-errors@~1.6.2: version "1.6.3" resolved "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz" @@ -2370,6 +2425,17 @@ http-errors@~1.6.2: setprototypeof "1.1.0" statuses ">= 1.4.0 < 2" +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz" @@ -2379,6 +2445,14 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" +http-proxy-agent@^7.0.2: + version "7.0.2" + resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + https-proxy-agent@^5.0.0: version "5.0.1" resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz" @@ -2433,7 +2507,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3: +inherits@^2.0.3, inherits@~2.0.3, inherits@2, inherits@2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2473,6 +2547,11 @@ is-number@^7.0.0: resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-potential-custom-element-name@^1.0.1: + 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== + is-regex@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz" @@ -2488,16 +2567,16 @@ is-stream@^2.0.0: resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== - isarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" @@ -2764,7 +2843,7 @@ jest-resolve-dependencies@30.0.5: jest-regex-util "30.0.1" jest-snapshot "30.0.5" -jest-resolve@30.0.5: +jest-resolve@*, jest-resolve@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.5.tgz" integrity sha512-d+DjBQ1tIhdz91B79mywH5yYu76bZuE96sSbxj8MkjWVx5WNdt1deEFRONVL4UkKLSrAbMkdhb24XN691yDRHg== @@ -2861,7 +2940,7 @@ jest-snapshot@30.0.5: semver "^7.7.2" synckit "^0.11.8" -jest-util@30.0.5: +"jest-util@^29.0.0 || ^30.0.0", jest-util@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz" integrity sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g== @@ -2910,7 +2989,7 @@ jest-worker@30.0.5: merge-stream "^2.0.0" supports-color "^8.1.1" -jest@^30.0.5: +"jest@^29.0.0 || ^30.0.0", jest@^30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/jest/-/jest-30.0.5.tgz" integrity sha512-y2mfcJywuTUkvLm2Lp1/pFX8kTgMO5yyQGq/Sk/n2mN7XWYp4JsCZ/QXW34M8YScgk8bPZlREH04f6blPnoHnQ== @@ -2933,6 +3012,32 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" +jsdom@^27.4.0: + version "27.4.0" + resolved "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz" + integrity sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ== + dependencies: + "@acemir/cssom" "^0.9.28" + "@asamuzakjp/dom-selector" "^6.7.6" + "@exodus/bytes" "^1.6.0" + cssstyle "^5.3.4" + data-urls "^6.0.0" + decimal.js "^10.6.0" + html-encoding-sniffer "^6.0.0" + http-proxy-agent "^7.0.2" + https-proxy-agent "^7.0.6" + is-potential-custom-element-name "^1.0.1" + parse5 "^8.0.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^6.0.0" + w3c-xmlserializer "^5.0.0" + webidl-conversions "^8.0.0" + whatwg-mimetype "^4.0.0" + whatwg-url "^15.1.0" + ws "^8.18.3" + xml-name-validator "^5.0.0" + jsesc@^3.0.2: version "3.1.0" resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz" @@ -3193,7 +3298,7 @@ lodash.once@^4.0.0: long-timeout@0.1.1: version "0.1.1" - resolved "https://registry.yarnpkg.com/long-timeout/-/long-timeout-0.1.1.tgz#9721d788b47e0bcb5a24c2e2bee1a0da55dab514" + resolved "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz" integrity sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w== long@*, long@^5.0.0: @@ -3206,6 +3311,11 @@ lru-cache@^10.2.0: resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== +lru-cache@^11.2.4: + version "11.2.4" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz" + integrity sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz" @@ -3215,7 +3325,7 @@ lru-cache@^5.1.1: luxon@^3.2.1: version "3.7.2" - resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.7.2.tgz#d697e48f478553cca187a0f8436aff468e3ba0ba" + resolved "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz" integrity sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew== make-dir@^3.0.2: @@ -3249,6 +3359,11 @@ math-intrinsics@^1.1.0: resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz" integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== +mdn-data@2.12.2: + version "2.12.2" + resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz" + integrity sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" @@ -3294,7 +3409,14 @@ mimic-fn@^2.1.0: resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: +minimatch@^3.0.4, minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -3354,6 +3476,15 @@ mongodb-memory-server@^10.2.0: mongodb-memory-server-core "10.2.0" tslib "^2.8.1" +mongodb@^6.9.0: + version "6.18.0" + resolved "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz" + integrity sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ== + dependencies: + "@mongodb-js/saslprep" "^1.1.9" + bson "^6.10.4" + mongodb-connection-string-url "^3.0.0" + mongodb@3.7.4: version "3.7.4" resolved "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz" @@ -3367,21 +3498,12 @@ mongodb@3.7.4: optionalDependencies: saslprep "^1.0.0" -mongodb@^6.9.0: - version "6.18.0" - resolved "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz" - integrity sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ== - dependencies: - "@mongodb-js/saslprep" "^1.1.9" - bson "^6.10.4" - mongodb-connection-string-url "^3.0.0" - mongoose-legacy-pluralize@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz" integrity sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ== -mongoose@^5.10.9: +mongoose@*, mongoose@^5.10.9: version "5.13.23" resolved "https://registry.npmjs.org/mongoose/-/mongoose-5.13.23.tgz" integrity sha512-Q5bo1yYOcH2wbBPP4tGmcY5VKsFkQcjUDh66YjrbneAFB3vNKQwLvteRFLuLiU17rA5SDl3UMcMJLD9VS8ng2Q== @@ -3417,6 +3539,11 @@ mquery@3.2.5: safe-buffer "5.1.2" sliced "1.0.1" +ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -3427,11 +3554,6 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1, ms@^2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - napi-postinstall@^0.3.0: version "0.3.2" resolved "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz" @@ -3464,7 +3586,7 @@ node-domexception@^1.0.0: resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== -node-fetch@2, node-fetch@^2.6.9: +node-fetch@^2.6.9, node-fetch@2: version "2.7.0" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -3492,7 +3614,7 @@ node-releases@^2.0.19: node-schedule@^2.1.1: version "2.1.1" - resolved "https://registry.yarnpkg.com/node-schedule/-/node-schedule-2.1.1.tgz#6958b2c5af8834954f69bb0a7a97c62b97185de3" + resolved "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz" integrity sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ== dependencies: cron-parser "^4.2.0" @@ -3547,11 +3669,6 @@ only@~0.0.2: resolved "https://registry.npmjs.org/only/-/only-0.0.2.tgz" integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ== -optional-require@1.0.x: - version "1.0.3" - resolved "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz" - integrity sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA== - optional-require@^1.1.8: version "1.1.8" resolved "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz" @@ -3559,6 +3676,11 @@ optional-require@^1.1.8: dependencies: require-at "^1.0.6" +optional-require@1.0.x: + version "1.0.3" + resolved "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz" + integrity sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA== + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz" @@ -3600,6 +3722,13 @@ parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" +parse5@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz" + integrity sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA== + dependencies: + entities "^6.0.0" + parseurl@^1.3.2: version "1.3.3" resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz" @@ -3610,7 +3739,7 @@ path-exists@^4.0.0: resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== -path-is-absolute@1.0.1, path-is-absolute@^1.0.0: +path-is-absolute@^1.0.0, path-is-absolute@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== @@ -3672,7 +3801,7 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pretty-format@30.0.5, pretty-format@^30.0.0: +pretty-format@^30.0.0, pretty-format@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz" integrity sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw== @@ -3783,7 +3912,7 @@ readable-stream@^3.1.1: string_decoder "^1.1.1" util-deprecate "^1.0.1" -regexp-clone@1.0.0, regexp-clone@^1.0.0: +regexp-clone@^1.0.0, regexp-clone@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz" integrity sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw== @@ -3798,6 +3927,11 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== +require-from-string@^2.0.2: + 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== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz" @@ -3827,15 +3961,25 @@ retry-request@^8.0.0: extend "^3.0.2" teeny-request "^10.0.0" -safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: +safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0, safe-buffer@5.2.1: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0: version "5.1.2" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-buffer@5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== safe-regex-test@^1.1.0: version "1.1.0" @@ -3858,17 +4002,39 @@ saslprep@^1.0.0: dependencies: sparse-bitfield "^3.0.3" +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + semver@^5.6.0: version "5.7.2" resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@^6.0.0, semver@^6.3.1: +semver@^6.0.0: + version "6.3.1" + resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^6.3.1: version "6.3.1" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.3, semver@^7.5.4, semver@^7.7.2: +semver@^7.5.3: + version "7.7.2" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + +semver@^7.5.4: + version "7.7.2" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + +semver@^7.7.2: version "7.7.2" resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== @@ -3962,9 +4128,14 @@ sliced@1.0.1: sorted-array-functions@^1.3.0: version "1.3.0" - resolved "https://registry.yarnpkg.com/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz#8605695563294dffb2c9796d602bd8459f7a0dd5" + resolved "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz" integrity sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA== +source-map-js@^1.0.1: + version "1.2.1" + resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + source-map-support@0.5.13: version "0.5.13" resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz" @@ -3997,16 +4168,16 @@ stack-utils@^2.0.6: dependencies: escape-string-regexp "^2.0.0" +statuses@^1.5.0, "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2": + version "1.5.0" + resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + statuses@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0: - version "1.5.0" - resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" - integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== - stream-events@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz" @@ -4029,6 +4200,20 @@ streamx@^2.15.0: optionalDependencies: bare-events "^2.2.0" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + string-length@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz" @@ -4064,20 +4249,6 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -4141,6 +4312,11 @@ supports-color@^8.1.1: dependencies: has-flag "^4.0.0" +symbol-tree@^3.2.4: + 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== + synckit@^0.11.8: version "0.11.11" resolved "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz" @@ -4183,6 +4359,18 @@ text-decoder@^1.1.0: dependencies: b4a "^1.6.4" +tldts-core@^7.0.19: + version "7.0.19" + resolved "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz" + integrity sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A== + +tldts@^7.0.5: + version "7.0.19" + resolved "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz" + integrity sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA== + dependencies: + tldts-core "^7.0.19" + tmpl@1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz" @@ -4200,6 +4388,13 @@ toidentifier@1.0.1: resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +tough-cookie@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz" + integrity sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w== + dependencies: + tldts "^7.0.5" + tr46@^5.1.0: version "5.1.1" resolved "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz" @@ -4207,6 +4402,13 @@ tr46@^5.1.0: dependencies: punycode "^2.3.1" +tr46@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz" + integrity sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw== + dependencies: + punycode "^2.3.1" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz" @@ -4227,7 +4429,7 @@ ts-jest@^29.4.0: type-fest "^4.41.0" yargs-parser "^21.1.1" -ts-node@^10.9.2: +ts-node@^10.9.2, ts-node@>=9.0.0: version "10.9.2" resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz" integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== @@ -4256,6 +4458,13 @@ tsscmp@1.0.6: resolved "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz" integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== +turndown@^7.2.2: + version "7.2.2" + resolved "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz" + integrity sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ== + dependencies: + "@mixmark-io/domino" "^2.2.0" + type-detect@4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" @@ -4279,7 +4488,7 @@ type-is@^1.6.16: media-typer "0.3.0" mime-types "~2.1.24" -typescript@^5.6.3: +typescript@^5.6.3, typescript@>=2.7, "typescript@>=4.3 <6": version "5.8.2" resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz" integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ== @@ -4378,6 +4587,13 @@ vary@^1.1.2: resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== +w3c-xmlserializer@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz" + integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== + dependencies: + xml-name-validator "^5.0.0" + walker@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz" @@ -4400,6 +4616,21 @@ webidl-conversions@^7.0.0: resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz" integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== +webidl-conversions@^8.0.0: + 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== + +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + +whatwg-mimetype@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz" + integrity sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw== + "whatwg-url@^14.1.0 || ^13.0.0": version "14.2.0" resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz" @@ -4408,6 +4639,14 @@ webidl-conversions@^7.0.0: tr46 "^5.1.0" webidl-conversions "^7.0.0" +whatwg-url@^15.1.0: + version "15.1.0" + resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz" + integrity sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g== + dependencies: + tr46 "^6.0.0" + webidl-conversions "^8.0.0" + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz" @@ -4463,6 +4702,21 @@ write-file-atomic@^5.0.1: imurmurhash "^0.1.4" signal-exit "^4.0.1" +ws@^8.18.3: + version "8.19.0" + resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz" + integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== + +xml-name-validator@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz" + integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" @@ -4519,7 +4773,7 @@ zod-to-json-schema@^3.24.5: resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz" integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== -zod@^3.19.1, zod@^3.24.3: +zod@^3.19.1, zod@^3.24.1, zod@^3.24.3: version "3.24.3" resolved "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz" integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg== From 789f22a7e714eaaa40a05094b2e468c8c4829bca Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sat, 24 Jan 2026 01:04:04 +0200 Subject: [PATCH 08/33] feat: Add e2e testing guide and apply authentication middleware to the board page. --- .agent/e2e/SKILL.md | 34 ++++++++++++++++++++++++++++++++++ frontend/pages/board/index.vue | 31 ++++++++++++++++++------------- 2 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 .agent/e2e/SKILL.md diff --git a/.agent/e2e/SKILL.md b/.agent/e2e/SKILL.md new file mode 100644 index 0000000..71f7c78 --- /dev/null +++ b/.agent/e2e/SKILL.md @@ -0,0 +1,34 @@ +--- +name: e2e +description: End-to-end navigation and testing guide for Subturtle Dashboard +--- + +This skill provides specific instructions for navigating and testing the Subturtle Dashboard application using browser tools. + +## Prerequisites +1. **Server**: Ensure the server is running (usually on port 8080). +2. **Frontend**: Ensure the frontend is running (usually on port 3000). + +## Authentication Flow +The application uses hash-based routing (`hashMode: true` in Nuxt config). To authenticate via token, you MUST include the `#` character in the URL. + +**Correct URL Template:** +`http://localhost:3000/#/auth/login_with_token?token={YOUR_TOKEN}` + +### Steps for Browser Agents: +1. **Navigate**: Use the `open_browser_url` tool with the correct hash-based login URL. +2. **Wait for Redirect**: The `login_with_token` page is a redirection page. Wait at least 3 seconds for the `onMounted` hook to process the token and redirect to the dashboard. +3. **Verify Dashboard**: After redirection, verify you are on `/#/` or another authenticated route. Use `capture_browser_screenshot` to confirm the UI has loaded. +4. **Inspect UI**: Once logged in, use the navigation menu or direct hash-links to find the specific page. + +## Navigation Structure +- Home/Overview: `/#/` +- Board (Learning System): `/#/board` +- Statistic: `/#/statistic` +- Sessions: `/#/sessions` +- Settings: `/#/settings` + +## Troubleshooting +- **Blank Page**: Check browser console for authentication errors. +- **Login Redirect**: If redirected to `/#/auth/login`, the token might be invalid or expired. +- **Missing Hash**: Always ensure the URL contains `/#/` after the origin. \ No newline at end of file diff --git a/frontend/pages/board/index.vue b/frontend/pages/board/index.vue index adc5781..fff4809 100644 --- a/frontend/pages/board/index.vue +++ b/frontend/pages/board/index.vue @@ -8,27 +8,28 @@
-
+

{{ $t('board.no_activities') }}

{{ $t('board.all_caught_up') }}

-
+
-
- +
+
- + {{ $t('board.due') }}
@@ -45,10 +46,8 @@ {{ $t('board.activity_ready') }}

- @@ -72,6 +71,12 @@ const loading = ref(true); const activities = computed(() => boardActivities.value); +definePageMeta({ + layout: 'default', + // @ts-ignore + middleware: ['auth'], +}); + onMounted(async () => { loading.value = true; // We should trigger a refresh check first? From dcca8b2c41fc47e735c18dda3ffc4f0a84f23606 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Sat, 24 Jan 2026 10:51:40 +0200 Subject: [PATCH 09/33] feat: Reorganize agent documentation by introducing a development skill, integrating lib-vue-components docs, and updating doc download paths with conditional markdown conversion. --- .agent/development/SKILL.md | 43 + .agent/development/lib-vue-components.md | 9768 +++++++++++++++++ .../modular-rest_client.md} | 0 .../modular-rest_server.md} | 0 .agent/modular-rest/SKILL.md | 9 - server/scripts/download_docs.js | 18 +- 6 files changed, 9824 insertions(+), 14 deletions(-) create mode 100644 .agent/development/SKILL.md create mode 100644 .agent/development/lib-vue-components.md rename .agent/{modular-rest/client.md => development/modular-rest_client.md} (100%) rename .agent/{modular-rest/server.md => development/modular-rest_server.md} (100%) delete mode 100644 .agent/modular-rest/SKILL.md diff --git a/.agent/development/SKILL.md b/.agent/development/SKILL.md new file mode 100644 index 0000000..7e172b2 --- /dev/null +++ b/.agent/development/SKILL.md @@ -0,0 +1,43 @@ +--- +name: CodeBridger Development Skill +description: Core development guidelines and documentation for the Subturtle/CodeBridger ecosystem. Triggered when working on dashboard-app or server. +--- + +# Development Guidelines + +## General Principles + +- **Modularity**: Prioritize modularity in all code changes. Organize logic into distinct modules (Server-side) or reusable components (Frontend). +- **ClickUp Integration**: + - Task management primarily relies on ClickUp. + - Always reference the ClickUp task ID in commit messages. Format: `feat: #taskid message`. +- **Communication**: + - Favor answering questions and providing explanations over direct code modifications unless explicitly requested. + - Ensure all explanations are clear and provide necessary context. + +## Framework Documentation + +Always refer to the official documentation for package-specific implementations: + +### Server-Side (@modular-rest/server) +- **Primary Docs**: [modular-rest_server.md](./modular-rest_server.md) +- **Key Patterns**: + - Use `defineCollection` in `db.ts` for database models. + - Use `defineFunction` in `functions.ts` for API logic. + - Avoid manual router creation unless necessary (`router.ts`). + +### Frontend-Side (@modular-rest/client) +- **Primary Docs**: [modular-rest_client.md](./modular-rest_client.md) +- **Key Patterns**: + - Reference the JS client for consuming services and calling server functions. + +### UI Components (lib-vue-components) +- **Primary Docs**: [lib-vue-components.md](./lib-vue-components.md) +- **Key Patterns**: + - Import components directly from `@codebridger/lib-vue-components` as needed (e.g., `import { Button } from '@codebridger/lib-vue-components'`). + - Follow the design system guidelines for consistent aesthetics. + + +--- + +You must read the specific documentation files listed above before proposing or implementing any technical changes related to these packages. \ No newline at end of file diff --git a/.agent/development/lib-vue-components.md b/.agent/development/lib-vue-components.md new file mode 100644 index 0000000..85b4767 --- /dev/null +++ b/.agent/development/lib-vue-components.md @@ -0,0 +1,9768 @@ +# Lib Vue Components Documentation + +Generated on: 2025-11-16T14:05:12.514Z + +--- + +## Getting Started / Installation + +### Installation Guide + +#### Prerequisites + +- A working Vue 3 or Nuxt 3 project +- GitHub account with package access +- Node.js and npm/yarn installed + +#### Setup Steps + +##### 1\. GitHub Authentication + +1. Create a GitHub personal access token: + + - Go to GitHub Settings → Developer Settings → Personal Access Tokens + - Generate a new token with `read:packages` permission + - Copy the generated token + - For detailed instructions, watch this guide +2. Create an `.npmrc` file in your project root: + + ``` + @codebridger:registry=https://npm.pkg.github.com + //npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN + ``` + + Replace `YOUR_GITHUB_TOKEN` with the token you created. + + +##### 2\. Package Installation + +Install the package using npm or yarn: + +``` +# Using npm +npm install @codebridger/lib-vue-components + +# Using yarn +yarn add @codebridger/lib-vue-components + +# install from dev branch +yarn add @codebridger/lib-vue-components@dev +``` + +##### 3\. Integration + +###### Vue 3 Setup + +``` +// Import components and Styles +import vueComponents from "@codebridger/lib-vue-components"; +import '@codebridger/lib-vue-components/style.css'; + +// Configuration options +const options = { + // Component prefix (default: "CL") + prefix: "CL", + + // Optional: Disable specific integrations + dontInstallPinia: true, + dontInstallPopper: false, + dontInstallPerfectScrollbar: false, +}; + +// Install on Vue app +vueApp.use(vueComponents, options); +``` + +###### Nuxt 3 Setup + +1. Create a plugin file `plugins/component-library.client.ts`: + +``` +import { defineNuxtPlugin as init } from '@codebridger/lib-vue-components/nuxt'; + +export default defineNuxtPlugin({ + name: '@codebridger/lib-vue-components', + enforce: 'pre', + async setup(nuxtApp) { + const options = { + prefix: "CL", + dontInstallPinia: true, + dontInstallPopper: false, + dontInstallPerfectScrollbar: false, + }; + + return init(nuxtApp, options); + }, +}); +``` + +2. Update your `nuxt.config.ts`: + +``` +export default defineNuxtConfig({ + // Ensure components are transpiled during build + build: { + transpile: ['@codebridger/lib-vue-components'], + }, + + css: [ + // ... other CSS files + '@codebridger/lib-vue-components/style.css', + ], + + // ... other Nuxt config +}); +``` + +#### Configuration Options + +``` +interface ConfigOptions { + // Prefix for component names (default: "CL") + prefix?: string; + + // Disable Pinia store integration (default: false) + dontInstallPinia?: boolean; + + // Disable Popper.js integration (default: false) + dontInstallPopper?: boolean; + + // Disable Perfect Scrollbar integration (default: false) + dontInstallPerfectScrollbar?: boolean; +} +``` + +#### Next Steps + +After installation, you can start using the components in your application. Check out our component documentation for detailed usage instructions and examples. + +--- + +## Getting Started How To / Use + +### Component library + +We are two indie developers who love to build things. We are building a component library for vue and nuxt projects. it inspired by `vristo` and powered by `tailwindcss`. + +You need to follow these principles in order to use the component library properly. + +#### LLM Friendly docs + +You can use this doc for llm agents. + +#### AppRoot + +All components should be wrapped with the main ancestor component called `AppRoot`. It's really important to wrap all components with `AppRoot` because it's responsible for the global state management and the theme management. + +``` + + + +``` + +``` +// All Components +Import { Button, Input, App } form '@codebridger/lib-vue-components' + +// Or Import by category: + +// Shell components +import { + App, + DashboardShell, + ThemeCustomizer, + SidebarMenu, + HorizontalMenu, +} from "@codebridger/lib-vue-components/shell"; + +// Element components +import { Button } from "@codebridger/lib-vue-components/elements"; + +// Form components +import { Input } from "@codebridger/lib-vue-components/form"; + +// Complex components +import { Modal } from "@codebridger/lib-vue-components/complex"; + +// Type imports +import type { + SidebarItemType, + HorizontalMenuItemType, +} from "@codebridger/lib-vue-components/types"; +``` + +#### Global Configuration + +There is pinia store for global configuration. see full documentation here + +``` +import { useAppStore } from "@codebridger/lib-vue-components/store.ts"; + +const appStore = useAppStore(); +appStore.setTheme("dark"); +``` + +--- + +## Getting Started Global / Configuration + +#### Using `useAppStore` + +Import and use the `useAppStore` in your components to access and modify the global state. + +##### Importing the Store + +``` +import { useAppStore } from '@codebridger/lib-vue-components/store.ts'; +``` + +##### Accessing State + +You can access the state properties directly from the store instance: + +``` +const store = useAppStore(); + +console.log(store.isDarkMode); // false +console.log(store.theme); // "light" +``` + +##### Modifying State + +The store provides several methods to modify the state. Below are examples of how to use these methods. + +``` +// Toggle Theme +store.toggleTheme('dark'); + +// Toggle Menu Style +store.toggleMenuStyle('horizontal'); + +// Toggle Layout +store.toggleLayout('boxed-layout'); + +// Toggle RTL +store.toggleRTL('rtl'); + +// Toggle Animation +store.toggleAnimation('fade'); + +// Toggle Navbar +store.toggleNavbar('navbar-floating'); + +// Toggle Semidark Mode +store.toggleSemidark(true); + +// Toggle Sidebar +store.toggleSidebar(true); +store.toggleSidebar(false); + +// Toggle Main Loader +store.toggleMainLoader(true); // Show main loader +``` + +#### State Properties + +| Property | Type | Description | +| --- | --- | --- | +| isDarkMode | boolean | Indicates if dark mode is enabled. | +| mainLayout | string | The main layout of the application. | +| theme | string | The current theme (light, dark, or system). | +| menu | string | The current menu style (vertical, horizontal, or collapsible-vertical). | +| layout | string | The current layout style (boxed-layout or full). | +| rtlClass | string | The current text direction (ltr or rtl). | +| isRtl | boolean | Computed property indicating if RTL is enabled. | +| animation | string | The current animation style. | +| navbar | string | The current navbar style (navbar-sticky, navbar-static, or navbar-floating). | +| locale | string | The current locale. | +| sidebar | boolean | Indicates if the sidebar is visible. | +| isShowMainLoader | boolean | Indicates if the main loader is visible. | +| semidark | boolean | Indicates if semidark mode is enabled. | + +#### Methods + +| Method | Input Type | Description | +| --- | --- | --- | +| setMainLayout(payload) | any | Sets the main layout. | +| toggleTheme(payload) | "light" \| "dark" \| "system" \| any | Toggles the theme. | +| toggleMenuStyle(payload) | "vertical" \| "horizontal" \| "collapsible-vertical" \| string | Toggles the menu style. | +| toggleLayout(payload) | "boxed-layout" \| "full" \| any | Toggles the layout style. | +| toggleRTL(payload) | "ltr" \| "rtl" \| any | Toggles the text direction. | +| toggleAnimation(payload) | any | Toggles the animation style. | +| toggleNavbar(payload) | "navbar-sticky" \| "navbar-static" \| "navbar-floating" | Toggles the navbar style. | +| toggleSemidark(payload) | any | Toggles semidark mode. | +| toggleSidebar(state?) | boolean | Toggles the sidebar visibility. | +| toggleMainLoader(state) | boolean | Toggles the main loader visibility. | + +#### Example + +Here is an example of how to use the `useAppStore` in a Vue component: + +``` + + + +``` + +--- + +## Shell / Approot + +### AppRoot + +Top-level shell that wires global layout concerns: color scheme, direction (LTR/RTL), and layout style. Wrap your application to ensure consistent theming and structure. + +#### Features + +- Controls color scheme (light/dark), layout style (full/boxed), and direction (LTR/RTL) +- Provides consistent container and reset styles for child content + +#### Usage + +Use as the root wrapper for app pages/stories. Combine with DashboardShell for full navigation scaffolding. + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| colorScheme | "light""dark""system" | - | lightdarksystem | +| layoutStyle | "boxed-layout""full" | - | fullboxed-layout | +| direction | "ltr""rtl" | - | ltrrtl | +| slots | | +| default | other | - | | + +--- + +## Shell / Dashboardshell + +### DashboardShell + +Composable page shell providing header, horizontal menu, sidebar, content, and footer slots. Supports vertical and horizontal navigation styles. + +#### Features + +- Slot-based regions: header, horizontal-menu, sidebar-menu, content, footer +- Toggleable menu visibility; vertical/horizontal navigation styles +- Works with HorizontalMenu and SidebarMenu components + +#### Usage + +Wrap application pages to provide consistent navigation and scaffolding. Fill slots with your own menus and content. + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| menuStyle | "vertical""horizontal""collapsible-vertical" | "vertical" | horizontalverticalcollapsible-vertical | +| brandTitle* | string | - | PilotsUI | +| hideMenu | boolean | - | FalseTrue | +| brandLogo | string | - | | +| loading | boolean | - | | +| slots | | +| sidebar-menu | Area for sidebar menu{ closeSidebar: unknown } | - | | +| brand | Area for logo and menu iconother | - | | +| header | Decorating the empty space after brand titleother | - | | +| horizontal-menu | Area on the header right below of the header, for horizontal menuother | - | | +| content | Main content slot, page content should be placed here{ width: unknown; height: unknown } | - | | +| footer | Footer slotother | - | | + +#### Stories + +##### Full Setup Shell + +``` + +``` + +##### Simple Shell + +``` + +``` + +--- + +## Shell / Horizontalmenu + +### HorizontalMenu + +Responsive top navigation bar rendering a hierarchy of items with icons and labels. Integrates with the shell store to switch layout style. + +#### Features + +- Renders menu items with nesting and icons +- Suited for wide screens; pairs with DashboardShell +- Works in LTR/RTL and dark mode contexts + +#### Usage + +Supply a prepared items array. Keep labels concise; group related pages under dropdowns. + +``` + + + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| items* | Sidebar itemsHorizontalMenuGroupType[] | - | items : [0 : {...} 3 keys1 : {...} 3 keys2 : {...} 3 keys3 : {...} 3 keys4 : {...} 3 keys5 : {...} 3 keys6 : {...} 3 keys7 : {...} 3 keys] | +| events | | +| ItemClick | Emit when the sidebar item is clickedHorizontalMenuItemType | - | - | + +--- + +## Shell / Sidebarmenu + +### SidebarMenu + +### SidebarMenu + +Vertical navigation menu suitable for dashboards and admin panels. Displays labeled items, groups, and nested sections. + +#### Features + +- Sticky sidebar layout with collapsible sections +- Integrates with store to control visibility +- Dark mode and RTL support + +#### Usage + +Provide an items tree with groups and links. Keep the hierarchy shallow for discoverability. + +``` + + + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| items* | Sidebar itemsSidebarGroupType[] | - | items : [0 : {...} 2 keys1 : {...} 2 keys2 : {...} 2 keys3 : {...} 2 keys4 : {...} 2 keys5 : {...} 2 keys] | +| title | Sidebar titlestring | - | SIDEBAR | +| brandLogo | Sidebar logo URLstring | - | | +| events | | +| ItemClick | Emit when the sidebar item is clickedSidebarItemType | - | - | +| slots | | +| brand | brand content, title will be removed in this caseother | - | | + +--- + +## Icons Alternative Icon / Packs + +### Alternative Icon Packs + +Using Iconify as an Alternative Icon Pack with Tailwind CSS + +Iconify is a comprehensive icon library that offers over 200,000 icons from many popular sets. It can serve as an alternative icon pack for Tailwind CSS projects, giving you access to a vast selection of icons beyond the defaults. With the Iconify Tailwind plugin, you can easily integrate these icons and style them with Tailwind utility classes. + +- **Official Website:** Iconify gallery +- **Official Iconify Tailwind CSS page:** doc + +#### Tailwind CSS Setup for Iconify + +To get started with Iconify in a Tailwind CSS project, follow these steps: + +###### 1\. **Install the Iconify Tailwind CSS plugin and icon set(s):** + +Add the official plugin and any icon sets you want to use as development dependencies. For example, to use Material Design Icons (light theme) you can run: `npm install -D @iconify/tailwind @iconify-json/mdi-light`. + +###### 2\. **Configure Tailwind to use the Iconify plugin:** + +Open your tailwind.config.js and import the plugin. Then add it to the plugins array, specifying which icon sets (by their prefix) to include if using the static selectors. For example: + +``` +// tailwind.config.js +const { addIconSelectors } = require('@iconify/tailwind'); +module.exports = { + // ... other Tailwind config ... + plugins: [ + // Include Iconify plugin and specify icon set prefixes to load + addIconSelectors(['mdi-light']) + ] +}; + +// (Alternatively, you can use the dynamic plugin with addDynamicIconSelectors() to avoid listing prefixes. +// Make sure to install the icon sets you need.) +``` + +###### 3\. **Run your build:** + +Ensure your build process (e.g., Vite or Webpack) runs Tailwind so it generates the icon classes. The plugin will generate the necessary CSS for any Iconify icon classes you use in your templates. + +Usage Example in a Vue Component + +After setup, you can use Iconify icons in your Vue components just like using any other HTML element with Tailwind classes. For example, in a Vue component template you might add an icon like this: + +``` + +``` + +In the snippet above, the icon-\[mdi-light--home\] class inserts the mdi-light:home icon as an inline SVG. We also apply Tailwind utility classes for color and size. In this example, text-gray-600 sets the icon color (monotone icons inherit the text color), and w-6 h-6 gives it a fixed width and height. You can swap out the prefix and icon name to use any icon from Iconify’s library (just make sure you’ve installed the corresponding icon set). + +For more details on using Iconify with Tailwind (and other setup options like additional icon sets or dynamic mode), refer to the official Iconify Tailwind CSS documentation. + +--- + +## Icons Icon / Gallery + +### Icon Gallery + +To use icons listed in this page you need to import the `icon` component and provide a name from the list below. + +``` + + + +``` + +### Menu Icons + +IconMenuScrumboard + +IconMenuTables + +IconMenuComponents + +IconMenuWidgets + +IconMenuUsers + +IconMenuElements + +IconMenuMore + +IconMenuChat + +IconMenuDatatables + +IconMenuContacts + +IconMenuFontIcons + +IconMenuAuthentication + +IconMenuCalendar + +IconMenuMailbox + +IconMenuDashboard + +IconMenuPages + +IconMenuForms + +IconMenuInvoice + +IconMenuDragAndDrop + +IconMenuTodo + +IconMenuCharts + +IconMenuApps + +IconMenuNotes + +IconMenuDocumentation + +### Variant Icons + +IconAirplay + +IconCaretsDown + +IconMessageDots + +IconUser + +IconPlayCircle + +IconPhoneCall + +IconLockDots + +IconMail + +IconDesktop + +IconBookmark + +IconBox + +IconPlusCircle + +IconDollarSignCircle + +IconInfoCircle + +IconPencil + +IconRouter + +IconTwitter + +IconMinusCircle + +IconLayout + +### Static Icons + +IconArrowLeft + +IconArrowRight + +IconArrowUp + +IconArrowDown + +IconArrowWaveLeftUp + +IconArrowBackward + +IconArrowForward + +IconMultipleForwardRight + +IconCaretDown + +IconLogin + +IconLogout + +IconFacebook + +IconFacebookCircle + +IconLinkedin + +IconInstagram + +IconDribbble + +IconGoogle + +IconChrome + +IconNetflix + +IconSafari + +IconGithub + +IconTether + +IconBinance + +IconBitcoin + +IconEthereum + +IconSolana + +Litecoin + +IconLitecoin + +IconUserPlus + +IconUsers + +IconUsersGroup + +IconLock + +IconLockOpen + +IconSettings + +IconMoodSmile + +IconEye + +IconEyeOff + +IconCashBanknotes + +IconShoppingCart + +IconShoppingBag + +IconCreditCard + +IconDollarSign + +IconTag + +IconChatNotification + +IconChatDot + +IconChatDots + +IconMessagesDot + +IconMessage + +IconMessage2 + +IconMailDot + +IconBell + +IconBellBing + +IconThumbUp + +IconAt + +IconShare + +IconLink + +IconFile + +IconTxtFile + +IconZipFile + +IconFolder + +IconFolderPlus + +IconFolderMinus + +IconOpenBook + +IconBook + +IconClipboardText + +IconNotes + +IconNotesEdit + +IconPencilPaper + +IconPaperclip + +IconCopy + +IconPrinter + +IconSave + +IconInfoTriangle + +IconInfoHexagon + +IconHelpCircle + +IconListCheck + +IconChecks + +IconCheck + +IconSquareCheck + +IconCircleCheck + +IconLoader + +IconPlus + +IconMinus + +IconX + +IconXCircle + +IconSquareRotated + +IconCamera + +IconGallery + +IconVideo + +IconMicrophoneOff + +IconCode + +IconCpuBolt + +IconHome + +IconSearch + +IconMenu + +IconRefresh + +IconLayoutGrid + +IconHorizontalDots + +IconServer + +IconLaptop + +IconWheel + +IconBarChart + +IconChartSquare + +IconTrendingUp + +IconCalendar + +IconClock + +IconTrash + +IconTrashLines + +IconArchive + +IconCloudDownload + +IconCloudUpload + +IconGlobe + +IconMapPin + +IconPhone + +IconRestore + +IconSend + +IconSun + +IconMoon + +IconDroplet + +IconAward + +IconHeart + +IconBolt + +IconCoffee + +IconStar + +--- + +## Utilities / Toast + +### Toast Utility Functions + +The `toast.ts` file provides utility functions for displaying toast notifications using SweetAlert2. These functions allow you to show different types of toast messages with various configurations. + +#### Usage + +To show a basic toast message, use the `showToast` function: + +``` +import { showToast, toastSuccess, toastError, toastWarning, toastInfo } from '@codebridger/lib-vue-components/toast.ts'; + +showToast({ message: 'This is a basic toast message', variant: 'success' }); + +toastSuccess('This is a success toast message'); + +toastError('This is an error toast message'); + +toastWarning('This is a warning toast message'); + +toastInfo('This is an info toast message'); +``` + +#### Configuration + +``` +{ + render: args => ({ + components: { + Button + }, + setup() { + return { + args + }; + }, + template: '', + methods: { + showToast + } + }) +} +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| message | string | - | This is a toast message | +| variant | string | - | defaultsuccessdangerwarninginfo | +| position | string | - | top-righttop-leftbottom-rightbottom-left | +| duration | number | - | | +| showCloseButton | boolean | - | FalseTrue | +| onDismiss | function | - | - | +| containerId | string | - | | +| isRTL | boolean | - | FalseTrue | + +--- + +## Complex / Modal + +### Modal + +A flexible dialog for confirmations, forms, and rich content. Provides slots for trigger, title, default content, and footer; supports sizes, vertical alignment, and animations. + +#### Features + +- Sizes: sm, md, lg, xl, full; center/top/bottom positioning +- Animations: fade, slide, rotate, zoom (and none) +- Persistent and prevent-close modes; optional close button hiding +- Custom content and footer slots; content class passthrough + +#### Accessibility + +- Focus trapping and ESC/overlay behaviors configurable via props +- Ensure meaningful titles and keyboard operability of controls. + +#### Usage + +Use for tasks that require focused attention. Keep content concise; avoid nesting modals. + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + ` + }) +} +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| modelValue | Controls the visibility of the modal.boolean | - | FalseTrue | +| title | The title of the modal.string | "" | Modal Title | +| triggerLabel | The label for the trigger button that opens the modal.string | - | Open Modal | +| size | The size of the modal. Can be one of "sm", "md", "lg", "xl", or "full".ModalSize | "md" | smmdlgxlfull | +| animation | The animation type for the modal. Can be one of "fade", "slideDown", "slideUp", "fadeLeft", "fadeRight", "rotateLeft", "zoomIn", or "none".AnimationType | "fade" | fadeslideDownslideUpfadeLeftfadeRightrotateLeftzoomInnone | +| hideClose | If true, the close button will be hidden.boolean | false | FalseTrue | +| persistent | If true, the modal will not close when clicking outside of it.boolean | false | FalseTrue | +| preventClose | If true, the modal cannot be closed.boolean | false | FalseTrue | +| contentClass | Custom class for the content area of the modal.string | "" | | +| verticalPosition | The position of the modal on the screen."top""center""bottom" | - | topcenterbottom | +| customClass | Custom classes for different parts of the modal.ModalClass | () => ({ panel: "", overlay: "", wrapper: "", }) | | +| events | | +| update:modelValue | boolean | - | - | +| close | other | - | - | +| slots | | +| trigger | { toggleModal: unknown } | - | | +| default | { toggleModal: unknown } | - | | +| footer | { toggleModal: unknown } | - | | + +#### Stories + +##### Default + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + ` + }) +} +``` + +##### Custom Trigger + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + + + ` + }), + args: { + title: "Custom Trigger Modal" + } +} +``` + +##### With Title + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + ` + }), + args: { + title: "Custom Modal Title" + } +} +``` + +##### With Title Slot + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + + + ` + }), + args: { + // Don't set title prop when using title slot + } +} +``` + +##### Persistent + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + ` + }), + args: { + persistent: true, + title: "Persistent Modal" + } +} +``` + +##### Custom Size + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + ` + }), + args: { + size: "lg", + title: "Contact Form" + } +} +``` + +##### Custom Animation + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + ` + }), + args: { + animation: "zoomIn", + title: "Animated Modal" + } +} +``` + +##### With Footer + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + + + ` + }), + args: { + title: "Modal with Footer" + } +} +``` + +##### No Close Button + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + ` + }), + args: { + hideClose: true, + title: "Confirmation Required" + } +} +``` + +##### Small Size + +``` +{ + render: args => ({ + components: { + Modal, + Button + }, + setup() { + return { + args + }; + }, + template: ` + + + + ` + }), + args: { + size: "sm", + triggerLabel: "Delete Item" + } +} +``` + +--- + +## Complex / Pagination + +### Pagination + +``` +The Pagination component allows users to navigate through multiple pages of content. + + ## Events + - `update:modelValue`: Emitted when the current page changes (for v-model support) + - `change-page`: Emitted when the page changes, with the new page number as payload + + ## Usage + + ```vue + + + + ``` +``` + +Current Page: 1 + +- 1 / 5 + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| initialPage | Initial page numbernumber | - | | +| totalPages | Total number of pagesnumber | - | | + +#### Stories + +##### Default + +Default pagination using directly provided totalPages + +Current Page: 1 + +- 1 / 5 + +``` + +``` + +##### Last Page + +Pagination on last page + +Current Page: 9 + +- 9 / 1 + +``` + +``` + +##### Single Page + +Single page pagination + +Current Page: 1 + +- 1 / 1 + +``` + +``` + +--- + +## Elements / Avatar + +### Avatar + +Displays a user image or placeholder with configurable size and rounding. Optional presence indicator conveys online/offline state. + +#### Features + +- Sizes: xs, sm, md, lg +- Rounding: none → full +- Optional status dot (online, offline, away, busy) +- Dark mode and RTL-aware spacing + +#### Accessibility + +- Always provide a meaningful alt describing the person/content shown. + +#### Usage + +Use in lists, headers, and cards. Combine with AvatarGroup to show multiple participants. + +![User avatar](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| rounded | Border radius of the avatar"full""none""xs""sm""md""lg""xl" | "full" | Choose option...nonexssmmdlgxlfull | +| size | Size of the avatar"xs""sm""md""lg" | - | Choose option...xssmmdlg | +| showStatus | Whether to display the status indicatorboolean | false | FalseTrue | +| status | Current status of the user"online""offline""away""busy" | "online" | Choose option...onlineofflineawaybusy | +| disabled | Whether the avatar is in a disabled stateboolean | false | FalseTrue | +| src* | Image source URL for the avatarstring | | https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg | +| alt | Alternative text for accessibilitystring | - | User avatar | +| slots | | +| status-icon | other | - | | + +#### Stories + +##### Default + +![User avatar](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` + +``` + +##### With Online Status + +![User avatar](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` + +``` + +##### With Offline Status + +![User avatar](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` + +``` + +##### With Away Status + +![User avatar](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` + +``` + +##### With Busy Status + +![User avatar](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` + +``` + +##### Square Avatar + +![User avatar](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` + +``` + +##### Slightly Rounded Avatar + +![User avatar](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` + +``` + +##### Fully Rounded Avatar + +![User avatar](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` + +``` + +--- + +## Elements / Avatargroup + +### AvatarGroup + +#### A container component for grouping multiple avatars + +Groups multiple Avatar components with an overlapping layout to indicate participants or teams. + +#### Features + +- Automatic spacing/overlap with RTL support +- Optional hover animations +- Works with any Avatar sizes and rounding + +#### Accessibility + +- Ensure each avatar has an informative alt text; the group itself should be labeled when used as a control. + +#### Usage + +Use to summarize membership, commenters, or assignees; link the group to a details view when appropriate. + +![User 1](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 2](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 3](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` +{ + parameters: { + docs: { + description: { + story: "Standard implementation of the avatar group with three members." + } + } + }, + render: args => ({ + components: { + AvatarGroup, + Avatar + }, + setup() { + return { + avatarImages, + args + }; + }, + template: ` + + + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify avatar group renders correctly", async () => { + const avatarGroup = canvas.getByRole("group"); + expect(avatarGroup).toBeInTheDocument(); + expect(avatarGroup).toHaveClass("flex", "items-center"); + }); + await step("Verify all avatars are rendered", async () => { + const avatars = canvas.getAllByAltText(/User \d/); + expect(avatars).toHaveLength(3); + avatars.forEach((avatar, index) => { + expect(avatar).toBeInTheDocument(); + expect(avatar).toHaveAttribute("src", "https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg"); + }); + }); + } +} +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| hoverAnimation | Add animation effect when hovering avatarsboolean | - | FalseTrue | +| slots | | +| default | other | - | | + +#### Stories + +##### Default + +Standard implementation of the avatar group with three members. + +![User 1](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 2](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 3](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` +{ + parameters: { + docs: { + description: { + story: "Standard implementation of the avatar group with three members." + } + } + }, + render: args => ({ + components: { + AvatarGroup, + Avatar + }, + setup() { + return { + avatarImages, + args + }; + }, + template: ` + + + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify avatar group renders correctly", async () => { + const avatarGroup = canvas.getByRole("group"); + expect(avatarGroup).toBeInTheDocument(); + expect(avatarGroup).toHaveClass("flex", "items-center"); + }); + await step("Verify all avatars are rendered", async () => { + const avatars = canvas.getAllByAltText(/User \d/); + expect(avatars).toHaveLength(3); + avatars.forEach((avatar, index) => { + expect(avatar).toBeInTheDocument(); + expect(avatar).toHaveAttribute("src", "https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg"); + }); + }); + } +} +``` + +##### With More Avatars + +Avatar group displaying a larger number of members to demonstrate spacing. + +![User 1](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 2](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 3](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 4](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 5](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` +{ + parameters: { + docs: { + description: { + story: "Avatar group displaying a larger number of members to demonstrate spacing." + } + } + }, + render: args => ({ + components: { + AvatarGroup, + Avatar + }, + setup() { + const extendedAvatars = [...avatarImages, { + src: "https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg", + alt: "User 4" + }, { + src: "https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg", + alt: "User 5" + }]; + return { + avatarImages: extendedAvatars, + args + }; + }, + template: ` + + + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify extended avatar group renders correctly", async () => { + const avatarGroup = canvas.getByRole("group"); + expect(avatarGroup).toBeInTheDocument(); + }); + await step("Verify all 5 avatars are rendered", async () => { + const avatars = canvas.getAllByAltText(/User \d/); + expect(avatars).toHaveLength(5); + }); + } +} +``` + +##### Animate X + +Avatar group with Animate X. + +![User 1](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 2](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 3](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` +{ + parameters: { + docs: { + description: { + story: "Avatar group with Animate X." + } + } + }, + args: { + hoverAnimation: true + }, + render: args => ({ + components: { + AvatarGroup, + Avatar + }, + setup() { + return { + avatarImages, + args + }; + }, + template: ` +
+ + + +
+ ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify animated avatar group renders correctly", async () => { + const avatarGroup = canvas.getByRole("group"); + expect(avatarGroup).toBeInTheDocument(); + }); + await step("Verify hover animation classes are applied", async () => { + const avatars = canvas.getAllByAltText(/User \d/); + avatars.forEach(avatar => { + const avatarContainer = avatar.parentElement; + expect(avatarContainer).toHaveClass("transition-all", "duration-300", "hover:translate-x-2"); + }); + }); + } +} +``` + +##### RTL Support + +Avatar group with RTL (Right-to-Left) layout support enabled. + +![User 1](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 2](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +![User 3](https://html.vristo.sbthemes.com/assets/images/profile-12.jpeg) + +``` +{ + parameters: { + docs: { + description: { + story: "Avatar group with RTL (Right-to-Left) layout support enabled." + } + } + }, + render: args => ({ + components: { + AvatarGroup, + Avatar + }, + setup() { + return { + avatarImages, + args + }; + }, + template: ` +
+ + + +
+ ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify RTL avatar group renders correctly", async () => { + const rtlContainer = canvas.getByRole("group").parentElement; + expect(rtlContainer).toHaveAttribute("dir", "rtl"); + const avatarGroup = canvas.getByRole("group"); + expect(avatarGroup).toBeInTheDocument(); + }); + await step("Verify all avatars are rendered in RTL context", async () => { + const avatars = canvas.getAllByAltText(/User \d/); + expect(avatars).toHaveLength(3); + }); + } +} +``` + +--- + +## Elements / Button + +### Button + +A flexible, accessible button with rich visual variants and behaviors. Use it for primary and secondary actions, icon-only actions, links, and async/loading flows. + +#### Features + +- Color themes: default, primary, info, success, warning, danger, secondary, dark, gradient +- Sizes: xs, sm, md, lg; block layout and rounded radii +- Outline, shadow, and border styles (solid, dashed, dotted) +- Loading state with customizable spinner icon +- Icon support before/after label; icon-only usage works too +- Link mode via the to prop for navigation +- Optional chip mode: adds a close icon and emits the chip-click event + +#### Accessibility + +- Renders semantic button or link depending on props +- Keyboard-focus styles; loading and disabled states are visually communicated + +#### Usage + +Wrap actions, confirm flows, and toolbar icons. Prefer meaningful labels; use icons to reinforce meaning, not replace it. + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| block | boolean | false | FalseTrue | +| outline | boolean | false | FalseTrue | +| shadow | boolean | false | FalseTrue | +| isLoading | boolean | false | FalseTrue | +| borderType | Border type"solid""dashed""dotted" | "solid" | soliddasheddotted | +| loadingIcon | You can insert the Icon's name from here or add your icons."IconLoader""IconRefresh""IconRestore"string | "IconLoader" | IconLoader | +| label | string | - | Button | +| textTransform | "normal-case""capitalize""lowercase""uppercase" | "normal-case" | Choose option...normal-casecapitalizelowercaseuppercase | +| color | "primary""info""success""warning""danger""secondary""dark""gradient" | - | Choose option...defaultprimaryinfosuccesswarningdangersecondarydarkgradient | +| size | "xs""sm""md""lg" | - | Choose option...xssmmdlg | +| rounded | "full""none""xs""sm""md""lg""xl" | - | Choose option...fullnonexssmmdlgxl | +| disabled | boolean | - | | +| to | URL path for link functionalitystring | - | | +| iconName | Icon name to displaystring | - | | +| iconClass | Additional classes for the iconstring | - | | +| chip | Enable chip mode (close icon)boolean | false | | +| events | | +| click | other | - | - | +| chip-click | other | - | - | +| slots | | +| icon | other | - | | +| default | other | - | | + +#### Stories + +##### Default + +``` + +``` + +##### Rounded + +``` + +``` + +##### Outline + +``` + +``` + +##### Loading + +``` + +``` + +##### Size + +``` + +``` + +##### Shadow + +``` + +``` + +##### As Link + +``` + +``` + +##### With Icon + +``` + +``` + +##### Disabled + +``` + +``` + +##### Gradient Borders + +A single gradient border button demonstrating the gradient outline styling with dashed border, medium size, and rounded corners. + +``` + +``` + +##### Interactive Button + +``` + +``` + +##### Form Button + +``` +{ + render: () => ({ + components: { + Button + }, + template: ` +
+
+ + + +
+
+ `, + setup() { + const isLoading = ref(false); + const handleClick = () => { + isLoading.value = true; + setTimeout(() => { + isLoading.value = false; + }, 2000); + }; + const handleSubmit = () => { + console.log("Form submitted"); + }; + return { + isLoading, + handleClick, + handleSubmit + }; + } + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Fill out form fields", async () => { + const emailInput = canvas.getByPlaceholderText(/email/i); + const passwordInput = canvas.getByPlaceholderText(/password/i); + await userEvent.type(emailInput, "test@example.com"); + await userEvent.type(passwordInput, "password123"); + expect(emailInput).toHaveValue("test@example.com"); + expect(passwordInput).toHaveValue("password123"); + }); + await step("Submit form and verify loading state", async () => { + const submitButton = canvas.getByRole("button", { + name: /submit form/i + }); + await userEvent.click(submitButton); + + // Button should show loading state + expect(canvas.getByRole("button", { + name: /submitting/i + })).toBeInTheDocument(); + }); + } +} +``` + +##### Chip + +Button clicks: 0 + +Chip clicks: 0 + +``` + +``` + +--- + +## Elements / Card + +### Card + +A versatile Card component that serves as a container for content with consistent styling. The component features: + +- Automatic dark mode support +- Consistent shadow and border styling +- Disabled state propagation to child components +- Full TypeScript support +- Tailwind CSS integration + +#### Usage + +The Card component accepts a default slot that receives the cardDisabled state: + +``` + + + +``` + +#### Styling + +The card uses Tailwind CSS with: + +- Light/dark mode support +- Configurable shadow and border +- Consistent padding +- Opacity changes for disabled state + +##### Default Card + +This is a default card with some example content. + +``` +{ + render: args => ({ + components: { + Card + }, + template: ` + + + + `, + setup() { + return { + args + }; + } + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify default card renders correctly", async () => { + const card = canvas.getByText("Default Card").closest("div").parentElement; + expect(card).toBeInTheDocument(); + expect(card).toHaveClass("bg-white", "shadow-[4px_6px_10px_-3px_#bfc9d4]", "dark:bg-[#191e3a]", "border", "border-[#e0e6ed]", "dark:border-[#1b2e4b]"); + }); + await step("Verify card content is displayed", async () => { + const title = canvas.getByText("Default Card"); + const content = canvas.getByText("This is a default card with some example content."); + expect(title).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); + } +} +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| disabled | Disables the card and its child interactive elementsboolean | false | FalseTrue | +| slots | | +| default | { cardDisabled: unknown } | - | | + +#### Stories + +##### Default + +##### Default Card + +This is a default card with some example content. + +``` +{ + render: args => ({ + components: { + Card + }, + template: ` + + + + `, + setup() { + return { + args + }; + } + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify default card renders correctly", async () => { + const card = canvas.getByText("Default Card").closest("div").parentElement; + expect(card).toBeInTheDocument(); + expect(card).toHaveClass("bg-white", "shadow-[4px_6px_10px_-3px_#bfc9d4]", "dark:bg-[#191e3a]", "border", "border-[#e0e6ed]", "dark:border-[#1b2e4b]"); + }); + await step("Verify card content is displayed", async () => { + const title = canvas.getByText("Default Card"); + const content = canvas.getByText("This is a default card with some example content."); + expect(title).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); + } +} +``` + +##### Card With Input + +A disabled card with input components that can be disabled together. +The first input is getting disabled by ancestor card component, the second input is disabled by itself. + +##### Card with Input + +Email Input + +Number Input + +``` +{ + args: { + disabled: true + }, + render: args => ({ + components: { + Card, + Input + }, + template: ` + + + + `, + setup() { + return { + args + }; + } + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify disabled card renders correctly", async () => { + const card = canvas.getByText("Card with Input").closest("div").parentElement; + expect(card).toBeInTheDocument(); + expect(card).toHaveClass("opacity-50"); + }); + await step("Verify inputs are rendered", async () => { + const emailInput = canvas.getByPlaceholderText("Enter your email"); + const numberInput = canvas.getByPlaceholderText("Enter a number"); + expect(emailInput).toBeInTheDocument(); + expect(numberInput).toBeInTheDocument(); + }); + await step("Verify disabled input is properly disabled", async () => { + const numberInput = canvas.getByPlaceholderText("Enter a number"); + expect(numberInput).toBeDisabled(); + }); + }, + parameters: { + docs: { + description: { + story: `A disabled card with input components that can be disabled together. +
The first input is getting disabled by ancestor card component, the second input is disabled by itself.` + } + } + } +} +``` + +##### Custom Class Card + +A card with custom classes for additional styling + +##### Card with Custom Classes + +This card uses additional flex classes for layout. + +``` +{ + render: args => ({ + components: { + Card + }, + template: ` + + + + `, + setup() { + return { + args + }; + } + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify custom class card renders correctly", async () => { + const card = canvas.getByText("Card with Custom Classes").closest("div").parentElement; + expect(card).toBeInTheDocument(); + expect(card).toHaveClass("flex", "items-center", "justify-start"); + }); + await step("Verify custom content is displayed", async () => { + const title = canvas.getByText("Card with Custom Classes"); + const content = canvas.getByText("This card uses additional flex classes for layout."); + expect(title).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: "A card with custom classes for additional styling" + } + } + } +} +``` + +##### Disabled Card + +A card in a disabled state with reduced opacity and disabled interactive elements + +##### Disabled Card + +Disabled Input + +``` +{ + args: { + disabled: true + }, + render: args => ({ + components: { + Card, + Input + }, + template: ` + + + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify disabled card renders correctly", async () => { + const card = canvas.getByText("Disabled Card").closest("div").parentElement; + expect(card).toBeInTheDocument(); + }); + await step("Verify disabled input and button", async () => { + const input = canvas.getByPlaceholderText("This input is disabled"); + const button = canvas.getByRole("button", { + name: "Disabled Button" + }); + expect(input).toBeInTheDocument(); + expect(button).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: "A card in a disabled state with reduced opacity and disabled interactive elements" + } + } + } +} +``` + +##### Multiple Interactive Elements + +A card with multiple interactive elements that can be disabled together + +##### Interactive Elements + +Text Input + +Option 1Option 2 + +``` +{ + render: args => ({ + components: { + Card, + Input + }, + template: ` + + + + `, + setup() { + return { + args + }; + } + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify card with multiple interactive elements", async () => { + const card = canvas.getByText("Interactive Elements").closest("div").parentElement; + expect(card).toBeInTheDocument(); + }); + await step("Verify all interactive elements are present", async () => { + const input = canvas.getByPlaceholderText("Enter text"); + const select = canvas.getByRole("combobox"); + const button = canvas.getByRole("button", { + name: "Perform Action" + }); + expect(input).toBeInTheDocument(); + expect(select).toBeInTheDocument(); + expect(button).toBeInTheDocument(); + }); + await step("Test interactive elements functionality", async () => { + const input = canvas.getByPlaceholderText("Enter text"); + const select = canvas.getByRole("combobox"); + const button = canvas.getByRole("button", { + name: "Perform Action" + }); + await userEvent.type(input, "test input"); + expect(input).toHaveValue("test input"); + await userEvent.selectOptions(select, "Option 2"); + expect(select).toHaveValue("Option 2"); + await userEvent.click(button); + expect(button).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: "A card with multiple interactive elements that can be disabled together" + } + } + } +} +``` + +--- + +## Elements / Dropdown + +### Dropdown + +Contextual menu/popover for secondary actions. Provides trigger slot and body slot, positioning via Popper, and rich interaction modes. + +#### Features + +- Placement options with offsets; optional arrow +- Click and hover triggers; interactive content support +- Locking, z-index control, delays, and click-away behavior +- RTL and dark mode aware styles + +#### Accessibility + +- Trigger is a standard control; body content should be keyboard navigable. Manage focus when opening/closing. + +#### Usage + +Use for menus, quick filters, and small forms. Keep actions concise and avoid deep nesting. + +- Action +- Another action +- Something else here +- Separated link + +``` +{ + render: args => ({ + components: { + Dropdown, + Button, + Icon, + DropdownItem + }, + setup() { + return { + args, + triggerText: "Action" + }; + }, + template: ` + + + +` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify dropdown trigger renders correctly", async () => { + const trigger = canvas.getByRole("button"); + expect(trigger).toBeInTheDocument(); + }); + await step("Test dropdown interaction", async () => { + const trigger = canvas.getByRole("button"); + await userEvent.click(trigger); + + // Check if dropdown items are present + const actionItem = canvas.getByText("Action"); + expect(actionItem).toBeInTheDocument(); + }); + } +} +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| triggerText | Text for the trigger elementstring | - | Dropdown | +| placement | Preferred placement of the PopperPlacement | "bottom-end" | Choose option...autoauto-startauto-endtoptop-starttop-endbottombottom-startbottom-endrightright-startright-endleftleft-startleft-end | +| offsetDistance | Offset in pixels away from the trigger elementnumber | 0 | | +| offsetSkid | Offset in pixels along the trigger elementnumber | 0 | | +| hover | Trigger the Popper on hoverboolean | false | FalseTrue | +| disabled | Disables the Popper. If it was already open, it will be closed.boolean | false | FalseTrue | +| interactive | If the Popper should be interactive, it will close when clicked/hovered if falseboolean | true | FalseTrue | +| arrow | Display an arrow on the Popperboolean | false | FalseTrue | +| locked | Lock the Popper into place, it will not flip dynamically when it runs out of space if this is set to trueboolean | false | FalseTrue | +| zIndex | The z-index of the Poppernumberstring | 9999 | | +| arrowPadding | Stop arrow from reaching the edge of the Popper (in pixels)number | 0 | | +| closeDelay | Close the Popper after a delay (ms)numberstring | 0 | | +| openDelay | Open the Popper after a delay (ms)numberstring | 0 | | +| disableClickAway | Disables automatically closing the Popper when the user clicks away from itboolean | false | FalseTrue | +| show | Control the Popper manually, other events (click, hover) are ignored if this is set to true/falsebooleannull | - | FalseTrue | +| bodyWrapperClass | Class to apply to the body wrapperstring | - | | +| triggerClass | Class to apply to the trigger elementstring | - | | +| events | | +| open:popper | other | - | - | +| close:popper | other | - | - | +| slots | | +| trigger | Trigger slot{ isDisabled: unknown } | - | | +| body | Body slot{ close: unknown; isOpen: unknown } | - | | + +#### Stories + +##### Default + +- Action +- Another action +- Something else here +- Separated link + +``` +{ + render: args => ({ + components: { + Dropdown, + Button, + Icon, + DropdownItem + }, + setup() { + return { + args, + triggerText: "Action" + }; + }, + template: ` + + + +` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify dropdown trigger renders correctly", async () => { + const trigger = canvas.getByRole("button"); + expect(trigger).toBeInTheDocument(); + }); + await step("Test dropdown interaction", async () => { + const trigger = canvas.getByRole("button"); + await userEvent.click(trigger); + + // Check if dropdown items are present + const actionItem = canvas.getByText("Action"); + expect(actionItem).toBeInTheDocument(); + }); + } +} +``` + +##### Profile Menu + +![](http://localhost:6006/assets/user-profile-4f75ed46.jpeg) + +- ![](http://localhost:6006/assets/user-profile-4f75ed46.jpeg) + + ###### John DoePro + + johndoe@gmail.com + +- Profile +- Inbox +- Lock Screen +- Sign Out + +``` +{ + parameters: { + docs: { + story: { + height: "500px" + } + } + }, + render(args) { + return { + components: { + Dropdown, + IconButton, + Icon, + DropdownItem + }, + setup() { + return { + args, + userProfilePicUrl + }; + }, + template: ` + + + + + + ` + }; + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify profile menu trigger renders correctly", async () => { + const trigger = canvas.getByRole("img"); + expect(trigger).toBeInTheDocument(); + }); + await step("Test profile menu interaction", async () => { + const trigger = canvas.getByRole("img"); + await userEvent.click(trigger); + + // Check if profile menu items are present + const profileItem = canvas.getByText("Profile"); + const inboxItem = canvas.getByText("Inbox"); + expect(profileItem).toBeInTheDocument(); + expect(inboxItem).toBeInTheDocument(); + }); + } +} +``` + +##### Hover Trigger + +- Action +- Another action +- Something else here +- Separated link + +``` +{ + args: { + hover: true, + placement: "bottom-start" + }, + render: args => ({ + components: { + Dropdown, + Button, + Icon, + DropdownItem + }, + setup() { + return { + args, + triggerText: "Hover Me" + }; + }, + template: ` + + + +` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify hover trigger renders correctly", async () => { + const trigger = canvas.getByRole("button"); + expect(trigger).toBeInTheDocument(); + }); + await step("Test hover trigger interaction", async () => { + const trigger = canvas.getByRole("button"); + await userEvent.hover(trigger); + + // Check if dropdown items are present after hover + const actionItem = canvas.getByText("Action"); + expect(actionItem).toBeInTheDocument(); + }); + } +} +``` + +##### With Arrow + +- Action +- Another action +- Something else here +- Separated link + +``` +{ + args: { + arrow: true, + offsetDistance: 12 + }, + render: args => ({ + components: { + Dropdown, + Button, + Icon, + DropdownItem + }, + setup() { + return { + args, + triggerText: "With Arrow" + }; + }, + template: ` + + + +` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify dropdown with arrow renders correctly", async () => { + const trigger = canvas.getByRole("button"); + expect(trigger).toBeInTheDocument(); + }); + await step("Test dropdown with arrow interaction", async () => { + const trigger = canvas.getByRole("button"); + await userEvent.click(trigger); + + // Check if dropdown items are present + const actionItem = canvas.getByText("Action"); + expect(actionItem).toBeInTheDocument(); + }); + } +} +``` + +##### Custom Offset + +- Action +- Another action +- Something else here +- Separated link + +``` +{ + args: { + offsetDistance: 20, + offsetSkid: 10 + }, + render: args => ({ + components: { + Dropdown, + Button, + Icon, + DropdownItem + }, + setup() { + return { + args, + triggerText: "Custom Offset" + }; + }, + template: ` + + + +` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify custom offset dropdown renders correctly", async () => { + const trigger = canvas.getByRole("button"); + expect(trigger).toBeInTheDocument(); + }); + await step("Test custom offset dropdown interaction", async () => { + const trigger = canvas.getByRole("button"); + await userEvent.click(trigger); + + // Check if dropdown items are present + const actionItem = canvas.getByText("Action"); + expect(actionItem).toBeInTheDocument(); + }); + } +} +``` + +##### Disabled + +- Action +- Another action +- Something else here +- Separated link + +``` +{ + args: { + disabled: true + }, + render: args => ({ + components: { + Dropdown, + Button, + Icon, + DropdownItem + }, + setup() { + return { + args, + triggerText: "Disabled" + }; + }, + template: ` + + + +` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify disabled dropdown renders correctly", async () => { + const trigger = canvas.getByRole("button"); + expect(trigger).toBeInTheDocument(); + }); + await step("Test disabled dropdown behavior", async () => { + const trigger = canvas.getByRole("button"); + await userEvent.click(trigger); + + // Disabled dropdown might still show items, so we just verify the trigger exists + expect(trigger).toBeInTheDocument(); + }); + } +} +``` + +##### Interactive Content + +``` +{ + args: { + interactive: true + // offsetDistance: "8", + }, + render: args => ({ + components: { + Dropdown, + Button, + Icon, + DropdownItem + }, + setup() { + return { + args + }; + }, + template: ` + + + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify interactive dropdown renders correctly", async () => { + const trigger = canvas.getByRole("button"); + expect(trigger).toBeInTheDocument(); + }); + await step("Test interactive dropdown content", async () => { + const trigger = canvas.getByRole("button"); + await userEvent.click(trigger); + + // Check if interactive content is present + const input = canvas.getByPlaceholderText("Type something..."); + const submitButton = canvas.getByRole("button", { + name: "Submit" + }); + expect(input).toBeInTheDocument(); + expect(submitButton).toBeInTheDocument(); + }); + } +} +``` + +##### RTL Support + +- العنصر الأول +- العنصر الثاني +- العنصر الثالث + +``` +{ + args: { + triggerText: "قائمة منسدلة", + placement: "bottom-start" + }, + render: args => ({ + components: { + Dropdown, + Button, + Icon, + DropdownItem + }, + setup() { + return { + args + }; + }, + template: ` +
+ + + +
+ ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify RTL dropdown renders correctly", async () => { + const trigger = canvas.getByRole("button"); + expect(trigger).toBeInTheDocument(); + }); + await step("Test RTL dropdown interaction", async () => { + const trigger = canvas.getByRole("button"); + await userEvent.click(trigger); + + // Check if RTL dropdown items are present + const firstItem = canvas.getByText("العنصر الأول"); + expect(firstItem).toBeInTheDocument(); + }); + } +} +``` + +--- + +## Elements / Iconbutton + +### IconButton + +A compact, versatile button optimized for icons or avatars. Works as a clickable control by default and as a decorative badge when badge is true. + +#### Features + +- Color themes and rounded radii for circular or rounded styles +- Sizes: xs, sm, md, lg, xl +- Loading state with customizable spinner icon +- Disabled state; optional badge (non-interactive) mode +- Supports either an icon name or an image via imgUrl + +#### Accessibility + +- Focusable and keyboard operable when interactive +- Loading/disabled states use non-pointer cursors to signal non-interactivity + +#### Usage + +Use for toolbar actions, quick affordances, and avatars. Prefer tooltips or aria-labels to convey meaning for icon-only buttons. + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| color | Color theme for the icon button"default""primary""info""success""warning""danger""secondary""dark" | "default" | Choose option...defaultprimaryinfosuccesswarningdangersecondarydarkgradient | +| size | Visual size of the inner icon or image"xs""sm""md""lg""xl" | - | Choose option...xssmmdlgxl | +| isLoading | Shows a loading spinner when trueboolean | false | FalseTrue | +| loadingIcon | Icon to show when loading"IconLoader""IconRefresh""IconRestore"string | "IconLoader" | Choose option...IconLoaderIconRefreshIconRestore | +| disabled | Disables the buttonboolean | - | FalseTrue | +| badge | Enable badge mode (non-interactive): no click events and default cursorboolean | false | FalseTrue | +| rounded | Border radius size"full""none""xs""sm""md""lg""xl" | "full" | Choose option...fullnonexssmmdlgxl | +| icon | Icon name to render from the icon setstring | - | IconSun | +| imgUrl | string | - | | +| label | Label text to display when in badge modestring | - | | +| events | | +| click | other | - | - | +| slots | | +| default | other | - | | + +#### Stories + +##### Default + +Interactive usage. Emits `click` and shows a pointer cursor when not disabled/loading. + +``` + +``` + +##### Loading + +Shows the built-in spinner and is non-interactive while loading. + +``` + +``` + +##### Loading With Custom Icon + +Customize the spinner icon via the `loadingIcon` prop. + +``` + +``` + +##### With Images + +Pass `imgUrl` to render an image instead of an icon. Works in both interactive and badge modes. + +![](/assets/images/user-profile.jpeg) + +``` + +``` + +##### Disabled + +Disabled state is non-interactive and shows a not-allowed cursor. + +``` + +``` + +##### Color Variants + +Preview of all color variants. + +##### IconButton Color Variants + +Default + +Primary + +Info + +Success + +Warning + +Danger + +Secondary + +Dark + +Gradient + +``` + +``` + +##### Toolbar Example + +Example layout with multiple IconButtons. + +##### Toolbar Example + +Different colored IconButtons in a practical toolbar context + +``` + +``` + +##### Badge + +Badge mode: decorative only. Shows default cursor and does not emit `click`. + +``` + +``` + +##### Badge Variants + +Multiple badge color examples. + +##### IconButton Badge Variants + +Primary Badge + +Success Badge + +Warning Badge + +Danger Badge + +Info Badge + +Secondary Badge + +Badge mode removes click functionality and shows default cursor + +``` + +``` + +##### Badge Label Sizes + +Demonstrates how labels scale with different badge sizes. + +##### IconButton Badge with Labels - Size Variants + +New + +XS + +Hot + +SM + +Featured + +MD + +Trending + +LG + +Popular + +XL + +Badge mode with labels at different sizes - note how label text scales with icon size + +``` + +``` + +##### Badge With Labels Variants + +Multiple badge color examples with labels. + +##### IconButton Badge with Labels - Color Variants + +Premium + +Primary + +Verified + +Success + +Warning + +Warning + +Blocked + +Danger + +Info + +Info + +Settings + +Secondary + +Badge mode with labels in different colors - perfect for status indicators and tags + +``` + +``` + +--- + +## Elements / Progress + +### Progress + +A versatile progress bar component with a clean, modern design. + +##### Features + +- Multiple sizes (xs, sm, md, lg, xl) +- Different border radius options +- Built-in RTL support using Tailwind's RTL utilities +- Dark mode support with smooth transitions +- Interactive hover and active states +- Animated progress and striped effects +- Accessible with ARIA attributes +- Labels with customizable text + +##### Usage + +``` + +``` + +##### RTL Support + +The component uses Tailwind's RTL utilities for bidirectional support: + +- `ltr:origin-left rtl:origin-right` for proper transform origins +- CSS custom properties for RTL-aware animations +- Automatic support in RTL contexts (no extra props needed) + +##### Props + +- `value`: The current progress value (number) +- `max`: The maximum progress value (number, default: 100) +- `size`: Size of the progress bar (default, sm, md, lg, xl) +- `rounded`: Whether to show a rounded progress bar (boolean) +- `classes`: Custom CSS classes for wrapper and progress elements +- `striped`: Whether to show a striped pattern (boolean) +- `animated`: Whether to animate the progress bar (boolean) +- `showLabel`: Whether to show a label inside the progress bar (boolean) +- `label`: Custom label text (string, defaults to percentage) + +##### Accessibility + +The component includes proper ARIA attributes and follows accessibility best practices: + +- `role="progressbar"` +- `aria-valuenow`: Current progress value +- `aria-valuemax`: Maximum progress value +- Automatic RTL support through HTML `dir` attribute +- Smooth transitions for visual changes + +##### Interactions & Animations + +The component includes several interactive features: + +1. Hover effect: Slight brightness increase +2. Active state: Subtle scale reduction +3. Smooth transitions for: + - Progress value changes + - Theme switching + - Size changes + - Color changes + +##### Best Practices + +1. Use appropriate sizes based on context +2. Set the correct `dir` attribute on a parent container for RTL support +3. Ensure proper color contrast in both light and dark themes +4. Use animations judiciously to avoid overwhelming users +5. Provide clear labels for important progress indicators + +``` +{ + args: { + value: 50, + max: 100 + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify progress bar renders correctly", async () => { + const progressBar = canvas.getByRole("progressbar"); + expect(progressBar).toBeInTheDocument(); + expect(progressBar).toHaveAttribute("aria-valuenow", "50"); + expect(progressBar).toHaveAttribute("aria-valuemax", "100"); + }); + } +} +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| value | The current progress valuenumber | 50 | 050 / 100 | +| max | The maximum progress valuenumber | 100 | | +| color | The color of the progress bar"primary""info""success""warning""danger""secondary""dark""gradient" | "primary" | Choose option...primaryinfosuccesswarningdangersecondarydark | +| size | The size of the progress bar"default""sm""md""lg""xl" | "default" | Choose option...defaultsmmdlgxl | +| rounded | The border radius of the progress barboolean | true | FalseTrue | +| striped | Whether to show a striped patternboolean | false | FalseTrue | +| animated | Whether to animate the progress barboolean | false | FalseTrue | +| showLabel | Whether to show a label inside the progress barboolean | false | FalseTrue | +| label | Custom label text (defaults to percentage)string | "" | | +| classes | Custom CSS classes for wrapper and progress elements{ /** * CSS classes to apply to the wrapper element. */ wrapper?: string \| string[]; /** * CSS classes to apply to the progress element. */ progress?: string \| string[]; } | - | | + +#### Stories + +##### Default + +``` +{ + args: { + value: 50, + max: 100 + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify progress bar renders correctly", async () => { + const progressBar = canvas.getByRole("progressbar"); + expect(progressBar).toBeInTheDocument(); + expect(progressBar).toHaveAttribute("aria-valuenow", "50"); + expect(progressBar).toHaveAttribute("aria-valuemax", "100"); + }); + } +} +``` + +##### Progress Examples + +##### Progress Examples + +0% + +25% + +50% + +75% + +100% + +``` +{ + render: () => ({ + components: { + Progress + }, + template: ` +
+
+

Progress Examples

+
+ + + + + +
+
+
+ ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify all progress bars are rendered", async () => { + const progressBars = canvas.getAllByRole("progressbar"); + expect(progressBars).toHaveLength(5); + }); + await step("Verify progress values are correct", async () => { + const progressBars = canvas.getAllByRole("progressbar"); + expect(progressBars[0]).toHaveAttribute("aria-valuenow", "0"); + expect(progressBars[1]).toHaveAttribute("aria-valuenow", "25"); + expect(progressBars[2]).toHaveAttribute("aria-valuenow", "50"); + expect(progressBars[3]).toHaveAttribute("aria-valuenow", "75"); + expect(progressBars[4]).toHaveAttribute("aria-valuenow", "100"); + }); + } +} +``` + +##### Sizes + +``` +{ + render: () => ({ + components: { + Progress + }, + template: ` +
+ + + + + +
+ ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify all size variants are rendered", async () => { + const progressBars = canvas.getAllByRole("progressbar"); + expect(progressBars).toHaveLength(5); + }); + await step("Verify all progress bars have correct value", async () => { + const progressBars = canvas.getAllByRole("progressbar"); + progressBars.forEach(bar => { + expect(bar).toHaveAttribute("aria-valuenow", "50"); + }); + }); + } +} +``` + +##### Striped Animated + +25% + +50% + +75% + +100% + +``` +{ + render: () => ({ + components: { + Progress + }, + template: ` +
+ + + + +
+ ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify all animated progress bars are rendered", async () => { + const progressBars = canvas.getAllByRole("progressbar"); + expect(progressBars).toHaveLength(4); + }); + await step("Verify progress values are correct", async () => { + const progressBars = canvas.getAllByRole("progressbar"); + expect(progressBars[0]).toHaveAttribute("aria-valuenow", "25"); + expect(progressBars[1]).toHaveAttribute("aria-valuenow", "50"); + expect(progressBars[2]).toHaveAttribute("aria-valuenow", "75"); + expect(progressBars[3]).toHaveAttribute("aria-valuenow", "100"); + }); + } +} +``` + +--- + +## Elements / Tabs + +### Tabs + +### Tabs Component + +A flexible and customizable tab navigation component built with Vue 3, TypeScript, and Tailwind CSS. The Tabs component provides an intuitive interface for organizing content into separate, easily accessible sections. + +#### Features + +- **Icon Support**: Each tab can include an optional icon displayed alongside the label +- **Disabled State**: Tabs can be marked as disabled to prevent user interaction +- **Custom Styling**: Customizable container classes for different styling needs +- **Slot-Based Content**: Use slots to provide custom content for each tab +- **Dark Mode Support**: Built-in styling for both light and dark themes +- **v-model Support**: Uses Vue 3's v-model for two-way binding of the active tab + +#### Usage + +``` + + + +``` + +- Home +- Profile +- Contact +- Disabled + +###### We move your world! + +Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +![profile](https://html.vristo.sbthemes.com/assets/images/profile-34.jpeg) + +###### Media heading + +Cras sit amet nibh libero, in gravida nulla. Nulla vel metus scelerisque ante sollicitudin. Cras purus odio, vestibulum in vulputate at, tempus viverra turpis. Fusce condimentum nunc ac nisi vulputate fringilla. Donec lacinia congue felis in faucibus. + +Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + +``` +{ + args: { + modelValue: "home" + }, + render: args => ({ + components: { + Tabs + }, + setup() { + const activeTab = ref(args.modelValue); + const onTabChange = (tabId: string) => { + activeTab.value = tabId; + console.log("Tab changed to:", tabId); + }; + return { + args, + activeTab, + onTabChange + }; + }, + template: ` + + + + + + + + + + + + + + + + + ` + }), + parameters: { + docs: { + description: { + story: "Default tabs with icons and custom content for each tab." + } + } + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify tabs component renders correctly", async () => { + const tabsContainer = canvas.getByRole("list"); + expect(tabsContainer).toBeInTheDocument(); + }); + await step("Verify tab links are rendered", async () => { + const tabLinks = canvas.getAllByRole("link"); + expect(tabLinks).toHaveLength(4); + }); + await step("Verify default active tab", async () => { + const homeTab = canvas.getByRole("link", { + name: /home/i + }); + expect(homeTab).toBeInTheDocument(); + }); + await step("Test tab switching", async () => { + const profileTab = canvas.getByRole("link", { + name: /profile/i + }); + await userEvent.click(profileTab); + expect(profileTab).toBeInTheDocument(); + }); + } +} +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| modelValue* | ID of the currently active tab (v-model)string | - | home | +| containerClass | Additional CSS classes for the tabs containerstring | "mb-5" | mb-5 | +| tabs* | Array of tab items with id, label, content and optional icon and disabled propertiesTabItem[] | - | tabs : [0 : {...} 3 keys1 : {...} 3 keys2 : {...} 3 keys3 : {...} 3 keys] | +| events | | +| update:modelValue | string | - | - | +| onUpdate:modelValue | Event emitted when the active tab changesstring | - | - | +| slots | | +| `icon-${tab.id}` | { name: unknown } | - | | +| `content-${tab.id}` | { name: unknown } | - | | + +#### Stories + +##### Default + +Default tabs with icons and custom content for each tab. + +- Home +- Profile +- Contact +- Disabled + +###### We move your world! + +Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +![profile](https://html.vristo.sbthemes.com/assets/images/profile-34.jpeg) + +###### Media heading + +Cras sit amet nibh libero, in gravida nulla. Nulla vel metus scelerisque ante sollicitudin. Cras purus odio, vestibulum in vulputate at, tempus viverra turpis. Fusce condimentum nunc ac nisi vulputate fringilla. Donec lacinia congue felis in faucibus. + +Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + +``` +{ + args: { + modelValue: "home" + }, + render: args => ({ + components: { + Tabs + }, + setup() { + const activeTab = ref(args.modelValue); + const onTabChange = (tabId: string) => { + activeTab.value = tabId; + console.log("Tab changed to:", tabId); + }; + return { + args, + activeTab, + onTabChange + }; + }, + template: ` + + + + + + + + + + + + + + + + + ` + }), + parameters: { + docs: { + description: { + story: "Default tabs with icons and custom content for each tab." + } + } + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify tabs component renders correctly", async () => { + const tabsContainer = canvas.getByRole("list"); + expect(tabsContainer).toBeInTheDocument(); + }); + await step("Verify tab links are rendered", async () => { + const tabLinks = canvas.getAllByRole("link"); + expect(tabLinks).toHaveLength(4); + }); + await step("Verify default active tab", async () => { + const homeTab = canvas.getByRole("link", { + name: /home/i + }); + expect(homeTab).toBeInTheDocument(); + }); + await step("Test tab switching", async () => { + const profileTab = canvas.getByRole("link", { + name: /profile/i + }); + await userEvent.click(profileTab); + expect(profileTab).toBeInTheDocument(); + }); + } +} +``` + +##### No Icons + +Simple tabs without icons, using the content property from tab items. + +- Tab 1 +- Tab 2 +- Tab 3 + +Content for Tab 1 + +Content for Tab 2 + +Content for Tab 3 + +``` +{ + args: { + modelValue: "tab1", + tabs: [{ + id: "tab1", + label: "Tab 1", + content: "Content for Tab 1" + }, { + id: "tab2", + label: "Tab 2", + content: "Content for Tab 2" + }, { + id: "tab3", + label: "Tab 3", + content: "Content for Tab 3" + }] + }, + render: args => ({ + components: { + Tabs + }, + setup() { + const activeTab = ref(args.modelValue); + const onTabChange = (tabId: string) => { + activeTab.value = tabId; + console.log("Tab changed to:", tabId); + }; + return { + args, + activeTab, + onTabChange + }; + }, + template: ` + + + ` + }), + parameters: { + docs: { + description: { + story: "Simple tabs without icons, using the content property from tab items." + } + } + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify tabs without icons render correctly", async () => { + const tabsContainer = canvas.getByRole("list"); + expect(tabsContainer).toBeInTheDocument(); + }); + await step("Verify tab links are rendered", async () => { + const tabLinks = canvas.getAllByRole("link"); + expect(tabLinks).toHaveLength(3); + }); + await step("Verify default active tab", async () => { + const tab1 = canvas.getByRole("link", { + name: /tab 1/i + }); + expect(tab1).toBeInTheDocument(); + }); + await step("Test tab switching", async () => { + const tab2 = canvas.getByRole("link", { + name: /tab 2/i + }); + await userEvent.click(tab2); + expect(tab2).toBeInTheDocument(); + }); + } +} +``` + +##### Custom Styles + +Tabs with custom container styling using the containerClass prop. + +- Home +- Profile +- Contact + +###### Custom Styled Tab + +This tab container has custom background styling applied via the containerClass prop. + +You can style the container to match your design system. + +The styling is applied to the entire tabs component container. + +``` +{ + args: { + modelValue: "home", + containerClass: "mb-5 bg-gray-100 p-4 rounded", + tabs: [{ + id: "home", + label: "Home", + icon: "home-icon" + }, { + id: "profile", + label: "Profile", + icon: "profile-icon" + }, { + id: "contact", + label: "Contact", + icon: "contact-icon" + }] + }, + render: args => ({ + components: { + Tabs + }, + setup() { + const activeTab = ref(args.modelValue); + const onTabChange = (tabId: string) => { + activeTab.value = tabId; + console.log("Tab changed to:", tabId); + }; + return { + args, + activeTab, + onTabChange + }; + }, + template: ` + + + + + + + + + + + + + + + + + ` + }), + parameters: { + docs: { + description: { + story: "Tabs with custom container styling using the containerClass prop." + } + } + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify custom styled tabs render correctly", async () => { + const tabsContainer = canvas.getByRole("list"); + expect(tabsContainer).toBeInTheDocument(); + }); + await step("Verify tab links are rendered", async () => { + const tabLinks = canvas.getAllByRole("link"); + expect(tabLinks).toHaveLength(3); + }); + await step("Test tab switching", async () => { + const profileTab = canvas.getByRole("link", { + name: /profile/i + }); + await userEvent.click(profileTab); + expect(profileTab).toBeInTheDocument(); + }); + } +} +``` + +##### Disabled Tab + +Example with a disabled tab that cannot be selected by the user. + +- Active Tab +- Regular Tab +- Disabled Tab + +###### Active Tab Content + +This is the content for the active tab. + +This is content for the second tab. + +This content won't be accessible because the tab is disabled. + +``` +{ + args: { + modelValue: "tab1", + tabs: [{ + id: "tab1", + label: "Active Tab" + }, { + id: "tab2", + label: "Regular Tab" + }, { + id: "tab3", + label: "Disabled Tab", + disabled: true + }] + }, + render: args => ({ + components: { + Tabs + }, + setup() { + const activeTab = ref(args.modelValue); + const onTabChange = (tabId: string) => { + activeTab.value = tabId; + console.log("Tab changed to:", tabId); + }; + return { + args, + activeTab, + onTabChange + }; + }, + template: ` + + + + + + + + + ` + }), + parameters: { + docs: { + description: { + story: "Example with a disabled tab that cannot be selected by the user." + } + } + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify tabs with disabled tab render correctly", async () => { + const tabsContainer = canvas.getByRole("list"); + expect(tabsContainer).toBeInTheDocument(); + }); + await step("Verify tab links are rendered", async () => { + const tabLinks = canvas.getAllByRole("link"); + expect(tabLinks).toHaveLength(3); + }); + await step("Verify disabled tab is not clickable", async () => { + const disabledTab = canvas.getByRole("link", { + name: /disabled tab/i + }); + expect(disabledTab).toBeInTheDocument(); + }); + await step("Test switching to regular tab", async () => { + const regularTab = canvas.getByRole("link", { + name: /regular tab/i + }); + await userEvent.click(regularTab); + expect(regularTab).toBeInTheDocument(); + }); + } +} +``` + +--- + +## Elements / Tooltip + +### Tooltip + +Wrap any element to show helpful text on hover or focus. Placement, delay, and color are configurable for consistent guidance. + +#### Features + +- Top/bottom/left/right placement +- Delay before showing to avoid flicker +- Color themes for contrast on light/dark backgrounds +- Disable when not needed + +#### Accessibility + +- Should appear on focus as well as hover; ensure the trigger is keyboard reachable and supply concise, informative text. + +#### Usage + +Use for short hints, not long-form content. Prefer inline help or docs links for complex explanations. + +This is a helpful tooltip message + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| text* | The text content to display in the tooltipstring | - | This is a helpful tooltip message | +| delay | Delay in milliseconds before showing the tooltip on hovernumber | 0 | | +| placement | The placement of the tooltipPlacementType | "bottom" | Choose option...topbottomleftright | +| color | The color of the tooltipColorType | "primary" | Choose option...primarysuccessinfowarningdangersecondarywhiteblacksystem | +| disabled | Whether to disable the tooltipboolean | false | FalseTrue | +| slots | | +| default | other | - | | + +#### Stories + +##### Default + +This is a helpful tooltip message + +``` + +``` + +##### With Delay + +This tooltip appears after 2 seconds of hovering + +``` + +``` + +##### Long Text + +This is a much longer tooltip message that demonstrates how the tooltip handles longer text content. It will wrap appropriately and maintain good readability. + +``` + +``` + +--- + +## Form / Checkboxinput + +### CheckboxInput + +A flexible single checkbox component that supports various visual variants, color schemes, and states. The component features: + +- Single checkbox with customizable text +- Various color variants (primary, success, secondary, danger, warning, info, dark) +- Visual variants (default, outline, rounded, outline-rounded) +- Error state with custom error message +- Disabled state +- Required field indicator +- Fully reactive with Vue's v-model +- Change events +- RTL support with proper directional styling +- Dark theme support +- Smooth transitions and hover effects + +#### Usage + +The CheckboxInput component can be used for single checkbox selections: + +``` + +``` + +#### Variants + +- **Default**: Standard checkbox appearance +- **Outline**: Checkbox with outline styling +- **Rounded**: Checkbox with rounded corners +- **Outline Rounded**: Combination of outline and rounded styles + +#### Colors + +- **Primary**: Default primary color +- **Success**: Green color for success states +- **Secondary**: Secondary color variant +- **Danger**: Red color for error/danger states +- **Warning**: Orange color for warning states +- **Info**: Blue color for informational states +- **Dark**: Dark color variant + +Checkbox Label Accept terms and conditions + +``` +{ + render: args => ({ + components: { + CheckboxInput + }, + setup() { + const isChecked = ref(false); + return { + args, + isChecked + }; + }, + template: ` + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify checkbox renders correctly", async () => { + const checkbox = canvas.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + }); + await step("Verify checkbox text is displayed", async () => { + const text = canvas.getByText("Accept terms and conditions"); + expect(text).toBeInTheDocument(); + }); + await step("Test checkbox interaction", async () => { + const checkbox = canvas.getByRole("checkbox"); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + }); + } +} +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| label | Label for the checkbox groupstring | undefined | Checkbox Label | +| text | Text displayed next to the checkboxstring | undefined | Checkbox Text | +| value | Value of the checkboxstring | undefined | checkbox-value | +| color | Color variant for checkboxstring | primary | Choose option...primarysuccesssecondarydangerwarninginfodark | +| variant | Visual variant of checkboxstring | default | Choose option...defaultoutlineroundedoutline-rounded | +| disabled | Disabled stateboolean | false | FalseTrue | +| required | Required stateboolean | false | FalseTrue | +| error | Error stateboolean | false | FalseTrue | +| errorMessage | Error message textstring | undefined | | +| id | ID for the checkboxstring | undefined | checkbox-input | +| modelValue | boolean | false | | +| events | | +| blur | Emitted when a checkbox loses focus.FocusEvent | - | - | +| focus | Emitted when a checkbox gains focus.FocusEvent | - | - | +| update:modelValue | Emitted when the checkbox selection changes.boolean | - | - | +| change | Emitted when a checkbox value changes.union | - | - | + +#### Stories + +##### Default + +Checkbox Label Accept terms and conditions + +``` +{ + render: args => ({ + components: { + CheckboxInput + }, + setup() { + const isChecked = ref(false); + return { + args, + isChecked + }; + }, + template: ` + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify checkbox renders correctly", async () => { + const checkbox = canvas.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + }); + await step("Verify checkbox text is displayed", async () => { + const text = canvas.getByText("Accept terms and conditions"); + expect(text).toBeInTheDocument(); + }); + await step("Test checkbox interaction", async () => { + const checkbox = canvas.getByRole("checkbox"); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + }); + } +} +``` + +##### With Label + +A checkbox with a label and descriptive text for form sections + +Terms and Conditions I agree to the terms and conditions + +``` +{ + args: { + label: "Terms and Conditions", + text: "I agree to the terms and conditions", + value: "terms", + id: "terms-checkbox" + }, + render: args => ({ + components: { + CheckboxInput + }, + setup() { + const isChecked = ref(false); + return { + args, + isChecked + }; + }, + template: ` + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify checkbox with label renders correctly", async () => { + const checkbox = canvas.getByRole("checkbox"); + const label = canvas.getByText("Terms and Conditions"); + expect(checkbox).toBeInTheDocument(); + expect(label).toBeInTheDocument(); + }); + await step("Verify checkbox has correct ID", async () => { + const checkbox = canvas.getByRole("checkbox"); + expect(checkbox).toHaveAttribute("id", "terms-checkbox"); + }); + await step("Test checkbox interaction", async () => { + const checkbox = canvas.getByRole("checkbox"); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + }); + }, + parameters: { + docs: { + description: { + story: "A checkbox with a label and descriptive text for form sections" + } + } + } +} +``` + +##### Success Color + +A checkbox using the success color variant for positive confirmations + +Success Variant Task completed successfully + +``` +{ + args: { + label: "Success Variant", + text: "Task completed successfully", + color: "success", + value: "completed" + }, + render: args => ({ + components: { + CheckboxInput + }, + setup() { + const isChecked = ref(true); + return { + args, + isChecked + }; + }, + template: ` + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify success checkbox renders correctly", async () => { + const checkbox = canvas.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toBeChecked(); + }); + await step("Verify success color styling", async () => { + const checkbox = canvas.getByRole("checkbox"); + const checkboxContainer = checkbox.closest("div"); + expect(checkboxContainer).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: "A checkbox using the success color variant for positive confirmations" + } + } + } +} +``` + +##### Outline Rounded Variant + +A checkbox combining outline styling with rounded corners + +Outline Rounded Variant Enable dark mode + +``` +{ + args: { + label: "Outline Rounded Variant", + text: "Enable dark mode", + variant: "outline-rounded", + value: "dark-mode" + }, + render: args => ({ + components: { + CheckboxInput + }, + setup() { + const isChecked = ref(false); + return { + args, + isChecked + }; + }, + template: ` + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify outline rounded checkbox renders correctly", async () => { + const checkbox = canvas.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + }); + await step("Verify outline rounded styling", async () => { + const checkbox = canvas.getByRole("checkbox"); + const checkboxContainer = checkbox.closest("div"); + expect(checkboxContainer).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: "A checkbox combining outline styling with rounded corners" + } + } + } +} +``` + +##### Disabled + +A disabled checkbox that cannot be interacted with + +Disabled Checkbox This option is currently unavailable + +``` +{ + args: { + label: "Disabled Checkbox", + text: "This option is currently unavailable", + disabled: true, + value: "disabled-option" + }, + render: args => ({ + components: { + CheckboxInput + }, + setup() { + const isChecked = ref(false); + return { + args, + isChecked + }; + }, + template: ` + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify disabled checkbox renders correctly", async () => { + const checkbox = canvas.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toBeDisabled(); + }); + await step("Verify disabled styling", async () => { + const checkbox = canvas.getByRole("checkbox"); + const checkboxContainer = checkbox.closest("div"); + expect(checkboxContainer).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: "A disabled checkbox that cannot be interacted with" + } + } + } +} +``` + +##### With Error + +A checkbox in an error state with a validation message + +Checkbox with Error Accept terms and conditionsYou must accept the terms to continue + +``` +{ + args: { + label: "Checkbox with Error", + text: "Accept terms and conditions", + error: true, + errorMessage: "You must accept the terms to continue", + value: "terms" + }, + render: args => ({ + components: { + CheckboxInput + }, + setup() { + const isChecked = ref(false); + return { + args, + isChecked + }; + }, + template: ` + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify error checkbox renders correctly", async () => { + const checkbox = canvas.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + }); + await step("Verify error message is displayed", async () => { + const errorMessage = canvas.getByText("You must accept the terms to continue"); + expect(errorMessage).toBeInTheDocument(); + }); + await step("Verify error styling", async () => { + const checkbox = canvas.getByRole("checkbox"); + const checkboxContainer = checkbox.closest("div"); + expect(checkboxContainer).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: "A checkbox in an error state with a validation message" + } + } + } +} +``` + +##### Required + +A required checkbox with a required field indicator + +Required Checkbox \*I confirm that I am over 18 years old + +``` +{ + args: { + label: "Required Checkbox", + text: "I confirm that I am over 18 years old", + required: true, + value: "age-confirmation" + }, + render: args => ({ + components: { + CheckboxInput + }, + setup() { + const isChecked = ref(false); + return { + args, + isChecked + }; + }, + template: ` + + ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify required checkbox renders correctly", async () => { + const checkbox = canvas.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toHaveAttribute("required"); + }); + await step("Verify required indicator", async () => { + const label = canvas.getByText("Required Checkbox"); + expect(label).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: "A required checkbox with a required field indicator" + } + } + } +} +``` + +##### Multiple Checkboxes + +Multiple checkboxes demonstrating how to create a checkbox group with reactive state tracking + +##### Communication Preferences + +Email Notifications Receive email notifications + +Newsletter Subscribe to our newsletter + +Marketing Receive marketing communications + +Updates Get product updates + +Selected: None + +``` +{ + render: args => ({ + components: { + CheckboxInput + }, + setup() { + const selectedValues = ref({ + notifications: false, + newsletter: false, + marketing: false, + updates: false + }); + const options = [{ + label: "Email Notifications", + value: "notifications", + text: "Receive email notifications" + }, { + label: "Newsletter", + value: "newsletter", + text: "Subscribe to our newsletter" + }, { + label: "Marketing", + value: "marketing", + text: "Receive marketing communications" + }, { + label: "Updates", + value: "updates", + text: "Get product updates" + }]; + return { + args, + selectedValues, + options + }; + }, + template: ` +
+

Communication Preferences

+
+ +
+
+

+ Selected: {{ Object.keys(selectedValues).filter(key => selectedValues[key]).join(', ') || 'None' }} +

+
+
+ ` + }), + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify multiple checkboxes render correctly", async () => { + const checkboxes = canvas.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(4); + }); + await step("Verify all checkbox labels are displayed", async () => { + expect(canvas.getByText("Email Notifications")).toBeInTheDocument(); + expect(canvas.getByText("Newsletter")).toBeInTheDocument(); + expect(canvas.getByText("Marketing")).toBeInTheDocument(); + expect(canvas.getByText("Updates")).toBeInTheDocument(); + }); + await step("Test checkbox interactions", async () => { + const checkboxes = canvas.getAllByRole("checkbox"); + + // Click first checkbox + await userEvent.click(checkboxes[0]); + expect(checkboxes[0]).toBeChecked(); + + // Click second checkbox + await userEvent.click(checkboxes[1]); + expect(checkboxes[1]).toBeChecked(); + }); + await step("Verify selected values display", async () => { + const checkboxes = canvas.getAllByRole("checkbox"); + await userEvent.click(checkboxes[0]); + const selectedText = canvas.getByText(/Selected:/); + expect(selectedText).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: "Multiple checkboxes demonstrating how to create a checkbox group with reactive state tracking" + } + } + } +} +``` + +--- + +## Form / Fileinputbutton + +### FileInputButton + +Accessible file picker that pairs a styled button with a native file input under the hood. Supports accept filters, capture hints, and multiple selection. + +#### Features + +- Button color themes; customizable label +- Accept and capture attributes; multiple selection +- Disabled/required states; error messaging + +#### Accessibility + +- Uses a real input type=file with proper labeling; ensure descriptive button text. + +#### Usage + +Use when you need a simple, button-driven file chooser without drag-and-drop. + +Upload File + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| label | Input label textstring | "" | Upload File | +| buttonColor | Color variant of the upload button"primary""info""success""warning""danger""secondary""dark""gradient" | "primary" | Choose option...primaryinfosuccesswarningdangersecondarydarkgradient | +| accept | Allowed file types (e.g., ".jpg,.png,.pdf" or "image/*")string | "" | | +| multiple | Allow multiple file selectionboolean | false | FalseTrue | +| required | Whether the input is requiredboolean | false | FalseTrue | +| disabled | Whether the input is disabledboolean | false | FalseTrue | +| error | Whether the input has an errorboolean | false | FalseTrue | +| errorMessage | Error message to displaystring | "" | | +| id | string | "" | | +| capture | Capture method for file input (e.g., "user" or "environment")"user""environment"boolean | - | | +| size | Size attribute for the file inputnumber | 0 | | +| events | | +| blur | Emitted when the input loses focusFocusEvent | - | - | +| focus | Emitted when the input gains focusFocusEvent | - | - | +| change | Emitted when the value of the input changesEvent | - | - | +| input | Emitted when the user inputs dataEvent | - | - | +| cancel | Emitted when the user cancels the inputEvent | - | - | +| file-change | Emitted when the selected files changeFileList | - | - | + +#### Stories + +##### Default + +Upload File + +``` + +``` + +##### With Accepted Types + +Upload Images + +``` + +``` + +##### With Error + +Upload Document Please select a valid file + +``` + +``` + +##### Disabled + +Upload File + +``` + +``` + +##### With Capture + +Take Photo + +``` + +``` + +--- + +## Form / Fileinputcombo + +### FileInputCombo + +A versatile file upload component that supports both drag-and-drop and click-to-upload functionality. The component provides a modern interface with file previews, upload progress tracking, and comprehensive file management features. + +#### Features + +- **Dual Upload Modes**: Support for both drag-and-drop and click-to-upload +- **File Preview**: Visual preview of selected files with thumbnails +- **Progress Tracking**: Real-time upload progress indicators +- **Auto Upload**: Option to automatically start uploads when files are selected +- **File Validation**: Built-in file type and size restrictions +- **Multiple File Support**: Handle multiple file uploads with individual progress tracking +- **Customizable UI**: Custom icons, labels, and descriptions +- **Accessibility**: Full keyboard navigation and ARIA support +- **Responsive Design**: Works well on all screen sizes + +#### Storybook Demo Note + +In this Storybook demo, the file upload process is simulated with progress updates that happen automatically via intervals. In a real application, you would need to implement the actual upload logic and update the file states accordingly. + +#### Usage in Real Applications + +``` + + + +``` + +##### Drop files to upload + +Or + +Click to browse files + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| multiple | Allow multiple file uploadsboolean | true | FalseTrue | +| disabled | Disable the componentboolean | false | FalseTrue | +| required | Whether the field is requiredboolean | false | FalseTrue | +| showPreview | Show file preview after uploadboolean | true | FalseTrue | +| showControls | Show upload controlsboolean | true | FalseTrue | +| maxSize | Maximum file size in bytesnumber | 0 | | +| maxFiles | Maximum number of files allowednumber | 0 | | +| autoUpload | Automatically start upload when files are addedboolean | false | FalseTrue | +| uploadIcon | Icon to show in the upload areastring | "IconCloudUpload" | IconCloudUpload | +| dropModeIcon | Icon to show in the drop mode overlaystring | "IconGallery" | IconGallery | +| dropModeLabel | Label to show in the drop mode overlaystring | "Drop your files" | Drop your files | +| accept | File types that are allowed to be uploaded (e.g. '.jpg,.png')string | "" | | +| label | Label for the inputstring | "" | | +| id | ID for the input elementstring | - | | +| title | Custom title for the upload areastring | "" | | +| description | Custom description for the upload areastring | "" | | +| fileTypes | Human-readable description of accepted file typesstring | "" | | +| filterFileDropped | Function to filter files that can be droppedTSFunctionType | () => true | | +| errorMessage | Error message to show when there is an errorstring | "" | | +| events | | +| file-select | { files: File[] } | - | - | +| file-upload | { file: File; fileId: string } | - | - | +| file-remove | { file: File; fileId: string } | - | - | +| file-upload-all | other | - | - | +| file-upload-progress | { file: File; progress: number; fileId: string } | - | - | +| file-upload-error | { file: File; error: Error; fileId: string } | - | - | +| file-upload-complete | { file: File; fileId: string } | - | - | +| file-upload-cancel | { file: File; fileId: string } | - | - | +| slots | | +| controls | Custom slot for upload controlsother | - | | +| uploadArea | Custom slot for the upload area{ files: unknown; filesStatus: unknown } | - | | +| fileList | Custom slot for file list{ filesStatus: unknown; uploadFile: unknown; cancelUpload: unknown; removeFile: unknown } | - | | +| fileItem | Custom slot for individual file item{ file: unknown; fileId: unknown; status: unknown; uploadFile: unknown; cancelUpload: unknown; removeFile: unknown } | - | | +| fileProgress | Custom slot for file progress indicator{ file: unknown; fileId: unknown; state: unknown } | - | | +| fileActions | Custom slot for file action buttons{ file: unknown; fileId: unknown; status: unknown; uploadFile: unknown; cancelUpload: unknown; removeFile: unknown } | - | | +| expose | | +| uploadFile | other | - | - | +| uploadAllFiles | other | - | - | +| cancelUpload | other | - | - | +| removeFile | other | - | - | +| triggerFileInput | other | - | - | +| files | other | - | - | +| filesStatus | other | - | - | +| updateFileProgress | other | - | - | +| setFileStatus | other | - | - | + +#### Stories + +##### Default + +##### Drop files to upload + +Or + +Click to browse files + +``` + +``` + +##### With Label + +Upload Documents \* + +##### Drop files to upload + +Or + +Click to browse files + +``` + +``` + +##### Image Uploader + +Upload Images + +##### Drop your images here + +Or + +JPG, PNG, GIF and WebP files only + +``` + +``` + +##### Document Uploader + +Upload Documents + +##### Drop your documents here + +Or + +PDF, Word, and text files only + +``` + +``` + +##### Auto Upload + +Auto Upload Files + +##### Files will upload automatically + +Or + +Click to browse files + +``` + +``` + +##### Disabled + +Disabled Upload + +##### Upload disabled + +Or + +You cannot upload files at this time + +``` + +``` + +##### No Preview + +##### Simple Upload + +Or + +No file previews will be shown + +``` + +``` + +##### Custom Icons + +##### Drop files to upload + +Or + +Click to browse files + +``` + +``` + +##### With File Size Limit + +Limited Upload Size + +##### Drop files to upload + +Or + +Maximum file size: 1MB + +``` + +``` + +##### With Max Files + +Limited Number of Files + +##### Drop files to upload + +Or + +Maximum 2 files allowed + +``` + +``` + +##### With Upload Error + +Upload with Error Simulation + +##### Drop files to upload + +Or + +Files will fail to upload after progress reaches 30% + +``` + +``` + +##### With Toast Notifications + +This story demonstrates toast notifications for all component events. Try selecting, uploading, cancelling and removing files to see different toast notifications. + +File Upload with Toast Notifications + +##### Upload files to see toast notifications + +Or + +All component events will be displayed as toast notifications + +``` + +``` + +##### Withtoast Errors + +This story demonstrates error toast notifications. All uploads will fail at 30% progress, displaying error toasts. + +Upload with Error Notifications + +##### Files will error at 30% upload + +Or + +Demonstrates error toast notifications + +``` + +``` + +--- + +## Form / Input + +### Input + +### Input Component + +A flexible input component that supports various input types, icon integration with click events, and visual states. This component is built with accessibility in mind and supports form validation states. + +#### Features + +- Supports common input types (text, email, password, number, etc.) +- Optional label with required indicator +- Error state with custom error message +- Icon support with RTL/LTR aware positioning +- Clickable icons with event handling +- Disabled state +- Range input with min/max values +- Fully reactive with Vue's v-model + +#### Icon Positioning + +- **Default** (`iconOppositePosition: false`): Icons appear behind content (LTR: left, RTL: right) +- **Opposite** (`iconOppositePosition: true`): Icons appear after content (LTR: right, RTL: left) + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| type | Input type attribute"text""range""email""password""number""tel""url""search" | "text" | Choose option...textemailpasswordnumbertelurlsearchrange | +| placeholder | Placeholder textstring | "" | Default input field | +| disabled | Disabled stateboolean | - | FalseTrue | +| required | Required stateboolean | false | FalseTrue | +| error | Error stateboolean | false | FalseTrue | +| errorMessage | Error message textstring | "" | | +| iconName | Icon name to be displayed in the inputstring | "" | Choose option...IconSearchIconMailIconEyeIconLockIconUserIconX | +| iconOppositePosition | When true, positions icon on the opposite side. Default: icon behind content (LTR: left, RTL: right). Opposite: icon after content (LTR: right, RTL: left)boolean | false | FalseTrue | +| id | Input ID attribute for label associationstring | "" | | +| modelValue | string | "" | | +| label | Label of inputstring | "" | | +| min | Minimum value for range inputsstringnumber | 0 | | +| max | Maximum value for range inputsstringnumber | 100 | | +| events | | +| update:modelValue | Emitted when the input value changes.string | - | - | +| blur | Emitted when the input loses focus.FocusEvent | - | - | +| focus | Emitted when the input gains focus.FocusEvent | - | - | +| enter | Emitted when the Enter key is pressed.string | - | - | +| iconClick | Emitted when the icon is clicked.MouseEvent | - | - | + +#### Stories + +##### Default + +``` + +``` + +##### With Label + +Input with a descriptive label to improve accessibility. + +Username + +``` + +``` + +##### With Icon + +Input with a search icon using default positioning (behind content: LTR: left, RTL: right). + +Search + +``` + +``` + +##### With Icon Opposite Position + +Input with an icon positioned on the opposite side (after content: LTR: right, RTL: left). + +Username + +``` + +``` + +##### Clickable Icon + +Input with a clickable icon that can toggle password visibility or perform other actions. + +Password + +Click the eye icon to toggle password visibility + +``` + +``` + +##### Email Input + +Email input with mail icon positioned opposite to default (LTR: left, RTL: right). + +Email Address \* + +``` + +``` + +##### Password Input + +Password input with a lock icon positioned opposite to default direction. + +Password \* + +``` + +``` + +##### With Error + +Input in an error state with an alert icon using default positioning. + +Email + +This field is required + +``` + +``` + +##### Disabled + +Disabled input with visual indication of its unavailable state and icon in opposite position. + +Username + +``` + +``` + +##### Required + +Required input with asterisk indicator next to the label. + +Name \* + +``` + +``` + +##### Number Input + +Number input with minimum and maximum constraints. + +Age + +``` + +``` + +##### Range + +Range slider input with label and min/max values. + +Volume + +``` + + + +``` + +##### Tel Input + +Telephone input with appropriate formatting placeholder. + +Phone Number + +``` + +``` + +##### RTL Icon Comparison + +Comparison showing how icon positioning works with RTL/LTR awareness and iconOppositePosition. + +##### Default Icon Positioning (iconOppositePosition: false) + +Search (Default) + +##### Opposite Icon Positioning (iconOppositePosition: true) + +Search (Opposite) + +**Note:** Icon positioning adapts to your app's RTL/LTR direction automatically. + +• **LTR (Left-to-Right):** Default = right, Opposite = left + +• **RTL (Right-to-Left):** Default = left, Opposite = right + +``` + +``` + +##### With Enter Key Event + +Input that captures values when Enter key is pressed and displays them in a list below. + +Quick Add + +``` + +``` + +##### Icon Positioning Comparison + +This story demonstrates the two icon positioning modes. Toggle `iconOppositePosition` to see the difference between behind content (default) and after content (opposite). + +Icon Positioning Demo + +``` + +``` + +--- + +## Form / Inputgroup + +### InputGroup + +### InputGroup + +A flexible container component that groups form elements together with proper styling coordination. The InputGroup automatically manages border radius, error states, and spacing for its child components. + +#### Features + +- **Slot-based Architecture**: Accepts any number of child components through a single default slot +- **Automatic Styling**: Manages border radius for first, middle, and last children +- **Error State Management**: Coordinates error styling across all children +- **RTL Support**: Proper right-to-left layout support +- **Theme Support**: Works with light, dark, and semidark themes +- **Flexible Layout**: Input components automatically take available width with flex-1 + +#### Common Use Cases + +- Input with prefix/suffix text or icons +- Input with action buttons +- Multiple related form elements +- Search input with submit button +- Currency input with symbol prefix +- URL input with domain suffix + +#### Accessibility + +- Proper label association for screen readers +- Error message announcement +- Keyboard navigation support +- ARIA attributes for form validation + +Note: This section intentionally omits code; Storybook shows usage code automatically. + +Username + +@ + +``` + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| label | Label text for the input groupstring | "" | Username | +| required | Whether the input group is requiredboolean | false | FalseTrue | +| error | Whether the input group has an error stateboolean | false | FalseTrue | +| errorMessage | Error message to display when error is truestring | "" | | +| disabled | Whether the input group is disabledboolean | false | FalseTrue | + +#### Stories + +##### Default + +Username + +@ + +``` + +``` + +##### With Suffix + +Username + +@example.com + +``` + +``` + +##### With Prefix And Suffix + +Username + +$ + +.00 + +``` + +``` + +##### With Buttons + +Username + +``` + +``` + +##### Button Group + +Username + +``` + +``` + +##### With Icons + +Username + +``` + +``` + +##### With Error + +Username + +@ + +Please enter a valid email address + +``` + +``` + +##### Disabled + +Username + +@ + +``` + +``` + +##### Multiple Inputs + +Username + +Name + +``` + +``` + +##### Search With Dropdown + +Username + +``` + +``` + +##### With Select Dropdown + +Username + +``` + +``` + +##### Currency Input + +Username + +``` + +``` + +##### Interactive + +Username + +``` + +``` + +--- + +## Form / Select + +### Select + +### Select Component + +A flexible and customizable select component that supports both default and custom modes. The Select component provides a comprehensive dropdown interface with support for single/multiple selection, search functionality, grouped options, and custom rendering through slots. + +#### Features + +- **Default Mode**: Traditional dropdown with built-in search, styling, and interactions +- **Custom Mode**: Complete customization through three specialized slots (header, each, footer) +- **Confirmation Mode**: Built-in confirmation footer with Accept/Cancel buttons for improved UX +- **Multiple Selection**: Support for selecting multiple options with toggle behavior +- **Search Functionality**: Built-in search with filtering capabilities +- **Grouped Options**: Support for categorized option groups +- **Accessibility**: Full ARIA support and keyboard navigation +- **Theme Integration**: Light/dark mode support with Tailwind CSS +- **RTL Support**: Right-to-left layout support +- **Icon Integration**: Optional icon placement and interaction + +#### Confirmation Mode + +When `confirm={true}`, the component automatically shows a built-in footer with Accept and Cancel buttons. This mode works in both single and multiple selection modes and provides improved UX by allowing users to make temporary selections before committing. + +**Key Benefits:** + +- **Value Preservation**: Original selection is preserved until confirmed +- **Better UX**: Users can review their choices before committing +- **Built-in Footer**: No need to implement custom footer logic +- **Universal Support**: Works with single, multiple, and grouped options + +#### Custom Mode Slots + +When `custom={true}`, the component provides three specialized slots: + +##### Header Slot + +- **Purpose**: Custom search widgets, filters, or additional controls +- **Scoped Variables**: + - `allOptions`: Array of all available options + - `setNewList`: Function to update the options list + +##### Each Slot + +- **Purpose**: Custom rendering for each individual option +- **Scoped Variables**: + - `option`: The current option data + - `isSelected`: Boolean indicating if option is selected + - `setSelected`: Function to select/deselect the option + +##### Footer Slot + +- **Purpose**: Custom actions, accept/reject buttons, or additional controls +- **Scoped Variables**: + - `close`: Function to close dropdown with acceptance parameter + +#### Selected Slot + +The `selected` slot allows you to customize how selected options are displayed in the trigger button. This slot is available in both default and custom modes. + +- **Purpose**: Custom display of selected option(s) in the trigger button +- **Scoped Variables**: + - `selectedOption`: The selected value (single mode only, undefined in multiple mode) + - `selectedOptions`: Array of selected values (always an array - contains single item in single mode, multiple items in multiple mode) + - `multiple`: Boolean indicating if multiple selection mode is enabled + - `getOptionLabel`: Helper function to get the display label for an option + - `selectedCount`: Number of selected items + +#### Accessibility + +- Full keyboard navigation support (Enter, Space, Arrow keys, Escape) +- Proper ARIA attributes (aria-expanded, aria-haspopup, aria-selected) +- Screen reader friendly with proper role attributes +- Focus management for search inputs and options + +#### Usage Guidelines + +- Use **Default Mode** for standard dropdown requirements +- Use **Custom Mode** when you need complete control over the dropdown appearance and behavior +- Always provide meaningful labels and placeholders for accessibility +- Consider using the `required` prop for form validation +- Use `error` and `errorMessage` props for validation feedback + +Note: This section intentionally omits code; Storybook shows usage code automatically. + +Selected value: None + +``` + + + +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| placeholder | Placeholder text when no option is selectedstring | "Select an option" | Choose a color | +| disabled | Whether the select component is disabledboolean | - | FalseTrue | +| required | Whether the field is required (shows asterisk)boolean | false | FalseTrue | +| error | Whether to show error stylingboolean | false | FalseTrue | +| errorMessage | Error message to display below the selectstring | "" | | +| label | Label text for the select fieldstring | "" | | +| id | Unique identifier for the select fieldstring | "" | | +| searchable | Whether to show a search input in the dropdownboolean | false | FalseTrue | +| searchPlaceholder | Placeholder text for the search inputstring | "Search..." | Search... | +| multiple | Whether to allow multiple option selectionboolean | false | FalseTrue | +| grouped | Whether options are groupedboolean | false | FalseTrue | +| groupLabel | Property name for group labels when grouped is truestring | "group_name" | group_name | +| groupValues | Property name for group option arrays when grouped is truestring | "list" | list | +| trackBy | Property name to use for option identificationstring | "value" | value | +| labelKey | Property name to use for option display textstring | "label" | label | +| valueKey | Property name to use for option valuesstring | "value" | value | +| noOptionsMessage | Message to show when no options are availablestring | "No options available" | No options available | +| iconName | Name of the icon to displaystring | "" | Choose option...IconSearchIconMailIconUserIconX | +| iconOppositePosition | Whether to position icon on the opposite sideboolean | false | FalseTrue | +| preselectFirst | Whether to automatically select the first optionboolean | false | FalseTrue | +| allowEmpty | Whether to allow no selectionboolean | true | FalseTrue | +| custom | Whether to enable custom mode with slot-based renderingboolean | false | FalseTrue | +| confirm | Whether to show a confirmation footer with Accept/Cancel buttonsboolean | false | FalseTrue | +| options | Array of options to display in the dropdownTSParenthesizedType[] | () => [] | options : [0 : "Orange"1 : "White"2 : "Purple"3 : "Yellow"4 : "Red"5 : "Green"] | +| modelValue | The v-model value for the select componentany | - | | +| events | | +| update:modelValue | any | - | - | +| change | any | - | - | +| focus | FocusEvent | - | - | +| blur | FocusEvent | - | - | +| open | other | - | - | +| close | other | - | - | +| onUpdate:modelValue | Event emitted when value changesany | - | - | +| onChange | Event emitted when selection changesany | - | - | +| onOpen | Event emitted when dropdown opens- | - | - | +| onClose | Event emitted when dropdown closes- | - | - | +| slots | | +| selected | { selected-option: unknown; selected-options: unknown; multiple: unknown; get-option-label: unknown; selected-count: unknown } | - | | +| header | { all-options: unknown; set-new-list: unknown } | - | | +| each | { option: unknown; is-selected: unknown; set-selected: unknown } | - | | +| footer | { close: unknown } | - | | + +#### Stories + +##### Default + +Selected value: None + +``` + + + +``` + +##### With Label + +Favorite Color + +Selected value: None + +``` + + + +``` + +##### Required + +Favorite Color \* + +Selected value: None + +``` + + + +``` + +##### With Icon + +Search Category + +Selected value: None + +``` + + + +``` + +##### Searchable + +Search Country + +Selected value: None + +``` + + + +``` + +##### Multiple + +Select Colors + +Selected values: None + +``` + + + +``` + +##### Object Options + +Select User + +Selected value: None + +``` + + + +``` + +##### Grouped + +Select Category + +Selected value: None + +``` + + + +``` + +##### With Error + +Required Field \* + +This field is required + +Selected value: None + +``` + + + +``` + +##### Disabled + +Disabled Select + +Selected value: None + +``` + + + +``` + +##### Preselect First + +Auto-select First + +Selected value: First Option + +``` + + + +``` + +##### Interactive + +Interactive Select + +Selected Value: + +``` +"" +``` + +``` + + + +``` + +##### Multiple Interactive + +Multiple Selection + +Selected Values: + +``` +[] +``` + +``` + + + +``` + +##### Searchable Objects + +Search Users + +Selected User: + +``` +null +``` + +``` + + + +``` + +##### Complex Grouped + +Complex Grouped Select + +Selected: Option 1 + +``` + + + +``` + +##### Confirmation Mode + +This story demonstrates the **Confirmation Mode** functionality of the Select component. When `confirm={true}`, the component shows a built-in footer with Accept and Cancel buttons. + +**Key Features:** + +- **Built-in Footer**: Automatically shows Accept/Cancel buttons +- **Value Preservation**: Original value is preserved until confirmed +- **Single Mode**: Works seamlessly with single selection +- **Improved UX**: Users can make temporary selections and confirm or cancel them + +The confirmation footer appears below the options and provides clear actions for the user to either accept their selection or cancel and revert to the original value. + +Confirmation Mode Select (Single) + +Selected: Option 1 + +``` + + + +``` + +##### Multiple Confirmation Mode + +This story demonstrates the **Multiple Confirmation Mode** functionality. When `confirm={true}` and `multiple={true}`, users can select multiple options and then confirm or cancel their selection. + +**Key Features:** + +- **Multiple Selection**: Users can select/deselect multiple options +- **Built-in Footer**: Accept/Cancel buttons for final confirmation +- **Value Preservation**: Original selection is preserved until confirmed +- **Enhanced UX**: Perfect for scenarios where users need to review their choices + +This mode is particularly useful for forms where users need to make multiple selections and want to review them before committing. + +Confirmation Mode Select (Multiple) + +Selected: Option 1 + +``` + + + +``` + +##### Custom Mode + +This story demonstrates the **Custom Mode** functionality of the Select component, showcasing how to use the three specialized slots for complete customization: + +- **Header Slot**: Custom search widget with option count display +- **Each Slot**: Custom option rendering with selection indicators +- **Footer Slot**: Custom actions (Accept/Cancel buttons) + +The component is configured with `custom={true}` and `multiple={true}` to enable custom mode with multiple selection support. In custom mode, the dropdown height is increased to accommodate all content including the footer section. + +Custom Mode Select (Multiple) + +Selected: Option 1 + +``` + + + +``` + +##### Custom Selected Display + +This story demonstrates the **Selected Slot** functionality, which allows you to customize how selected options are displayed in the trigger button. + +**Scoped Variables:** + +- `selectedOption`: The selected value(s) - single value or array depending on mode +- `multiple`: Boolean indicating if multiple selection mode is enabled +- `getOptionLabel`: Helper function to get the display label for an option +- `selectedCount`: Number of selected items (for multiple mode) + +**Use Cases:** + +- Custom badges or chips for selected items +- Icons or indicators for selection state +- Custom formatting for selected values +- Displaying additional information about selections + +The slot provides a default implementation that shows a count for multiple selections or the option label for single selections, but you can completely customize this display. + +Custom Selected Display + +Selected: None + +``` + + + +``` + +##### Custom Selected Display Multiple + +This story demonstrates the **Selected Slot** with multiple selection mode, showing how to display selected items as chips/badges with a "more" indicator when there are many selections. + +The custom implementation shows: + +- Up to 2 selected items as individual badges +- A "+X more" badge when more than 2 items are selected +- Clean, compact display that works well in limited space + +Custom Selected Display (Multiple) + +Selected: None + +``` + + + +``` + +--- + +## Form / Switchball + +### SwitchBall + +### SwitchBall Component + +A customizable switch/toggle component with support for labels, sublabels, and various color themes. + +#### Features + +- Toggle switch with smooth animations +- Support for main label and sublabel +- Multiple color themes +- Icon integration +- Accessible design with proper ARIA attributes + +#### Basic Usage + +``` + + + +``` + +Default Switch + +``` +{ + args: { + label: "Default Switch" + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify switch renders correctly", async () => { + const switchInput = canvas.getByRole("checkbox"); + expect(switchInput).toBeInTheDocument(); + expect(switchInput).not.toBeChecked(); + }); + await step("Verify label is displayed", async () => { + const label = canvas.getByText("Default Switch"); + expect(label).toBeInTheDocument(); + }); + await step("Test switch interaction", async () => { + const switchInput = canvas.getByRole("checkbox"); + await userEvent.click(switchInput); + expect(switchInput).toBeChecked(); + }); + } +} +``` + +| Name | Description | Default | Control | +| --- | --- | --- | --- | +| props | | +| modelValue* | Switch state (on/off)boolean | false | FalseTrue | +| label* | Label textstring | "" | Default Switch | +| sublabel* | Secondary label text displayed below the main labelstring | "" | | +| color | Color theme of the switch"default""primary""info""success""warning""danger""secondary""dark" | "primary" | Choose option...primaryinfosuccesswarningdangersecondarydarkgradient | +| iconName | Icon name to be displayedstring | "IconCheck" | IconCheck | +| id* | Input ID attribute for label associationstring | - | switch-1 | +| events | | +| update:modelValue | boolean | - | - | + +#### Stories + +##### Default + +Default Switch + +``` +{ + args: { + label: "Default Switch" + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify switch renders correctly", async () => { + const switchInput = canvas.getByRole("checkbox"); + expect(switchInput).toBeInTheDocument(); + expect(switchInput).not.toBeChecked(); + }); + await step("Verify label is displayed", async () => { + const label = canvas.getByText("Default Switch"); + expect(label).toBeInTheDocument(); + }); + await step("Test switch interaction", async () => { + const switchInput = canvas.getByRole("checkbox"); + await userEvent.click(switchInput); + expect(switchInput).toBeChecked(); + }); + } +} +``` + +##### With Sublabel + +Switch with both main label and descriptive sublabel. + +NotificationsReceive email notifications + +``` +{ + args: { + label: "Notifications", + sublabel: "Receive email notifications", + id: "notifications-switch" + }, + parameters: { + docs: { + description: { + story: "Switch with both main label and descriptive sublabel." + } + } + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify switch with sublabel renders correctly", async () => { + const switchInput = canvas.getByRole("checkbox"); + expect(switchInput).toBeInTheDocument(); + expect(switchInput).not.toBeChecked(); + }); + await step("Verify main label is displayed", async () => { + const label = canvas.getByText("Notifications"); + expect(label).toBeInTheDocument(); + }); + await step("Verify sublabel is displayed", async () => { + const sublabel = canvas.getByText("Receive email notifications"); + expect(sublabel).toBeInTheDocument(); + }); + await step("Test switch interaction", async () => { + const switchInput = canvas.getByRole("checkbox"); + await userEvent.click(switchInput); + expect(switchInput).toBeChecked(); + }); + } +} +``` + +##### Custom Icon + +Switch with a custom icon instead of the default check icon. + +Custom Icon Switch + +``` +{ + args: { + label: "Custom Icon Switch", + iconName: "IconMail", + color: "info" + }, + parameters: { + docs: { + description: { + story: "Switch with a custom icon instead of the default check icon." + } + } + }, + play: async ({ + canvasElement, + step + }) => { + const canvas = within(canvasElement); + await step("Verify custom icon switch renders correctly", async () => { + const switchInput = canvas.getByRole("checkbox"); + expect(switchInput).toBeInTheDocument(); + expect(switchInput).not.toBeChecked(); + }); + await step("Verify label is displayed", async () => { + const label = canvas.getByText("Custom Icon Switch"); + expect(label).toBeInTheDocument(); + }); + await step("Test switch interaction", async () => { + const switchInput = canvas.getByRole("checkbox"); + await userEvent.click(switchInput); + expect(switchInput).toBeChecked(); + }); + } +} +``` + +--- + +## Form / Textarea + +### TextArea + +### TextArea Component + +A flexible textarea component that supports icon integration with click events, validation states, and accessibility features. This component is built with accessibility in mind and supports form validation states. + +#### Features + +- Configurable number of rows +- Optional label with required indicator +- Error state with custom error message +- Icon support with RTL/LTR aware positioning +- Clickable icons with event handling +- Disabled state +- Fully reactive with Vue's v-model +- Enter key event handling + +#### Icon Positioning + +- **Default** (`iconOppositePosition: false`): Icons appear behind content (LTR: left, RTL: right) +- **Opposite** (`iconOppositePosition: true`): Icons appear after content (LTR: right, RTL: left) + +``` +