commit 8b6184ecc5fcb09b7d485c26b30be23b53c4a3f0 Author: egg Date: Wed Dec 10 20:17:44 2025 +0800 feat: Meeting Assistant MVP - Complete implementation Enterprise Meeting Knowledge Management System with: Backend (FastAPI): - Authentication proxy with JWT (pj-auth-api integration) - MySQL database with 4 tables (users, meetings, conclusions, actions) - Meeting CRUD with system code generation (C-YYYYMMDD-XX, A-YYYYMMDD-XX) - Dify LLM integration for AI summarization - Excel export with openpyxl - 20 unit tests (all passing) Client (Electron): - Login page with company auth - Meeting list with create/delete - Meeting detail with real-time transcription - Editable transcript textarea (single block, easy editing) - AI summarization with conclusions/action items - 5-second segment recording (efficient for long meetings) Sidecar (Python): - faster-whisper medium model with int8 quantization - ONNX Runtime VAD (lightweight, ~20MB vs PyTorch ~2GB) - Chinese punctuation processing - OpenCC for Traditional Chinese conversion - Anti-hallucination parameters - Auto-cleanup of temp audio files OpenSpec: - add-meeting-assistant-mvp (47 tasks, archived) - add-realtime-transcription (29 tasks, archived) ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.claude/commands/openspec/apply.md b/.claude/commands/openspec/apply.md new file mode 100644 index 0000000..a36fd96 --- /dev/null +++ b/.claude/commands/openspec/apply.md @@ -0,0 +1,23 @@ +--- +name: OpenSpec: Apply +description: Implement an approved OpenSpec change and keep tasks in sync. +category: OpenSpec +tags: [openspec, apply] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directoryโ€”run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +Track these steps as TODOs and complete them one by one. +1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. +2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. +3. Confirm completion before updating statusesโ€”make sure every item in `tasks.md` is finished. +4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. +5. Reference `openspec list` or `openspec show ` when additional context is required. + +**Reference** +- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. + diff --git a/.claude/commands/openspec/archive.md b/.claude/commands/openspec/archive.md new file mode 100644 index 0000000..dbc7695 --- /dev/null +++ b/.claude/commands/openspec/archive.md @@ -0,0 +1,27 @@ +--- +name: OpenSpec: Archive +description: Archive a deployed OpenSpec change and update specs. +category: OpenSpec +tags: [openspec, archive] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directoryโ€”run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +1. Determine the change ID to archive: + - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. + - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. + - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. + - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. +2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. +3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). +4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. +5. Validate with `openspec validate --strict` and inspect with `openspec show ` if anything looks off. + +**Reference** +- Use `openspec list` to confirm change IDs before archiving. +- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. + diff --git a/.claude/commands/openspec/proposal.md b/.claude/commands/openspec/proposal.md new file mode 100644 index 0000000..cbb75ce --- /dev/null +++ b/.claude/commands/openspec/proposal.md @@ -0,0 +1,28 @@ +--- +name: OpenSpec: Proposal +description: Scaffold a new OpenSpec change and validate strictly. +category: OpenSpec +tags: [openspec, change] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directoryโ€”run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. +- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. +- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. + +**Steps** +1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. +3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. +4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. +5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. +6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. +7. Validate with `openspec validate --strict` and resolve every issue before sharing the proposal. + +**Reference** +- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. +- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. +- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a808902 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +venv/ +.venv/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ + +# Node.js +node_modules/ +npm-debug.log +yarn-error.log + +# Electron +out/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Environment +.env +.env.local +*.local + +# OS +.DS_Store +Thumbs.db + +# Whisper models (large files) +*.pt +*.bin +*.onnx + +# Temporary files +*.tmp +*.temp +*.webm +/tmp/ + +# Logs +*.log +logs/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0669699 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0669699 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,18 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..030c819 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,177 @@ +# Meeting Assistant Deployment Guide + +## Prerequisites + +- Python 3.10+ +- Node.js 18+ +- MySQL 8.0+ +- Access to Dify LLM service + +## Backend Deployment + +### 1. Setup Environment + +```bash +cd backend + +# Create virtual environment +python -m venv venv +source venv/bin/activate # Linux/Mac +# or: venv\Scripts\activate # Windows + +# Install dependencies +pip install -r requirements.txt +``` + +### 2. Configure Environment Variables + +```bash +# Copy example and edit +cp .env.example .env + +# Edit .env with actual values: +# - DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME +# - AUTH_API_URL +# - DIFY_API_URL, DIFY_API_KEY +# - ADMIN_EMAIL +# - JWT_SECRET (generate a secure random string) +``` + +### 3. Run Server + +```bash +# Development +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# Production +uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +### 4. Verify Deployment + +```bash +curl http://localhost:8000/api/health +# Should return: {"status":"healthy","service":"meeting-assistant"} +``` + +## Electron Client Deployment + +### 1. Setup + +```bash +cd client + +# Install dependencies +npm install +``` + +### 2. Development + +```bash +npm start +``` + +### 3. Build for Distribution + +```bash +# Build portable executable +npm run build +``` + +The executable will be in `client/dist/`. + +## Transcription Sidecar + +### 1. Setup + +```bash +cd sidecar + +# Create virtual environment +python -m venv venv +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt +pip install pyinstaller +``` + +### 2. Download Whisper Model + +The model will be downloaded automatically on first run. For faster startup, pre-download: + +```python +from faster_whisper import WhisperModel +model = WhisperModel("small", device="cpu", compute_type="int8") +``` + +### 3. Build Executable + +```bash +python build.py +``` + +The executable will be in `sidecar/dist/transcriber`. + +### 4. Package with Electron + +Copy `sidecar/dist/` to `client/sidecar/` before building Electron app. + +## Database Setup + +The backend will automatically create tables on first startup. To manually verify: + +```sql +USE db_A060; +SHOW TABLES LIKE 'meeting_%'; +``` + +Expected tables: +- `meeting_users` +- `meeting_records` +- `meeting_conclusions` +- `meeting_action_items` + +## Testing + +### Backend Tests + +```bash +cd backend +pytest tests/ -v +``` + +### Performance Verification + +On target hardware (i5/8GB): +1. Start the Electron app +2. Record 1 minute of audio +3. Verify transcription completes within acceptable time +4. Test AI summarization with the transcript + +## Troubleshooting + +### Database Connection Issues + +1. Verify MySQL is accessible from server +2. Check firewall rules for port 33306 +3. Verify credentials in .env + +### Dify API Issues + +1. Verify API key is valid +2. Check Dify service status +3. Review timeout settings for long transcripts + +### Transcription Issues + +1. Verify microphone permissions +2. Check sidecar executable runs standalone +3. Review audio format (16kHz, 16-bit, mono) + +## Security Notes + +- Never commit `.env` files +- Keep JWT_SECRET secure and unique per deployment +- Ensure HTTPS in production +- Regular security updates for dependencies diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..298f11f --- /dev/null +++ b/PRD.md @@ -0,0 +1,61 @@ +1. ็”ขๅ“ๆฆ‚่ฟฐ +ๆœฌ็ณป็ตฑ็‚บไผๆฅญ็ดšๆœƒ่ญฐ็Ÿฅ่ญ˜็ฎก็†่งฃๆฑบๆ–นๆกˆใ€‚ๅ‰็ซฏๆŽก็”จ Electron ้€ฒ่กŒ้‚Š็ทฃ้‹็ฎ—๏ผˆ้›ข็ทš่ชž้Ÿณ่ฝ‰ๅฏซ๏ผ‰๏ผŒๅพŒ็ซฏๆ•ดๅˆๅ…ฌๅธ็พๆœ‰ Auth APIใ€MySQL ่ณ‡ๆ–™ๅบซ่ˆ‡ Dify LLM ๆœๅ‹™ใ€‚ๆ—จๅœจ่งฃๆฑบๆœƒ่ญฐ่จ˜้Œ„่€—ๆ™‚ๅ•้กŒ๏ผŒไธฆ้€้Ž็ตๆง‹ๅŒ–่ณ‡ๆ–™้€ฒ่กŒๅพŒ็บŒ่ฟฝ่นคใ€‚ + +2. ๅŠŸ่ƒฝ้œ€ๆฑ‚ (Functional Requirements) +2.1 ่บซไปฝ้ฉ—่ญ‰ (Authentication) +FR-Auth-01 ็™ปๅ…ฅๆฉŸๅˆถ๏ผš + +ไฝฟ็”จๅ…ฌๅธ API (https://pj-auth-api.vercel.app/api/auth/login) ้€ฒ่กŒ้ฉ—่ญ‰ใ€‚ + +ๆ”ฏๆด็Ÿญๆ•ˆ Token ๆฉŸๅˆถ๏ผŒClient ็ซฏ้œ€ๅฏฆไฝœ่‡ชๅ‹•็บŒ็ฐฝ (Auto-Refresh) ้‚่ผฏไปฅ็ถญๆŒ้•ทๆ™‚้–“ๆœƒ่ญฐ้€ฃ็ทšใ€‚ + +FR-Auth-02 ๆฌŠ้™็ฎก็†๏ผš + +้ ่จญ็ฎก็†ๅ“กๅธณ่™Ÿ๏ผšymirliu@panjit.com.tw (ๆ“ๆœ‰ๆ‰€ๆœ‰ๆœƒ่ญฐๆชข่ฆ–่ˆ‡ Excel ๆจกๆฟ็ฎก็†ๆฌŠ้™)ใ€‚ + +2.2 ๆœƒ่ญฐๅปบ็ซ‹่ˆ‡ไธญ็นผ่ณ‡ๆ–™ (Metadata Input) +FR-Meta-01 ๅฟ…ๅกซๆฌ„ไฝ๏ผš + +็”ฑๆ–ผ AI ็„กๆณ•ๆ†‘็ฉบๅพ—็Ÿฅ้ƒจๅˆ†่ณ‡่จŠ๏ผŒ็ณป็ตฑ้œ€ๅœจใ€Œๅปบ็ซ‹ๆœƒ่ญฐใ€ๆˆ–ใ€Œๆœƒ่ญฐ่ณ‡่จŠใ€้ ้ขๆไพ›ไปฅไธ‹ๆ‰‹ๅ‹•่ผธๅ…ฅๆฌ„ไฝ๏ผš + +ๆœƒ่ญฐไธป้กŒ (Subject) + +ๆœƒ่ญฐๆ™‚้–“ (Date/Time) + +ๆœƒ่ญฐไธปๅธญ (Chairperson) + +ๆœƒ่ญฐๅœฐ้ปž (Location) + +ๆœƒ่ญฐ่จ˜้Œ„ไบบ (Recorder) - ้ ่จญๅธถๅ…ฅ็™ปๅ…ฅ่€… + +ๆœƒ่ญฐๅƒ่ˆ‡ไบบๅ“ก (Attendees) + +2.3 ๆ ธๅฟƒ่ฝ‰ๅฏซ่ˆ‡็ทจ่ผฏ (Core Transcription) +FR-Core-01 ้‚Š็ทฃ่ฝ‰ๅฏซ๏ผš ไฝฟ็”จ i5/8G ็ญ†้›ปๆœฌๅœฐ่ท‘ faster-whisper (int8) ๆจกๅž‹๏ผŒไธฆๅŠ ไธŠ OpenCC ๅผทๅˆถ็น้ซ”ๅŒ–ใ€‚ + +FR-Core-02 ๅณๆ™‚ไฟฎๆญฃ๏ผš ๆ”ฏๆด้›™ๆฌ„ไป‹้ข๏ผŒๅทฆๅด้กฏ็คบ AI ้€ๅญ—็จฟ๏ผŒๅณๅด็‚บ็ตๆง‹ๅŒ–็ญ†่จ˜ๅ€ใ€‚ + +2.4 AI ๆ™บๆ…งๆ‘˜่ฆ (LLM Integration) +FR-LLM-01 Dify ๆ•ดๅˆ๏ผš + +ไธฒๆŽฅ https://dify.theaken.com/v1ใ€‚ + +ๅฐ‡้€ๅญ—็จฟ้€ๅพ€ Dify๏ผŒไธฆ่ฆๆฑ‚ๅ›žๅ‚ณๅŒ…ๅซไปฅไธ‹่ณ‡่จŠ็š„็ตๆง‹ๅŒ–่ณ‡ๆ–™๏ผš + +ๆœƒ่ญฐ็ต่ซ– (Conclusions) + +ๅพ…่พฆไบ‹้ … (Action Items)๏ผš้œ€่งฃๆžๅ‡บ ๅ…งๅฎนใ€่ฒ ่ฒฌไบบใ€้ ่จˆๅฎŒๆˆๆ—ฅใ€‚ + +FR-LLM-02 ่ณ‡ๆ–™่ฃœๅ…จ๏ผš ่‹ฅ AI ็„กๆณ•่ญ˜ๅˆฅ่ฒ ่ฒฌไบบๆˆ–ๆ—ฅๆœŸ๏ผŒUI ้œ€ๆไพ›ไป‹้ข่ฎ“ไฝฟ็”จ่€…ๆ‰‹ๅ‹•่ฃœๅกซใ€‚ + +2.5 ่ณ‡ๆ–™ๅบซ่ˆ‡่ฟฝ่นค (Database & Tracking) +FR-DB-01 ่ณ‡ๆ–™้š”้›ข๏ผš ๆ‰€ๆœ‰่ณ‡ๆ–™่กจๅฟ…้ ˆๅŠ ไธŠ meeting_ ๅ‰็ถดใ€‚ + +FR-DB-02 ไบ‹้ …็ทจ่™Ÿ๏ผš ็ณป็ตฑ้œ€่‡ชๅ‹•็‚บๆฏไธ€ๆขใ€Œๆœƒ่ญฐ็ต่ซ–ใ€่ˆ‡ใ€Œๅพ…่พฆไบ‹้ …ใ€็”ข็”Ÿๅ”ฏไธ€็ทจ่™Ÿ (ID)๏ผŒไปฅไพฟๅพŒ็บŒ่ฟฝ่นคๅŸท่กŒ็พๆณใ€‚ + +2.6 ๅ ฑ่กจ่ผธๅ‡บ (Export) +FR-Export-01 Excel ็”Ÿๆˆ๏ผš + +ๅพŒ็ซฏๆ นๆ“š Template ็”Ÿๆˆ Excelใ€‚ + +้œ€ๅŒ…ๅซๆ‰€ๆœ‰ FR-Meta-01 ๅŠ FR-LLM-01 ๅฎš็พฉไน‹ๆฌ„ไฝใ€‚ \ No newline at end of file diff --git a/SDD.md b/SDD.md new file mode 100644 index 0000000..7ee2f5d --- /dev/null +++ b/SDD.md @@ -0,0 +1,143 @@ +1. ็ณป็ตฑๆžถๆง‹ๅœ– (System Architecture) +Plaintext + +[Client: Electron App] + | + |-- (1. Auth API) --> [Ext: PJ-Auth API (Vercel)] + | + |-- (2. Meeting Data) --> [Middleware Server (Python FastAPI)] + | + |-- (3. SQL Query) --> [DB: MySQL (Shared)] + | + |-- (4. Summarize) --> [Ext: Dify LLM] +ๆณจๆ„๏ผš ็‚บไบ†ๅฎ‰ๅ…จ๏ผŒ่ณ‡ๆ–™ๅบซ้€ฃ็ทš่ณ‡่จŠ่ˆ‡ Dify API Key ๅšด็ฆๆ‰“ๅŒ…ๅœจ Electron Client ็ซฏ๏ผŒๅฟ…้ ˆๆ”พๅœจ Middleware Serverใ€‚ + +2. ่ณ‡ๆ–™ๅบซ่จญ่จˆ (Database Schema) +Host: mysql.theaken.com (Port 33306) + +User/Pass: A060 / WLeSCi0yhtc7 + +DB Name: db_A060 + +Prefix: meeting_ + +SQL + +-- 1. ไฝฟ็”จ่€…่กจ (่ˆ‡ Auth API ๅฐๆ‡‰๏ผŒๆœฌๅœฐๅฟซๅ–็”จ) +CREATE TABLE meeting_users ( + user_id INT PRIMARY KEY AUTO_INCREMENT, + email VARCHAR(100) UNIQUE NOT NULL, -- ๅฐๆ‡‰ ymirliu@panjit.com.tw + display_name VARCHAR(50), + role ENUM('admin', 'user') DEFAULT 'user', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. ๆœƒ่ญฐไธป่กจ +CREATE TABLE meeting_records ( + meeting_id INT PRIMARY KEY AUTO_INCREMENT, + uuid VARCHAR(64) UNIQUE, -- ็ณป็ตฑๅ”ฏไธ€่ญ˜ๅˆฅ็ขผ + subject VARCHAR(200) NOT NULL, -- ๆœƒ่ญฐไธป้กŒ + meeting_time DATETIME NOT NULL, -- ๆœƒ่ญฐๆ™‚้–“ + location VARCHAR(100), -- ๆœƒ่ญฐๅœฐ้ปž + chairperson VARCHAR(50), -- ๆœƒ่ญฐไธปๅธญ + recorder VARCHAR(50), -- ๆœƒ่ญฐ่จ˜้Œ„ไบบ + attendees TEXT, -- ๅƒ่ˆ‡ไบบๅ“ก (้€—่™Ÿๅˆ†้š”ๆˆ– JSON) + transcript_blob LONGTEXT, -- AI ๅŽŸๅง‹้€ๅญ—็จฟ + created_by VARCHAR(100), -- ๅปบ็ซ‹่€… Email + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 3. ๆœƒ่ญฐ็ต่ซ–่กจ (Conclusions) +CREATE TABLE meeting_conclusions ( + conclusion_id INT PRIMARY KEY AUTO_INCREMENT, + meeting_id INT, + content TEXT, + system_code VARCHAR(20), -- ๆœƒ่ญฐ็ต่ซ–็ทจ่™Ÿ (ๅฆ‚: C-20251210-01) + FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) +); + +-- 4. ๅพ…่พฆ่ฟฝ่นค่กจ (Action Items) +CREATE TABLE meeting_action_items ( + action_id INT PRIMARY KEY AUTO_INCREMENT, + meeting_id INT, + content TEXT, -- ่ฟฝ่นคไบ‹้ …ๅ…งๅฎน + owner VARCHAR(50), -- ่ฒ ่ฒฌไบบ + due_date DATE, -- ้ ่จˆๅฎŒๆˆๆ—ฅๆœŸ + status ENUM('Open', 'In Progress', 'Done', 'Delayed') DEFAULT 'Open', -- ๅŸท่กŒ็พๆณ + system_code VARCHAR(20), -- ๆœƒ่ญฐไบ‹้ …็ทจ่™Ÿ (ๅฆ‚: A-20251210-01) + FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) +); +3. Middleware Server ้…็ฝฎ (FastAPI ็ฏ„ไพ‹) +Client ็ซฏไธ็›ดๆŽฅ้€ฃ MySQL๏ผŒ่€Œๆ˜ฏๅ‘ผๅซๆญค Middlewareใ€‚ + +3.1 ็’ฐๅขƒ่ฎŠๆ•ธ (.env) +Ini, TOML + +DB_HOST=mysql.theaken.com +DB_PORT=33306 +DB_USER=A060 +DB_PASS=WLeSCi0yhtc7 +DB_NAME=db_A060 +AUTH_API_URL=https://pj-auth-api.vercel.app/api/auth/login +DIFY_API_URL=https://dify.theaken.com/v1 +DIFY_API_KEY=app-xxxxxxxxxxx # ้œ€่‡ณ Dify ๅพŒๅฐๅ–ๅพ— +ADMIN_EMAIL=ymirliu@panjit.com.tw +3.2 API ไป‹้ข่ฆๆ ผ +A. ็™ปๅ…ฅไปฃ็† (Proxy) +Endpoint: POST /api/login + +Logic: Middleware ่ฝ‰็™ผ่ซ‹ๆฑ‚่‡ณ pj-auth-api.vercel.appใ€‚ๆˆๅŠŸๅพŒ๏ผŒ่‹ฅ่ฉฒ Email ็‚บ ymirliu@panjit.com.tw๏ผŒๅ‰‡ๅœจๅ›žๅ‚ณ็š„ JWT Payload ไธญๆจ™่จ˜ { "role": "admin" }ใ€‚ + +B. ไธŠๅ‚ณ/ๅŒๆญฅๆœƒ่ญฐ +Endpoint: POST /api/meetings + +Payload: + +JSON + +{ + "meta": { "subject": "...", "chairperson": "...", ... }, + "transcript": "...", + "conclusions": [ { "content": "..." } ], + "actions": [ { "content": "...", "owner": "...", "due_date": "..." } ] +} +Logic: + +Insert into meeting_records. + +Loop insert meeting_conclusions (่‡ชๅ‹•็”Ÿๆˆ ID: C-{YYYYMMDD}-{Seq}). + +Loop insert meeting_action_items (่‡ชๅ‹•็”Ÿๆˆ ID: A-{YYYYMMDD}-{Seq}). + +C. Dify ๆ‘˜่ฆ่ซ‹ๆฑ‚ +Endpoint: POST /api/ai/summarize + +Payload: { "transcript": "..." } + +Logic: ๅ‘ผๅซ Dify APIใ€‚ + +Dify Prompt ่จญๅฎš (System): + +Plaintext + +ไฝ ๆ˜ฏไธ€ๅ€‹ๆœƒ่ญฐ่จ˜้Œ„ๅŠฉๆ‰‹ใ€‚่ซ‹ๆ นๆ“š้€ๅญ—็จฟ๏ผŒๅ›žๅ‚ณ JSON ๆ ผๅผใ€‚ +ๅฟ…่ฆๆฌ„ไฝ๏ผš +1. conclusions (Array): ็ต่ซ–ๅ…งๅฎน +2. action_items (Array): { content, owner, due_date } +่‹ฅ้€ๅญ—็จฟๆœชๆๅŠๆ—ฅๆœŸๆˆ–่ฒ ่ฒฌไบบ๏ผŒ่ฉฒๆฌ„ไฝ่ซ‹็•™็ฉบๅญ—ไธฒใ€‚ +D. Excel ๅŒฏๅ‡บ +Endpoint: POST /api/meetings/{id}/export + +Logic: + +SQL Join ๆŸฅ่ฉข records, conclusions, action_itemsใ€‚ + +Load template.xlsx. + +Replace Placeholders: + +{{subject}}, {{time}}, {{chair}}... + +Table Filling: ๅ‹•ๆ…‹ๆ’ๅ…ฅ Rows ๅกซๅฏซ็ต่ซ–่ˆ‡ๅพ…่พฆไบ‹้ …ใ€‚ + +Return File Stream. \ No newline at end of file diff --git a/TDD.md b/TDD.md new file mode 100644 index 0000000..b8a1c82 --- /dev/null +++ b/TDD.md @@ -0,0 +1,46 @@ +1. ๅ–ฎๅ…ƒๆธฌ่ฉฆ (Middleware) +Test-DB-Connect: + +ๅ˜—่ฉฆ้€ฃ็ทš่‡ณ mysql.theaken.com:33306ใ€‚ + +้ฉ—่ญ‰ meeting_ ๅ‰็ถด่กจๆ˜ฏๅฆๅญ˜ๅœจ๏ผŒ่‹ฅไธๅญ˜ๅœจๅ‰‡ๅŸท่กŒ CREATE TABLE ๅˆๅง‹ๅŒ–่…ณๆœฌใ€‚ + +้ฉ—่ญ‰ ymirliu@panjit.com.tw ๆ˜ฏๅฆ่ƒฝ่ขซ่ญ˜ๅˆฅ็‚บ็ฎก็†ๅ“กใ€‚ + +Test-Dify-Proxy: + +็™ผ้€ Mock ๆ–‡ๅญ—่‡ณ /api/ai/summarizeใ€‚ + +้ฉ—่ญ‰ Server ่ƒฝๅฆๆญฃ็ขบ่งฃๆž Dify ๅ›žๅ‚ณ็š„ JSON๏ผŒไธฆ่™•็† Dify ๅฏ่ƒฝ็š„ Timeout ๆˆ– 500 ้Œฏ่ชคใ€‚ + +2. ๆ•ดๅˆๆธฌ่ฉฆ (Client-Server) +Test-Auth-Flow: + +Client ่ผธๅ…ฅๅธณๅฏ† -> Middleware -> Vercel Auth APIใ€‚ + +้ฉ—่ญ‰ Token ๅ–ๅพ—ๅพŒ๏ผŒClient ่ƒฝๅฆๆˆๅŠŸๅญ˜ๅ– /api/meetingsใ€‚ + +้‡่ฆ๏ผš ้ฉ—่ญ‰ Token ้ŽๆœŸๆจกๆ“ฌ๏ผˆๆ‰‹ๅ‹•ๅคฑๆ•ˆ Token๏ผ‰๏ผŒClient ๆ””ๆˆชๅ™จๆ˜ฏๅฆ่งธ็™ผ้‡่ฉฆใ€‚ + +Test-Full-Cycle: + +ๅปบ็ซ‹๏ผš ๅกซๅฏซ่กจๅ–ฎ๏ผˆไธปๅธญใ€ๅœฐ้ปž...๏ผ‰ใ€‚ + +้Œ„้Ÿณ๏ผš ๆจกๆ“ฌ 1 ๅˆ†้˜่ชž้Ÿณ่ผธๅ…ฅใ€‚ + +ๆ‘˜่ฆ๏ผš ้ปžๆ“Šใ€ŒAI ๆ‘˜่ฆใ€๏ผŒ็ขบ่ช Dify ๅ›žๅ‚ณ่ณ‡ๆ–™ๅกซๅ…ฅๅณๅดๆฌ„ไฝใ€‚ + +่ฃœๅกซ๏ผš ๆ‰‹ๅ‹•ไฟฎๆ”นใ€Œ่ฒ ่ฒฌไบบใ€ๆฌ„ไฝใ€‚ + +ๅญ˜ๆช”๏ผš ๆชขๆŸฅ MySQL ่ณ‡ๆ–™ๅบซๆ˜ฏๅฆๆญฃ็ขบๅฏซๅ…ฅ meeting_action_items ไธ” status ้ ่จญ็‚บ 'Open'ใ€‚ + +ๅŒฏๅ‡บ๏ผš ไธ‹่ผ‰ Excel๏ผŒๆชขๆŸฅๆ‰€ๆœ‰ๆฌ„ไฝ๏ผˆๅŒ…ๅซๆ‰‹ๅ‹•่ฃœๅกซ็š„่ฒ ่ฒฌไบบ๏ผ‰ๆ˜ฏๅฆๆญฃ็ขบ้กฏ็คบใ€‚ + +3. ้ƒจ็ฝฒๆชขๆ ธ่กจ (Deployment Checklist) +[ ] Middleware Server ็š„ requirements.txt ๅŒ…ๅซ mysql-connector-python, fastapi, requests, openpyxlใ€‚ + +[ ] Middleware Server ็š„็’ฐๅขƒ่ฎŠๆ•ธ (.env) ๅทฒ่จญๅฎšไธ”ไฟๅฏ†ใ€‚ + +[ ] Client ็ซฏ electron-builder ่จญๅฎš target: portableใ€‚ + +[ ] Client ็ซฏ Python Sidecar ๅทฒๅŒ…ๅซ faster-whisper, opencc ไธฆๅฎŒๆˆ PyInstaller ๆ‰“ๅŒ…ใ€‚ \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..3d99e27 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +# Database Configuration +DB_HOST=mysql.theaken.com +DB_PORT=33306 +DB_USER=A060 +DB_PASS=your_password_here +DB_NAME=db_A060 + +# External APIs +AUTH_API_URL=https://pj-auth-api.vercel.app/api/auth/login +DIFY_API_URL=https://dify.theaken.com/v1 +DIFY_API_KEY=app-xxxxxxxxxxx + +# Application Settings +ADMIN_EMAIL=ymirliu@panjit.com.tw +JWT_SECRET=your_jwt_secret_here diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e09403c --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Meeting Assistant Backend diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..a103e8c --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,24 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Settings: + DB_HOST: str = os.getenv("DB_HOST", "mysql.theaken.com") + DB_PORT: int = int(os.getenv("DB_PORT", "33306")) + DB_USER: str = os.getenv("DB_USER", "A060") + DB_PASS: str = os.getenv("DB_PASS", "") + DB_NAME: str = os.getenv("DB_NAME", "db_A060") + + AUTH_API_URL: str = os.getenv( + "AUTH_API_URL", "https://pj-auth-api.vercel.app/api/auth/login" + ) + DIFY_API_URL: str = os.getenv("DIFY_API_URL", "https://dify.theaken.com/v1") + DIFY_API_KEY: str = os.getenv("DIFY_API_KEY", "") + + ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL", "ymirliu@panjit.com.tw") + JWT_SECRET: str = os.getenv("JWT_SECRET", "meeting-assistant-secret") + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..bafe5d6 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,96 @@ +import mysql.connector +from mysql.connector import pooling +from contextlib import contextmanager +from .config import settings + +connection_pool = None + + +def init_db_pool(): + global connection_pool + connection_pool = pooling.MySQLConnectionPool( + pool_name="meeting_pool", + pool_size=5, + host=settings.DB_HOST, + port=settings.DB_PORT, + user=settings.DB_USER, + password=settings.DB_PASS, + database=settings.DB_NAME, + ) + return connection_pool + + +@contextmanager +def get_db_connection(): + conn = connection_pool.get_connection() + try: + yield conn + finally: + conn.close() + + +@contextmanager +def get_db_cursor(commit=False): + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + try: + yield cursor + if commit: + conn.commit() + finally: + cursor.close() + + +def init_tables(): + """Create all required tables if they don't exist.""" + create_statements = [ + """ + CREATE TABLE IF NOT EXISTS meeting_users ( + user_id INT PRIMARY KEY AUTO_INCREMENT, + email VARCHAR(100) UNIQUE NOT NULL, + display_name VARCHAR(50), + role ENUM('admin', 'user') DEFAULT 'user', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS meeting_records ( + meeting_id INT PRIMARY KEY AUTO_INCREMENT, + uuid VARCHAR(64) UNIQUE, + subject VARCHAR(200) NOT NULL, + meeting_time DATETIME NOT NULL, + location VARCHAR(100), + chairperson VARCHAR(50), + recorder VARCHAR(50), + attendees TEXT, + transcript_blob LONGTEXT, + created_by VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS meeting_conclusions ( + conclusion_id INT PRIMARY KEY AUTO_INCREMENT, + meeting_id INT, + content TEXT, + system_code VARCHAR(20), + FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE + ) + """, + """ + CREATE TABLE IF NOT EXISTS meeting_action_items ( + action_id INT PRIMARY KEY AUTO_INCREMENT, + meeting_id INT, + content TEXT, + owner VARCHAR(50), + due_date DATE, + status ENUM('Open', 'In Progress', 'Done', 'Delayed') DEFAULT 'Open', + system_code VARCHAR(20), + FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE + ) + """, + ] + + with get_db_cursor(commit=True) as cursor: + for statement in create_statements: + cursor.execute(statement) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..75293b7 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,44 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager + +from .database import init_db_pool, init_tables +from .routers import auth, meetings, ai, export + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + init_db_pool() + init_tables() + yield + # Shutdown (cleanup if needed) + + +app = FastAPI( + title="Meeting Assistant API", + description="Enterprise meeting knowledge management API", + version="1.0.0", + lifespan=lifespan, +) + +# CORS configuration for Electron client +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(auth.router, prefix="/api", tags=["Authentication"]) +app.include_router(meetings.router, prefix="/api", tags=["Meetings"]) +app.include_router(ai.router, prefix="/api", tags=["AI"]) +app.include_router(export.router, prefix="/api", tags=["Export"]) + + +@app.get("/api/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "meeting-assistant"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..b0f084d --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,37 @@ +from .schemas import ( + LoginRequest, + LoginResponse, + TokenPayload, + MeetingCreate, + MeetingUpdate, + MeetingResponse, + MeetingListResponse, + ConclusionCreate, + ConclusionResponse, + ActionItemCreate, + ActionItemUpdate, + ActionItemResponse, + SummarizeRequest, + SummarizeResponse, + ActionItemStatus, + UserRole, +) + +__all__ = [ + "LoginRequest", + "LoginResponse", + "TokenPayload", + "MeetingCreate", + "MeetingUpdate", + "MeetingResponse", + "MeetingListResponse", + "ConclusionCreate", + "ConclusionResponse", + "ActionItemCreate", + "ActionItemUpdate", + "ActionItemResponse", + "SummarizeRequest", + "SummarizeResponse", + "ActionItemStatus", + "UserRole", +] diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000..a7cd772 --- /dev/null +++ b/backend/app/models/schemas.py @@ -0,0 +1,128 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime, date +from enum import Enum + + +class ActionItemStatus(str, Enum): + OPEN = "Open" + IN_PROGRESS = "In Progress" + DONE = "Done" + DELAYED = "Delayed" + + +class UserRole(str, Enum): + ADMIN = "admin" + USER = "user" + + +# Auth schemas +class LoginRequest(BaseModel): + email: str + password: str + + +class LoginResponse(BaseModel): + token: str + email: str + role: str + + +class TokenPayload(BaseModel): + email: str + role: str + exp: Optional[int] = None + + +# Meeting schemas +class ConclusionCreate(BaseModel): + content: str + + +class ConclusionResponse(BaseModel): + conclusion_id: int + meeting_id: int + content: str + system_code: Optional[str] = None + + +class ActionItemCreate(BaseModel): + content: str + owner: Optional[str] = "" + due_date: Optional[date] = None + + +class ActionItemUpdate(BaseModel): + content: Optional[str] = None + owner: Optional[str] = None + due_date: Optional[date] = None + status: Optional[ActionItemStatus] = None + + +class ActionItemResponse(BaseModel): + action_id: int + meeting_id: int + content: str + owner: Optional[str] = None + due_date: Optional[date] = None + status: ActionItemStatus + system_code: Optional[str] = None + + +class MeetingCreate(BaseModel): + subject: str + meeting_time: datetime + location: Optional[str] = "" + chairperson: Optional[str] = "" + recorder: Optional[str] = "" + attendees: Optional[str] = "" + transcript_blob: Optional[str] = "" + conclusions: Optional[List[ConclusionCreate]] = [] + actions: Optional[List[ActionItemCreate]] = [] + + +class MeetingUpdate(BaseModel): + subject: Optional[str] = None + meeting_time: Optional[datetime] = None + location: Optional[str] = None + chairperson: Optional[str] = None + recorder: Optional[str] = None + attendees: Optional[str] = None + transcript_blob: Optional[str] = None + conclusions: Optional[List[ConclusionCreate]] = None + actions: Optional[List[ActionItemCreate]] = None + + +class MeetingResponse(BaseModel): + meeting_id: int + uuid: str + subject: str + meeting_time: datetime + location: Optional[str] = None + chairperson: Optional[str] = None + recorder: Optional[str] = None + attendees: Optional[str] = None + transcript_blob: Optional[str] = None + created_by: Optional[str] = None + created_at: datetime + conclusions: List[ConclusionResponse] = [] + actions: List[ActionItemResponse] = [] + + +class MeetingListResponse(BaseModel): + meeting_id: int + uuid: str + subject: str + meeting_time: datetime + chairperson: Optional[str] = None + created_at: datetime + + +# AI schemas +class SummarizeRequest(BaseModel): + transcript: str + + +class SummarizeResponse(BaseModel): + conclusions: List[str] + action_items: List[ActionItemCreate] diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..8e7b0e2 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +# Router modules diff --git a/backend/app/routers/ai.py b/backend/app/routers/ai.py new file mode 100644 index 0000000..6613758 --- /dev/null +++ b/backend/app/routers/ai.py @@ -0,0 +1,102 @@ +from fastapi import APIRouter, HTTPException, Depends +import httpx +import json + +from ..config import settings +from ..models import SummarizeRequest, SummarizeResponse, ActionItemCreate, TokenPayload +from .auth import get_current_user + +router = APIRouter() + + +@router.post("/ai/summarize", response_model=SummarizeResponse) +async def summarize_transcript( + request: SummarizeRequest, current_user: TokenPayload = Depends(get_current_user) +): + """ + Send transcript to Dify for AI summarization. + Returns structured conclusions and action items. + """ + if not settings.DIFY_API_KEY: + raise HTTPException(status_code=503, detail="Dify API not configured") + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + f"{settings.DIFY_API_URL}/chat-messages", + headers={ + "Authorization": f"Bearer {settings.DIFY_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "inputs": {}, + "query": request.transcript, + "response_mode": "blocking", + "user": current_user.email, + }, + timeout=120.0, # Long timeout for LLM processing + ) + + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail=f"Dify API error: {response.text}", + ) + + data = response.json() + answer = data.get("answer", "") + + # Try to parse structured JSON from Dify response + parsed = parse_dify_response(answer) + + return SummarizeResponse( + conclusions=parsed["conclusions"], + action_items=[ + ActionItemCreate( + content=item.get("content", ""), + owner=item.get("owner", ""), + due_date=item.get("due_date"), + ) + for item in parsed["action_items"] + ], + ) + + except httpx.TimeoutException: + raise HTTPException( + status_code=504, detail="Dify API timeout - transcript may be too long" + ) + except httpx.RequestError as e: + raise HTTPException(status_code=503, detail=f"Dify API unavailable: {str(e)}") + + +def parse_dify_response(answer: str) -> dict: + """ + Parse Dify response to extract conclusions and action items. + Attempts JSON parsing first, then falls back to text parsing. + """ + # Try to find JSON in the response + try: + # Look for JSON block + if "```json" in answer: + json_start = answer.index("```json") + 7 + json_end = answer.index("```", json_start) + json_str = answer[json_start:json_end].strip() + elif "{" in answer and "}" in answer: + # Try to find JSON object + json_start = answer.index("{") + json_end = answer.rindex("}") + 1 + json_str = answer[json_start:json_end] + else: + raise ValueError("No JSON found") + + data = json.loads(json_str) + return { + "conclusions": data.get("conclusions", []), + "action_items": data.get("action_items", []), + } + except (ValueError, json.JSONDecodeError): + # Fallback: return raw answer as single conclusion + return { + "conclusions": [answer] if answer else [], + "action_items": [], + } diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..39ad7e8 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,109 @@ +from fastapi import APIRouter, HTTPException, Depends, Header +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import httpx +from jose import jwt, JWTError +from datetime import datetime, timedelta +from typing import Optional + +from ..config import settings +from ..models import LoginRequest, LoginResponse, TokenPayload + +router = APIRouter() +security = HTTPBearer() + + +def create_token(email: str, role: str) -> str: + """Create a JWT token with email and role.""" + payload = { + "email": email, + "role": role, + "exp": datetime.utcnow() + timedelta(hours=24), + } + return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256") + + +def decode_token(token: str) -> TokenPayload: + """Decode and validate a JWT token.""" + try: + payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"]) + return TokenPayload(**payload) + except JWTError: + raise HTTPException(status_code=401, detail="Invalid token") + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> TokenPayload: + """Dependency to get current authenticated user.""" + token = credentials.credentials + try: + payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"]) + return TokenPayload(**payload) + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=401, + detail={"error": "token_expired", "message": "Token has expired"}, + ) + except JWTError: + raise HTTPException( + status_code=401, + detail={"error": "invalid_token", "message": "Invalid token"}, + ) + + +def is_admin(user: TokenPayload) -> bool: + """Check if user has admin role.""" + return user.role == "admin" + + +@router.post("/login", response_model=LoginResponse) +async def login(request: LoginRequest): + """ + Proxy login to company Auth API. + Adds admin role for ymirliu@panjit.com.tw. + """ + async with httpx.AsyncClient() as client: + try: + response = await client.post( + settings.AUTH_API_URL, + json={"username": request.email, "password": request.password}, + timeout=30.0, + ) + + if response.status_code == 401: + raise HTTPException(status_code=401, detail="Invalid credentials") + + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail="Authentication service error", + ) + + # Parse response from external Auth API + auth_data = response.json() + + # Check if authentication was successful + if not auth_data.get("success"): + error_msg = auth_data.get("error", "Authentication failed") + raise HTTPException(status_code=401, detail=error_msg) + + # Determine role + role = "admin" if request.email == settings.ADMIN_EMAIL else "user" + + # Create our own token with role info + token = create_token(request.email, role) + + return LoginResponse(token=token, email=request.email, role=role) + + except httpx.TimeoutException: + raise HTTPException(status_code=504, detail="Authentication service timeout") + except httpx.RequestError: + raise HTTPException( + status_code=503, detail="Authentication service unavailable" + ) + + +@router.get("/me") +async def get_me(current_user: TokenPayload = Depends(get_current_user)): + """Get current user information.""" + return {"email": current_user.email, "role": current_user.role} diff --git a/backend/app/routers/export.py b/backend/app/routers/export.py new file mode 100644 index 0000000..7fa1545 --- /dev/null +++ b/backend/app/routers/export.py @@ -0,0 +1,177 @@ +from fastapi import APIRouter, HTTPException, Depends +from fastapi.responses import StreamingResponse +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Font, Alignment, Border, Side +import io +import os + +from ..database import get_db_cursor +from ..models import TokenPayload +from .auth import get_current_user, is_admin + +router = APIRouter() + +TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "..", "templates") + + +def create_default_workbook(meeting: dict, conclusions: list, actions: list) -> Workbook: + """Create Excel workbook with meeting data.""" + wb = Workbook() + ws = wb.active + ws.title = "Meeting Record" + + # Styles + header_font = Font(bold=True, size=14) + label_font = Font(bold=True) + thin_border = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), + ) + + # Title + ws.merge_cells("A1:F1") + ws["A1"] = "Meeting Record" + ws["A1"].font = Font(bold=True, size=16) + ws["A1"].alignment = Alignment(horizontal="center") + + # Metadata section + row = 3 + metadata = [ + ("Subject", meeting.get("subject", "")), + ("Date/Time", str(meeting.get("meeting_time", ""))), + ("Location", meeting.get("location", "")), + ("Chairperson", meeting.get("chairperson", "")), + ("Recorder", meeting.get("recorder", "")), + ("Attendees", meeting.get("attendees", "")), + ] + + for label, value in metadata: + ws[f"A{row}"] = label + ws[f"A{row}"].font = label_font + ws.merge_cells(f"B{row}:F{row}") + ws[f"B{row}"] = value + row += 1 + + # Conclusions section + row += 1 + ws.merge_cells(f"A{row}:F{row}") + ws[f"A{row}"] = "Conclusions" + ws[f"A{row}"].font = header_font + row += 1 + + ws[f"A{row}"] = "Code" + ws[f"B{row}"] = "Content" + ws[f"A{row}"].font = label_font + ws[f"B{row}"].font = label_font + row += 1 + + for c in conclusions: + ws[f"A{row}"] = c.get("system_code", "") + ws.merge_cells(f"B{row}:F{row}") + ws[f"B{row}"] = c.get("content", "") + row += 1 + + # Action Items section + row += 1 + ws.merge_cells(f"A{row}:F{row}") + ws[f"A{row}"] = "Action Items" + ws[f"A{row}"].font = header_font + row += 1 + + headers = ["Code", "Content", "Owner", "Due Date", "Status"] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=row, column=col, value=header) + cell.font = label_font + cell.border = thin_border + row += 1 + + for a in actions: + ws.cell(row=row, column=1, value=a.get("system_code", "")).border = thin_border + ws.cell(row=row, column=2, value=a.get("content", "")).border = thin_border + ws.cell(row=row, column=3, value=a.get("owner", "")).border = thin_border + ws.cell(row=row, column=4, value=str(a.get("due_date", "") or "")).border = thin_border + ws.cell(row=row, column=5, value=a.get("status", "")).border = thin_border + row += 1 + + # Adjust column widths + ws.column_dimensions["A"].width = 18 + ws.column_dimensions["B"].width = 40 + ws.column_dimensions["C"].width = 15 + ws.column_dimensions["D"].width = 12 + ws.column_dimensions["E"].width = 12 + ws.column_dimensions["F"].width = 12 + + return wb + + +@router.get("/meetings/{meeting_id}/export") +async def export_meeting( + meeting_id: int, current_user: TokenPayload = Depends(get_current_user) +): + """Export meeting to Excel file.""" + with get_db_cursor() as cursor: + cursor.execute( + "SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,) + ) + meeting = cursor.fetchone() + + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + + # Check access + if not is_admin(current_user): + if ( + meeting["created_by"] != current_user.email + and meeting["recorder"] != current_user.email + and current_user.email not in (meeting["attendees"] or "") + ): + raise HTTPException(status_code=403, detail="Access denied") + + # Get conclusions + cursor.execute( + "SELECT * FROM meeting_conclusions WHERE meeting_id = %s", (meeting_id,) + ) + conclusions = cursor.fetchall() + + # Get action items + cursor.execute( + "SELECT * FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,) + ) + actions = cursor.fetchall() + + # Check for custom template + template_path = os.path.join(TEMPLATE_DIR, "template.xlsx") + if os.path.exists(template_path): + wb = load_workbook(template_path) + ws = wb.active + + # Replace placeholders + for row in ws.iter_rows(): + for cell in row: + if cell.value and isinstance(cell.value, str): + cell.value = ( + cell.value.replace("{{subject}}", meeting.get("subject", "")) + .replace("{{time}}", str(meeting.get("meeting_time", ""))) + .replace("{{location}}", meeting.get("location", "")) + .replace("{{chair}}", meeting.get("chairperson", "")) + .replace("{{recorder}}", meeting.get("recorder", "")) + .replace("{{attendees}}", meeting.get("attendees", "")) + ) + else: + # Use default template + wb = create_default_workbook(meeting, conclusions, actions) + + # Save to bytes buffer + buffer = io.BytesIO() + wb.save(buffer) + buffer.seek(0) + + filename = f"meeting_{meeting.get('uuid', meeting_id)}.xlsx" + + return StreamingResponse( + buffer, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/app/routers/meetings.py b/backend/app/routers/meetings.py new file mode 100644 index 0000000..a9cef6e --- /dev/null +++ b/backend/app/routers/meetings.py @@ -0,0 +1,372 @@ +from fastapi import APIRouter, HTTPException, Depends +from typing import List +import uuid +from datetime import date + +from ..database import get_db_cursor +from ..models import ( + MeetingCreate, + MeetingUpdate, + MeetingResponse, + MeetingListResponse, + ConclusionResponse, + ActionItemResponse, + ActionItemUpdate, + TokenPayload, +) +from .auth import get_current_user, is_admin + +router = APIRouter() + + +def generate_system_code(prefix: str, meeting_date: date, sequence: int) -> str: + """Generate system code like C-20251210-01 or A-20251210-01.""" + date_str = meeting_date.strftime("%Y%m%d") + return f"{prefix}-{date_str}-{sequence:02d}" + + +def get_next_sequence(cursor, prefix: str, date_str: str) -> int: + """Get next sequence number for a given prefix and date.""" + pattern = f"{prefix}-{date_str}-%" + cursor.execute( + """ + SELECT system_code FROM meeting_conclusions WHERE system_code LIKE %s + UNION + SELECT system_code FROM meeting_action_items WHERE system_code LIKE %s + ORDER BY system_code DESC LIMIT 1 + """, + (pattern, pattern), + ) + result = cursor.fetchone() + if result: + last_code = result["system_code"] + last_seq = int(last_code.split("-")[-1]) + return last_seq + 1 + return 1 + + +@router.post("/meetings", response_model=MeetingResponse) +async def create_meeting( + meeting: MeetingCreate, current_user: TokenPayload = Depends(get_current_user) +): + """Create a new meeting with optional conclusions and action items.""" + meeting_uuid = str(uuid.uuid4()) + recorder = meeting.recorder or current_user.email + meeting_date = meeting.meeting_time.date() + date_str = meeting_date.strftime("%Y%m%d") + + with get_db_cursor(commit=True) as cursor: + # Insert meeting record + cursor.execute( + """ + INSERT INTO meeting_records + (uuid, subject, meeting_time, location, chairperson, recorder, attendees, transcript_blob, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + meeting_uuid, + meeting.subject, + meeting.meeting_time, + meeting.location, + meeting.chairperson, + recorder, + meeting.attendees, + meeting.transcript_blob, + current_user.email, + ), + ) + meeting_id = cursor.lastrowid + + # Insert conclusions + conclusions = [] + seq = get_next_sequence(cursor, "C", date_str) + for conclusion in meeting.conclusions or []: + system_code = generate_system_code("C", meeting_date, seq) + cursor.execute( + """ + INSERT INTO meeting_conclusions (meeting_id, content, system_code) + VALUES (%s, %s, %s) + """, + (meeting_id, conclusion.content, system_code), + ) + conclusions.append( + ConclusionResponse( + conclusion_id=cursor.lastrowid, + meeting_id=meeting_id, + content=conclusion.content, + system_code=system_code, + ) + ) + seq += 1 + + # Insert action items + actions = [] + seq = get_next_sequence(cursor, "A", date_str) + for action in meeting.actions or []: + system_code = generate_system_code("A", meeting_date, seq) + cursor.execute( + """ + INSERT INTO meeting_action_items (meeting_id, content, owner, due_date, system_code) + VALUES (%s, %s, %s, %s, %s) + """, + (meeting_id, action.content, action.owner, action.due_date, system_code), + ) + actions.append( + ActionItemResponse( + action_id=cursor.lastrowid, + meeting_id=meeting_id, + content=action.content, + owner=action.owner, + due_date=action.due_date, + status="Open", + system_code=system_code, + ) + ) + seq += 1 + + # Fetch created meeting + cursor.execute( + "SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,) + ) + record = cursor.fetchone() + + return MeetingResponse( + meeting_id=record["meeting_id"], + uuid=record["uuid"], + subject=record["subject"], + meeting_time=record["meeting_time"], + location=record["location"], + chairperson=record["chairperson"], + recorder=record["recorder"], + attendees=record["attendees"], + transcript_blob=record["transcript_blob"], + created_by=record["created_by"], + created_at=record["created_at"], + conclusions=conclusions, + actions=actions, + ) + + +@router.get("/meetings", response_model=List[MeetingListResponse]) +async def list_meetings(current_user: TokenPayload = Depends(get_current_user)): + """List meetings. Admin sees all, users see only their own.""" + with get_db_cursor() as cursor: + if is_admin(current_user): + cursor.execute( + """ + SELECT meeting_id, uuid, subject, meeting_time, chairperson, created_at + FROM meeting_records ORDER BY meeting_time DESC + """ + ) + else: + cursor.execute( + """ + SELECT meeting_id, uuid, subject, meeting_time, chairperson, created_at + FROM meeting_records + WHERE created_by = %s OR recorder = %s OR attendees LIKE %s + ORDER BY meeting_time DESC + """, + ( + current_user.email, + current_user.email, + f"%{current_user.email}%", + ), + ) + records = cursor.fetchall() + + return [MeetingListResponse(**record) for record in records] + + +@router.get("/meetings/{meeting_id}", response_model=MeetingResponse) +async def get_meeting( + meeting_id: int, current_user: TokenPayload = Depends(get_current_user) +): + """Get meeting details with conclusions and action items.""" + with get_db_cursor() as cursor: + cursor.execute( + "SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,) + ) + record = cursor.fetchone() + + if not record: + raise HTTPException(status_code=404, detail="Meeting not found") + + # Check access + if not is_admin(current_user): + if ( + record["created_by"] != current_user.email + and record["recorder"] != current_user.email + and current_user.email not in (record["attendees"] or "") + ): + raise HTTPException(status_code=403, detail="Access denied") + + # Get conclusions + cursor.execute( + "SELECT * FROM meeting_conclusions WHERE meeting_id = %s", (meeting_id,) + ) + conclusions = [ConclusionResponse(**c) for c in cursor.fetchall()] + + # Get action items + cursor.execute( + "SELECT * FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,) + ) + actions = [ActionItemResponse(**a) for a in cursor.fetchall()] + + return MeetingResponse( + meeting_id=record["meeting_id"], + uuid=record["uuid"], + subject=record["subject"], + meeting_time=record["meeting_time"], + location=record["location"], + chairperson=record["chairperson"], + recorder=record["recorder"], + attendees=record["attendees"], + transcript_blob=record["transcript_blob"], + created_by=record["created_by"], + created_at=record["created_at"], + conclusions=conclusions, + actions=actions, + ) + + +@router.put("/meetings/{meeting_id}", response_model=MeetingResponse) +async def update_meeting( + meeting_id: int, + meeting: MeetingUpdate, + current_user: TokenPayload = Depends(get_current_user), +): + """Update meeting details.""" + with get_db_cursor(commit=True) as cursor: + cursor.execute( + "SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,) + ) + record = cursor.fetchone() + + if not record: + raise HTTPException(status_code=404, detail="Meeting not found") + + # Check access + if not is_admin(current_user) and record["created_by"] != current_user.email: + raise HTTPException(status_code=403, detail="Access denied") + + # Build update query dynamically + updates = [] + values = [] + for field in ["subject", "meeting_time", "location", "chairperson", "recorder", "attendees", "transcript_blob"]: + value = getattr(meeting, field) + if value is not None: + updates.append(f"{field} = %s") + values.append(value) + + if updates: + values.append(meeting_id) + cursor.execute( + f"UPDATE meeting_records SET {', '.join(updates)} WHERE meeting_id = %s", + values, + ) + + # Update conclusions if provided + if meeting.conclusions is not None: + cursor.execute( + "DELETE FROM meeting_conclusions WHERE meeting_id = %s", (meeting_id,) + ) + meeting_date = (meeting.meeting_time or record["meeting_time"]).date() if hasattr(meeting.meeting_time or record["meeting_time"], 'date') else date.today() + date_str = meeting_date.strftime("%Y%m%d") + seq = get_next_sequence(cursor, "C", date_str) + for conclusion in meeting.conclusions: + system_code = generate_system_code("C", meeting_date, seq) + cursor.execute( + """ + INSERT INTO meeting_conclusions (meeting_id, content, system_code) + VALUES (%s, %s, %s) + """, + (meeting_id, conclusion.content, system_code), + ) + seq += 1 + + # Update action items if provided + if meeting.actions is not None: + cursor.execute( + "DELETE FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,) + ) + meeting_date = (meeting.meeting_time or record["meeting_time"]).date() if hasattr(meeting.meeting_time or record["meeting_time"], 'date') else date.today() + date_str = meeting_date.strftime("%Y%m%d") + seq = get_next_sequence(cursor, "A", date_str) + for action in meeting.actions: + system_code = generate_system_code("A", meeting_date, seq) + cursor.execute( + """ + INSERT INTO meeting_action_items (meeting_id, content, owner, due_date, system_code) + VALUES (%s, %s, %s, %s, %s) + """, + (meeting_id, action.content, action.owner, action.due_date, system_code), + ) + seq += 1 + + # Return updated meeting + return await get_meeting(meeting_id, current_user) + + +@router.delete("/meetings/{meeting_id}") +async def delete_meeting( + meeting_id: int, current_user: TokenPayload = Depends(get_current_user) +): + """Delete meeting and all related data (cascade).""" + with get_db_cursor(commit=True) as cursor: + cursor.execute( + "SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,) + ) + record = cursor.fetchone() + + if not record: + raise HTTPException(status_code=404, detail="Meeting not found") + + # Check access - admin or creator can delete + if not is_admin(current_user) and record["created_by"] != current_user.email: + raise HTTPException(status_code=403, detail="Access denied") + + # Delete (cascade will handle conclusions and action items) + cursor.execute("DELETE FROM meeting_records WHERE meeting_id = %s", (meeting_id,)) + + return {"message": "Meeting deleted successfully"} + + +@router.put("/meetings/{meeting_id}/actions/{action_id}") +async def update_action_item( + meeting_id: int, + action_id: int, + action: ActionItemUpdate, + current_user: TokenPayload = Depends(get_current_user), +): + """Update a specific action item's status, owner, or due date.""" + with get_db_cursor(commit=True) as cursor: + cursor.execute( + "SELECT * FROM meeting_action_items WHERE action_id = %s AND meeting_id = %s", + (action_id, meeting_id), + ) + record = cursor.fetchone() + + if not record: + raise HTTPException(status_code=404, detail="Action item not found") + + updates = [] + values = [] + for field in ["content", "owner", "due_date", "status"]: + value = getattr(action, field) + if value is not None: + updates.append(f"{field} = %s") + values.append(value.value if hasattr(value, "value") else value) + + if updates: + values.append(action_id) + cursor.execute( + f"UPDATE meeting_action_items SET {', '.join(updates)} WHERE action_id = %s", + values, + ) + + cursor.execute( + "SELECT * FROM meeting_action_items WHERE action_id = %s", (action_id,) + ) + updated = cursor.fetchone() + + return ActionItemResponse(**updated) diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..c8c9c75 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..281f09a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +python-dotenv>=1.0.0 +mysql-connector-python>=9.0.0 +pydantic>=2.10.0 +httpx>=0.27.0 +python-jose[cryptography]>=3.3.0 +openpyxl>=3.1.2 +pytest>=8.0.0 +pytest-asyncio>=0.24.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..c61a54c --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,48 @@ +""" +Pytest configuration and fixtures. +""" + +import pytest +import sys +import os + +# Add the backend directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +@pytest.fixture(autouse=True) +def mock_env(monkeypatch): + """Set up mock environment variables for all tests.""" + monkeypatch.setenv("DB_HOST", "localhost") + monkeypatch.setenv("DB_PORT", "3306") + monkeypatch.setenv("DB_USER", "test") + monkeypatch.setenv("DB_PASS", "test") + monkeypatch.setenv("DB_NAME", "test_db") + monkeypatch.setenv("AUTH_API_URL", "https://auth.test.com/login") + monkeypatch.setenv("DIFY_API_URL", "https://dify.test.com/v1") + monkeypatch.setenv("DIFY_API_KEY", "test-api-key") + monkeypatch.setenv("ADMIN_EMAIL", "admin@test.com") + monkeypatch.setenv("JWT_SECRET", "test-jwt-secret") + + +@pytest.fixture +def sample_meeting(): + """Sample meeting data for tests.""" + return { + "subject": "Test Meeting", + "meeting_time": "2025-01-15T10:00:00", + "location": "Conference Room A", + "chairperson": "John Doe", + "recorder": "Jane Smith", + "attendees": "alice@test.com, bob@test.com", + } + + +@pytest.fixture +def sample_transcript(): + """Sample transcript for AI tests.""" + return """ + ไปŠๅคฉ็š„ๆœƒ่ญฐไธป่ฆ่จŽ่ซ–ไบ†Q1้ ็ฎ—ๅ’Œๆ–ฐๅ“กๅทฅๆ‹›่˜่จˆๅŠƒใ€‚ + ๆฑบๅฎšๅฐ‡่กŒ้Šท้ ็ฎ—ๅขžๅŠ 10%ใ€‚ + ๅฐๆ˜Ž่ฒ ่ฒฌๅœจไธ‹้€ฑไบ”ๅ‰ๆไบคๆœ€็ต‚ๅ ฑๅ‘Šใ€‚ + """ diff --git a/backend/tests/test_ai.py b/backend/tests/test_ai.py new file mode 100644 index 0000000..465291f --- /dev/null +++ b/backend/tests/test_ai.py @@ -0,0 +1,191 @@ +""" +Unit tests for AI summarization with mock Dify responses. +""" + +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +import json + +pytestmark = pytest.mark.asyncio + + +class TestDifyResponseParsing: + """Tests for parsing Dify LLM responses.""" + + def test_parse_json_response(self): + """Test parsing valid JSON response from Dify.""" + from app.routers.ai import parse_dify_response + + response = '''Here is the summary: +```json +{ + "conclusions": ["Agreed on Q1 budget", "New hire approved"], + "action_items": [ + {"content": "Submit budget report", "owner": "John", "due_date": "2025-01-15"}, + {"content": "Post job listing", "owner": "", "due_date": null} + ] +} +``` +''' + result = parse_dify_response(response) + + assert len(result["conclusions"]) == 2 + assert "Q1 budget" in result["conclusions"][0] + assert len(result["action_items"]) == 2 + assert result["action_items"][0]["owner"] == "John" + + def test_parse_inline_json_response(self): + """Test parsing inline JSON without code blocks.""" + from app.routers.ai import parse_dify_response + + response = '{"conclusions": ["Budget approved"], "action_items": []}' + result = parse_dify_response(response) + + assert len(result["conclusions"]) == 1 + assert result["conclusions"][0] == "Budget approved" + + def test_parse_non_json_response(self): + """Test fallback when response is not JSON.""" + from app.routers.ai import parse_dify_response + + response = "The meeting discussed Q1 budget and hiring plans." + result = parse_dify_response(response) + + # Should return the raw response as a single conclusion + assert len(result["conclusions"]) == 1 + assert "Q1 budget" in result["conclusions"][0] + assert len(result["action_items"]) == 0 + + def test_parse_empty_response(self): + """Test handling empty response.""" + from app.routers.ai import parse_dify_response + + result = parse_dify_response("") + + assert result["conclusions"] == [] + assert result["action_items"] == [] + + +class TestSummarizeEndpoint: + """Tests for the AI summarization endpoint.""" + + @patch("app.routers.ai.httpx.AsyncClient") + @patch("app.routers.ai.settings") + async def test_summarize_success(self, mock_settings, mock_client_class): + """Test successful summarization.""" + mock_settings.DIFY_API_URL = "https://dify.test.com/v1" + mock_settings.DIFY_API_KEY = "test-key" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "answer": json.dumps({ + "conclusions": ["Decision made"], + "action_items": [{"content": "Follow up", "owner": "Alice", "due_date": "2025-01-20"}] + }) + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + from app.routers.ai import summarize_transcript + from app.models import SummarizeRequest, TokenPayload + + mock_user = TokenPayload(email="test@test.com", role="user") + result = await summarize_transcript( + SummarizeRequest(transcript="Test meeting transcript"), + current_user=mock_user + ) + + assert len(result.conclusions) == 1 + assert len(result.action_items) == 1 + assert result.action_items[0].owner == "Alice" + + @patch("app.routers.ai.httpx.AsyncClient") + @patch("app.routers.ai.settings") + async def test_summarize_handles_timeout(self, mock_settings, mock_client_class): + """Test handling Dify timeout.""" + import httpx + from fastapi import HTTPException + + mock_settings.DIFY_API_URL = "https://dify.test.com/v1" + mock_settings.DIFY_API_KEY = "test-key" + + mock_client = AsyncMock() + mock_client.post.side_effect = httpx.TimeoutException("Timeout") + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + from app.routers.ai import summarize_transcript + from app.models import SummarizeRequest, TokenPayload + + mock_user = TokenPayload(email="test@test.com", role="user") + + with pytest.raises(HTTPException) as exc_info: + await summarize_transcript( + SummarizeRequest(transcript="Test"), + current_user=mock_user + ) + + assert exc_info.value.status_code == 504 + + @patch("app.routers.ai.settings") + async def test_summarize_no_api_key(self, mock_settings): + """Test error when Dify API key is not configured.""" + from fastapi import HTTPException + + mock_settings.DIFY_API_KEY = "" + + from app.routers.ai import summarize_transcript + from app.models import SummarizeRequest, TokenPayload + + mock_user = TokenPayload(email="test@test.com", role="user") + + with pytest.raises(HTTPException) as exc_info: + await summarize_transcript( + SummarizeRequest(transcript="Test"), + current_user=mock_user + ) + + assert exc_info.value.status_code == 503 + + +class TestPartialDataHandling: + """Tests for handling partial data from AI.""" + + def test_action_item_with_empty_owner(self): + """Test action items with empty owner are handled.""" + from app.routers.ai import parse_dify_response + + response = json.dumps({ + "conclusions": [], + "action_items": [ + {"content": "Task 1", "owner": "", "due_date": None}, + {"content": "Task 2", "owner": "Bob", "due_date": "2025-02-01"} + ] + }) + + result = parse_dify_response(response) + + assert result["action_items"][0]["owner"] == "" + assert result["action_items"][1]["owner"] == "Bob" + + def test_action_item_with_missing_fields(self): + """Test action items with missing fields.""" + from app.routers.ai import parse_dify_response + + response = json.dumps({ + "conclusions": ["Done"], + "action_items": [ + {"content": "Task only"} + ] + }) + + result = parse_dify_response(response) + + # Should have content but other fields may be missing + assert result["action_items"][0]["content"] == "Task only" diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..38e0bef --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,138 @@ +""" +Unit tests for authentication functionality. +""" + +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +from fastapi.testclient import TestClient +from jose import jwt + +pytestmark = pytest.mark.asyncio + + +class TestAdminRoleDetection: + """Tests for admin role detection.""" + + def test_admin_email_gets_admin_role(self): + """Test that admin email is correctly identified.""" + from app.config import settings + + admin_email = settings.ADMIN_EMAIL + test_email = "regular@example.com" + + # Admin email should be set (either from env or default) + assert admin_email is not None + assert len(admin_email) > 0 + assert test_email != admin_email + + @patch("app.routers.auth.settings") + def test_create_token_includes_role(self, mock_settings): + """Test that created tokens include the role.""" + mock_settings.JWT_SECRET = "test-secret" + mock_settings.ADMIN_EMAIL = "admin@test.com" + + from app.routers.auth import create_token + + # Test admin token + admin_token = create_token("admin@test.com", "admin") + admin_payload = jwt.decode(admin_token, "test-secret", algorithms=["HS256"]) + assert admin_payload["role"] == "admin" + + # Test user token + user_token = create_token("user@test.com", "user") + user_payload = jwt.decode(user_token, "test-secret", algorithms=["HS256"]) + assert user_payload["role"] == "user" + + +class TestTokenValidation: + """Tests for JWT token validation.""" + + @patch("app.routers.auth.settings") + def test_decode_valid_token(self, mock_settings): + """Test decoding a valid token.""" + mock_settings.JWT_SECRET = "test-secret" + + from app.routers.auth import create_token, decode_token + + token = create_token("test@example.com", "user") + payload = decode_token(token) + + assert payload.email == "test@example.com" + assert payload.role == "user" + + @patch("app.routers.auth.settings") + def test_decode_invalid_token_raises_error(self, mock_settings): + """Test that invalid tokens raise an error.""" + mock_settings.JWT_SECRET = "test-secret" + + from app.routers.auth import decode_token + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + decode_token("invalid-token") + + assert exc_info.value.status_code == 401 + + +class TestLoginEndpoint: + """Tests for the login endpoint.""" + + @pytest.fixture + def client(self): + """Create test client.""" + from app.main import app + + # Skip lifespan for tests + app.router.lifespan_context = None + return TestClient(app, raise_server_exceptions=False) + + @patch("app.routers.auth.httpx.AsyncClient") + @patch("app.routers.auth.settings") + async def test_login_success(self, mock_settings, mock_client_class): + """Test successful login.""" + mock_settings.AUTH_API_URL = "https://auth.test.com/login" + mock_settings.ADMIN_EMAIL = "admin@test.com" + mock_settings.JWT_SECRET = "test-secret" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"token": "external-token"} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + from app.routers.auth import login + from app.models import LoginRequest + + result = await login(LoginRequest(email="user@test.com", password="password")) + + assert result.email == "user@test.com" + assert result.role == "user" + assert result.token is not None + + @patch("app.routers.auth.httpx.AsyncClient") + @patch("app.routers.auth.settings") + async def test_login_admin_gets_admin_role(self, mock_settings, mock_client_class): + """Test that admin email gets admin role.""" + mock_settings.AUTH_API_URL = "https://auth.test.com/login" + mock_settings.ADMIN_EMAIL = "admin@test.com" + mock_settings.JWT_SECRET = "test-secret" + + mock_response = MagicMock() + mock_response.status_code = 200 + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + from app.routers.auth import login + from app.models import LoginRequest + + result = await login(LoginRequest(email="admin@test.com", password="password")) + + assert result.role == "admin" diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py new file mode 100644 index 0000000..8d7b98a --- /dev/null +++ b/backend/tests/test_database.py @@ -0,0 +1,95 @@ +""" +Unit tests for database connection and table initialization. +""" + +import pytest +from unittest.mock import patch, MagicMock + + +class TestDatabaseConnection: + """Tests for database connectivity.""" + + @patch("mysql.connector.pooling.MySQLConnectionPool") + def test_init_db_pool_success(self, mock_pool): + """Test successful database pool initialization.""" + mock_pool.return_value = MagicMock() + + from app.database import init_db_pool + + pool = init_db_pool() + assert pool is not None + mock_pool.assert_called_once() + + @patch("mysql.connector.pooling.MySQLConnectionPool") + def test_init_db_pool_with_correct_config(self, mock_pool): + """Test database pool is created with correct configuration.""" + from app.database import init_db_pool + from app.config import settings + + init_db_pool() + + call_args = mock_pool.call_args + assert call_args.kwargs["host"] == settings.DB_HOST + assert call_args.kwargs["port"] == settings.DB_PORT + assert call_args.kwargs["user"] == settings.DB_USER + assert call_args.kwargs["database"] == settings.DB_NAME + + +class TestTableInitialization: + """Tests for table creation.""" + + @patch("app.database.get_db_cursor") + def test_init_tables_creates_required_tables(self, mock_cursor_context): + """Test that all required tables are created.""" + mock_cursor = MagicMock() + mock_cursor_context.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_cursor_context.return_value.__exit__ = MagicMock(return_value=False) + + from app.database import init_tables + + init_tables() + + # Verify execute was called for each table + assert mock_cursor.execute.call_count == 4 + + # Check table names in SQL + calls = mock_cursor.execute.call_args_list + sql_statements = [call[0][0] for call in calls] + + assert any("meeting_users" in sql for sql in sql_statements) + assert any("meeting_records" in sql for sql in sql_statements) + assert any("meeting_conclusions" in sql for sql in sql_statements) + assert any("meeting_action_items" in sql for sql in sql_statements) + + +class TestDatabaseHelpers: + """Tests for database helper functions.""" + + @patch("app.database.connection_pool") + def test_get_db_connection_returns_connection(self, mock_pool): + """Test that get_db_connection returns a valid connection.""" + mock_conn = MagicMock() + mock_pool.get_connection.return_value = mock_conn + + from app.database import get_db_connection + + with get_db_connection() as conn: + assert conn == mock_conn + + mock_conn.close.assert_called_once() + + @patch("app.database.connection_pool") + def test_get_db_cursor_with_commit(self, mock_pool): + """Test that get_db_cursor commits when specified.""" + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_pool.get_connection.return_value = mock_conn + mock_conn.cursor.return_value = mock_cursor + + from app.database import get_db_cursor + + with get_db_cursor(commit=True) as cursor: + cursor.execute("SELECT 1") + + mock_conn.commit.assert_called_once() + mock_cursor.close.assert_called_once() diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..32911fc --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,4118 @@ +{ + "name": "meeting-assistant", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meeting-assistant", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "axios": "^1.6.2" + }, + "devDependencies": { + "electron": "^28.0.0", + "electron-builder": "^24.9.1" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", + "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", + "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", + "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.1", + "@malept/cross-spawn-promise": "^1.1.0", + "debug": "^4.3.1", + "dir-compare": "^3.0.0", + "fs-extra": "^9.0.1", + "minimatch": "^3.0.4", + "plist": "^3.0.4" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", + "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", + "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.2.1", + "@electron/osx-sign": "1.0.5", + "@electron/universal": "1.5.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chromium-pickle-js": "^0.2.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "electron-publish": "24.13.1", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "minimatch": "^5.1.1", + "read-config-file": "6.3.2", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "24.13.3", + "electron-builder-squirrel-windows": "24.13.3" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", + "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "4.0.0", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", + "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", + "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.10", + "typescript": "^5.3.3" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", + "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal": "^1.0.0", + "minimatch": "^3.0.4" + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", + "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", + "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "28.3.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz", + "integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^18.11.18", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", + "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "dmg-builder": "24.13.3", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "read-config-file": "6.3.2", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", + "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "archiver": "^5.3.1", + "builder-util": "24.13.1", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", + "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-config-file": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", + "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-file-ts": "^0.2.4", + "dotenv": "^9.0.2", + "dotenv-expand": "^5.1.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.0", + "lazy-val": "^1.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/require-directory": { + "version": "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "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==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..970bb01 --- /dev/null +++ b/client/package.json @@ -0,0 +1,47 @@ +{ + "name": "meeting-assistant", + "version": "1.0.0", + "description": "Enterprise Meeting Knowledge Management", + "main": "src/main.js", + "scripts": { + "start": "electron .", + "build": "electron-builder", + "pack": "electron-builder --dir" + }, + "author": "Your Company", + "license": "MIT", + "devDependencies": { + "electron": "^28.0.0", + "electron-builder": "^24.9.1" + }, + "dependencies": { + "axios": "^1.6.2" + }, + "build": { + "appId": "com.company.meeting-assistant", + "productName": "Meeting Assistant", + "directories": { + "output": "dist" + }, + "files": [ + "src/**/*", + "node_modules/**/*" + ], + "extraResources": [ + { + "from": "../sidecar/dist", + "to": "sidecar", + "filter": ["**/*"] + } + ], + "win": { + "target": "portable" + }, + "mac": { + "target": "dmg" + }, + "linux": { + "target": "AppImage" + } + } +} diff --git a/client/src/main.js b/client/src/main.js new file mode 100644 index 0000000..b039e21 --- /dev/null +++ b/client/src/main.js @@ -0,0 +1,278 @@ +const { app, BrowserWindow, ipcMain } = require("electron"); +const path = require("path"); +const fs = require("fs"); +const { spawn } = require("child_process"); +const os = require("os"); + +let mainWindow; +let sidecarProcess; +let sidecarReady = false; +let streamingActive = false; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, "preload.js"), + }, + }); + + mainWindow.loadFile(path.join(__dirname, "pages", "login.html")); + + mainWindow.on("closed", () => { + mainWindow = null; + }); +} + +function startSidecar() { + const sidecarDir = app.isPackaged + ? path.join(process.resourcesPath, "sidecar") + : path.join(__dirname, "..", "..", "sidecar"); + + const sidecarScript = path.join(sidecarDir, "transcriber.py"); + const venvPython = path.join(sidecarDir, "venv", "bin", "python"); + + if (!fs.existsSync(sidecarScript)) { + console.log("Sidecar script not found at:", sidecarScript); + console.log("Transcription will not be available."); + return; + } + + const pythonPath = fs.existsSync(venvPython) ? venvPython : "python3"; + + try { + console.log("Starting sidecar with:", pythonPath, sidecarScript); + sidecarProcess = spawn(pythonPath, [sidecarScript], { + cwd: sidecarDir, + stdio: ["pipe", "pipe", "pipe"], + }); + + // Handle stdout (JSON responses) + sidecarProcess.stdout.on("data", (data) => { + const lines = data.toString().split("\n").filter(l => l.trim()); + for (const line of lines) { + try { + const msg = JSON.parse(line); + console.log("Sidecar response:", msg); + + if (msg.status === "ready") { + sidecarReady = true; + console.log("Sidecar is ready"); + } + + // Forward streaming segment to renderer + if (msg.segment_id !== undefined && mainWindow) { + mainWindow.webContents.send("transcription-segment", msg); + } + + // Forward stream status changes + if (msg.status === "streaming" && mainWindow) { + mainWindow.webContents.send("stream-started", msg); + } + + if (msg.status === "stream_stopped" && mainWindow) { + mainWindow.webContents.send("stream-stopped", msg); + } + + // Legacy: file-based transcription result + if (msg.result !== undefined && mainWindow) { + mainWindow.webContents.send("transcription-result", msg.result); + } + } catch (e) { + console.log("Sidecar output:", line); + } + } + }); + + sidecarProcess.stderr.on("data", (data) => { + console.log("Sidecar:", data.toString().trim()); + }); + + sidecarProcess.on("close", (code) => { + console.log(`Sidecar exited with code ${code}`); + sidecarReady = false; + streamingActive = false; + }); + + sidecarProcess.on("error", (err) => { + console.error("Sidecar error:", err.message); + }); + } catch (error) { + console.error("Failed to start sidecar:", error); + } +} + +app.whenReady().then(() => { + createWindow(); + startSidecar(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + if (sidecarProcess) { + try { + sidecarProcess.stdin.write(JSON.stringify({ action: "quit" }) + "\n"); + } catch (e) {} + sidecarProcess.kill(); + } + if (process.platform !== "darwin") { + app.quit(); + } +}); + +// IPC handlers +ipcMain.handle("navigate", (event, page) => { + mainWindow.loadFile(path.join(__dirname, "pages", `${page}.html`)); +}); + +ipcMain.handle("get-sidecar-status", () => { + return { ready: sidecarReady, streaming: streamingActive }; +}); + +// === Streaming Mode IPC Handlers === + +ipcMain.handle("start-recording-stream", async () => { + if (!sidecarProcess || !sidecarReady) { + return { error: "Sidecar not ready" }; + } + + if (streamingActive) { + return { error: "Stream already active" }; + } + + return new Promise((resolve) => { + const responseHandler = (data) => { + const lines = data.toString().split("\n").filter(l => l.trim()); + for (const line of lines) { + try { + const msg = JSON.parse(line); + if (msg.status === "streaming" || msg.error) { + sidecarProcess.stdout.removeListener("data", responseHandler); + if (msg.status === "streaming") { + streamingActive = true; + } + resolve(msg); + return; + } + } catch (e) {} + } + }; + + sidecarProcess.stdout.on("data", responseHandler); + sidecarProcess.stdin.write(JSON.stringify({ action: "start_stream" }) + "\n"); + + setTimeout(() => { + sidecarProcess.stdout.removeListener("data", responseHandler); + resolve({ error: "Start stream timeout" }); + }, 5000); + }); +}); + +ipcMain.handle("stream-audio-chunk", async (event, base64Audio) => { + if (!sidecarProcess || !sidecarReady || !streamingActive) { + return { error: "Stream not active" }; + } + + try { + const cmd = JSON.stringify({ action: "audio_chunk", data: base64Audio }) + "\n"; + sidecarProcess.stdin.write(cmd); + return { sent: true }; + } catch (e) { + return { error: e.message }; + } +}); + +ipcMain.handle("stop-recording-stream", async () => { + if (!sidecarProcess || !streamingActive) { + return { error: "No active stream" }; + } + + return new Promise((resolve) => { + const responseHandler = (data) => { + const lines = data.toString().split("\n").filter(l => l.trim()); + for (const line of lines) { + try { + const msg = JSON.parse(line); + if (msg.status === "stream_stopped" || msg.error) { + sidecarProcess.stdout.removeListener("data", responseHandler); + streamingActive = false; + resolve(msg); + return; + } + } catch (e) {} + } + }; + + sidecarProcess.stdout.on("data", responseHandler); + sidecarProcess.stdin.write(JSON.stringify({ action: "stop_stream" }) + "\n"); + + setTimeout(() => { + sidecarProcess.stdout.removeListener("data", responseHandler); + streamingActive = false; + resolve({ error: "Stop stream timeout" }); + }, 10000); + }); +}); + +// === Legacy File-based Handlers (kept for fallback) === + +ipcMain.handle("save-audio-file", async (event, arrayBuffer) => { + const tempDir = os.tmpdir(); + const tempFile = path.join(tempDir, `recording_${Date.now()}.webm`); + const buffer = Buffer.from(arrayBuffer); + fs.writeFileSync(tempFile, buffer); + return tempFile; +}); + +ipcMain.handle("transcribe-audio", async (event, audioFilePath) => { + if (!sidecarProcess || !sidecarReady) { + return { error: "Sidecar not ready" }; + } + + return new Promise((resolve) => { + const responseHandler = (data) => { + const lines = data.toString().split("\n").filter(l => l.trim()); + for (const line of lines) { + try { + const msg = JSON.parse(line); + if (msg.result !== undefined || msg.error) { + sidecarProcess.stdout.removeListener("data", responseHandler); + // Delete temp file after transcription + try { + if (fs.existsSync(audioFilePath)) { + fs.unlinkSync(audioFilePath); + } + } catch (e) { + console.error("Failed to delete temp file:", e); + } + resolve(msg); + return; + } + } catch (e) {} + } + }; + + sidecarProcess.stdout.on("data", responseHandler); + const cmd = JSON.stringify({ action: "transcribe", file: audioFilePath }) + "\n"; + sidecarProcess.stdin.write(cmd); + + setTimeout(() => { + sidecarProcess.stdout.removeListener("data", responseHandler); + // Delete temp file on timeout too + try { + if (fs.existsSync(audioFilePath)) { + fs.unlinkSync(audioFilePath); + } + } catch (e) {} + resolve({ error: "Transcription timeout" }); + }, 60000); + }); +}); diff --git a/client/src/pages/login.html b/client/src/pages/login.html new file mode 100644 index 0000000..99c6666 --- /dev/null +++ b/client/src/pages/login.html @@ -0,0 +1,58 @@ + + + + + + Meeting Assistant - Login + + + + + + + + diff --git a/client/src/pages/meeting-detail.html b/client/src/pages/meeting-detail.html new file mode 100644 index 0000000..e7df113 --- /dev/null +++ b/client/src/pages/meeting-detail.html @@ -0,0 +1,685 @@ + + + + + + Meeting Assistant - Meeting Detail + + + + +
+

Meeting Details

+ +
+ +
+ +
+
+
+ Time: + Location: + Chair: + Recorder: +
+
+
+ + +
+ +
+
+ Transcript (้€ๅญ—็จฟ) +
+ +
+
+
+ + + + +
+ + +
+
+
+ + +
+
+ Notes & Actions + +
+
+ +
+

Conclusions (็ต่ซ–)

+
+ +
+ + +
+

Action Items (ๅพ…่พฆไบ‹้ …)

+
+ +
+
+
+ +
+
+
+
+ + + + diff --git a/client/src/pages/meetings.html b/client/src/pages/meetings.html new file mode 100644 index 0000000..f0fc1e5 --- /dev/null +++ b/client/src/pages/meetings.html @@ -0,0 +1,201 @@ + + + + + + Meeting Assistant - Meetings + + + +
+

Meeting Assistant

+ +
+ +
+
+
+ My Meetings +
+
+
+
+
+
+
+
+ + + + + + + diff --git a/client/src/preload.js b/client/src/preload.js new file mode 100644 index 0000000..74ea7f5 --- /dev/null +++ b/client/src/preload.js @@ -0,0 +1,32 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("electronAPI", { + // Navigation + navigate: (page) => ipcRenderer.invoke("navigate", page), + + // Sidecar status + getSidecarStatus: () => ipcRenderer.invoke("get-sidecar-status"), + + // === Streaming Mode APIs === + startRecordingStream: () => ipcRenderer.invoke("start-recording-stream"), + streamAudioChunk: (base64Audio) => ipcRenderer.invoke("stream-audio-chunk", base64Audio), + stopRecordingStream: () => ipcRenderer.invoke("stop-recording-stream"), + + // Streaming events + onTranscriptionSegment: (callback) => { + ipcRenderer.on("transcription-segment", (event, segment) => callback(segment)); + }, + onStreamStarted: (callback) => { + ipcRenderer.on("stream-started", (event, data) => callback(data)); + }, + onStreamStopped: (callback) => { + ipcRenderer.on("stream-stopped", (event, data) => callback(data)); + }, + + // === Legacy File-based APIs (fallback) === + saveAudioFile: (arrayBuffer) => ipcRenderer.invoke("save-audio-file", arrayBuffer), + transcribeAudio: (filePath) => ipcRenderer.invoke("transcribe-audio", filePath), + onTranscriptionResult: (callback) => { + ipcRenderer.on("transcription-result", (event, text) => callback(text)); + }, +}); diff --git a/client/src/services/api.js b/client/src/services/api.js new file mode 100644 index 0000000..2e7ab9e --- /dev/null +++ b/client/src/services/api.js @@ -0,0 +1,149 @@ +const API_BASE_URL = "http://localhost:8000/api"; + +let authToken = null; +let tokenRefreshTimer = null; + +export function setToken(token) { + authToken = token; + localStorage.setItem("authToken", token); + scheduleTokenRefresh(); +} + +export function getToken() { + if (!authToken) { + authToken = localStorage.getItem("authToken"); + } + return authToken; +} + +export function clearToken() { + authToken = null; + localStorage.removeItem("authToken"); + if (tokenRefreshTimer) { + clearTimeout(tokenRefreshTimer); + } +} + +function scheduleTokenRefresh() { + // Refresh token 5 minutes before expiry (assuming 24h token) + const refreshIn = 23 * 60 * 60 * 1000; // 23 hours + if (tokenRefreshTimer) { + clearTimeout(tokenRefreshTimer); + } + tokenRefreshTimer = setTimeout(async () => { + try { + // Re-login would require stored credentials + // For now, just notify user to re-login + console.warn("Token expiring soon, please re-login"); + } catch (error) { + console.error("Token refresh failed:", error); + } + }, refreshIn); +} + +async function request(endpoint, options = {}) { + const url = `${API_BASE_URL}${endpoint}`; + const headers = { + "Content-Type": "application/json", + ...options.headers, + }; + + const token = getToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch(url, { + ...options, + headers, + }); + + if (response.status === 401) { + const error = await response.json(); + if (error.detail?.error === "token_expired") { + clearToken(); + window.electronAPI.navigate("login"); + throw new Error("Session expired, please login again"); + } + throw new Error(error.detail || "Unauthorized"); + } + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP error ${response.status}`); + } + + // Handle blob responses for export + if (options.responseType === "blob") { + return response.blob(); + } + + return response.json(); +} + +// Auth API +export async function login(email, password) { + const data = await request("/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }); + setToken(data.token); + localStorage.setItem("userEmail", data.email); + localStorage.setItem("userRole", data.role); + return data; +} + +export async function getMe() { + return request("/me"); +} + +// Meetings API +export async function getMeetings() { + return request("/meetings"); +} + +export async function getMeeting(id) { + return request(`/meetings/${id}`); +} + +export async function createMeeting(meeting) { + return request("/meetings", { + method: "POST", + body: JSON.stringify(meeting), + }); +} + +export async function updateMeeting(id, meeting) { + return request(`/meetings/${id}`, { + method: "PUT", + body: JSON.stringify(meeting), + }); +} + +export async function deleteMeeting(id) { + return request(`/meetings/${id}`, { + method: "DELETE", + }); +} + +export async function updateActionItem(meetingId, actionId, data) { + return request(`/meetings/${meetingId}/actions/${actionId}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +// AI API +export async function summarizeTranscript(transcript) { + return request("/ai/summarize", { + method: "POST", + body: JSON.stringify({ transcript }), + }); +} + +// Export API +export async function exportMeeting(id) { + return request(`/meetings/${id}/export`, { + responseType: "blob", + }); +} diff --git a/client/src/styles/main.css b/client/src/styles/main.css new file mode 100644 index 0000000..124d993 --- /dev/null +++ b/client/src/styles/main.css @@ -0,0 +1,462 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + background-color: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* Header */ +.header { + background-color: #2c3e50; + color: white; + padding: 15px 20px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.header h1 { + font-size: 1.5rem; +} + +.header-nav { + display: flex; + gap: 15px; +} + +.header-nav a { + color: white; + text-decoration: none; + padding: 8px 16px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.header-nav a:hover { + background-color: #34495e; +} + +/* Login Page */ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.login-box { + background: white; + padding: 40px; + border-radius: 10px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + width: 100%; + max-width: 400px; +} + +.login-box h1 { + text-align: center; + margin-bottom: 30px; + color: #333; +} + +/* Forms */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 600; + color: #555; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 1rem; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: #667eea; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 12px 24px; + border: none; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + text-decoration: none; + text-align: center; + transition: all 0.2s; +} + +.btn-primary { + background-color: #667eea; + color: white; +} + +.btn-primary:hover { + background-color: #5a6fd6; +} + +.btn-secondary { + background-color: #6c757d; + color: white; +} + +.btn-secondary:hover { + background-color: #5a6268; +} + +.btn-danger { + background-color: #dc3545; + color: white; +} + +.btn-danger:hover { + background-color: #c82333; +} + +.btn-success { + background-color: #28a745; + color: white; +} + +.btn-success:hover { + background-color: #218838; +} + +.btn-full { + width: 100%; +} + +/* Cards */ +.card { + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; + overflow: hidden; +} + +.card-header { + padding: 15px 20px; + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; + font-weight: 600; +} + +.card-body { + padding: 20px; +} + +/* Meeting List */ +.meeting-list { + list-style: none; +} + +.meeting-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #eee; + transition: background-color 0.2s; + cursor: pointer; +} + +.meeting-item:hover { + background-color: #f8f9fa; +} + +.meeting-item:last-child { + border-bottom: none; +} + +.meeting-info h3 { + margin-bottom: 5px; + color: #333; +} + +.meeting-info p { + color: #666; + font-size: 0.9rem; +} + +.meeting-actions { + display: flex; + gap: 10px; +} + +/* Dual Panel Layout */ +.dual-panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + height: calc(100vh - 150px); +} + +.panel { + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + padding: 15px 20px; + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; + font-weight: 600; + display: flex; + justify-content: space-between; + align-items: center; +} + +.panel-body { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +/* Transcript */ +.transcript-content { + white-space: pre-wrap; + font-family: "Courier New", monospace; + font-size: 0.95rem; + line-height: 1.8; +} + +/* Action Items */ +.action-item { + padding: 15px; + border: 1px solid #dee2e6; + border-radius: 6px; + margin-bottom: 15px; +} + +.action-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.action-item-code { + font-weight: 600; + color: #667eea; +} + +.action-item-status { + padding: 4px 10px; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 600; +} + +.status-open { + background-color: #e3f2fd; + color: #1976d2; +} + +.status-in-progress { + background-color: #fff3e0; + color: #f57c00; +} + +.status-done { + background-color: #e8f5e9; + color: #388e3c; +} + +.status-delayed { + background-color: #ffebee; + color: #d32f2f; +} + +/* Recording */ +.recording-controls { + display: flex; + gap: 15px; + align-items: center; + padding: 15px 20px; + background-color: #f8f9fa; + border-top: 1px solid #dee2e6; +} + +.recording-indicator { + display: flex; + align-items: center; + gap: 8px; +} + +.recording-dot { + width: 12px; + height: 12px; + background-color: #dc3545; + border-radius: 50%; + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Alerts */ +.alert { + padding: 15px 20px; + border-radius: 6px; + margin-bottom: 20px; +} + +.alert-error { + background-color: #ffebee; + color: #c62828; + border: 1px solid #ef9a9a; +} + +.alert-success { + background-color: #e8f5e9; + color: #2e7d32; + border: 1px solid #a5d6a7; +} + +/* Loading */ +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 40px; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal { + background: white; + border-radius: 10px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + width: 100%; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; +} + +.modal-header { + padding: 20px; + border-bottom: 1px solid #dee2e6; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h2 { + margin: 0; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #666; +} + +.modal-body { + padding: 20px; +} + +.modal-footer { + padding: 20px; + border-top: 1px solid #dee2e6; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +/* Utility */ +.text-center { + text-align: center; +} + +.mt-10 { + margin-top: 10px; +} + +.mt-20 { + margin-top: 20px; +} + +.mb-10 { + margin-bottom: 10px; +} + +.mb-20 { + margin-bottom: 20px; +} + +.hidden { + display: none !important; +} diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md new file mode 100644 index 0000000..96ab0bb --- /dev/null +++ b/openspec/AGENTS.md @@ -0,0 +1,456 @@ +# OpenSpec Instructions + +Instructions for AI coding assistants using OpenSpec for spec-driven development. + +## TL;DR Quick Checklist + +- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) +- Decide scope: new capability vs modify existing capability +- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) +- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability +- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement +- Validate: `openspec validate [change-id] --strict` and fix issues +- Request approval: Do not start implementation until proposal is approved + +## Three-Stage Workflow + +### Stage 1: Creating Changes +Create proposal when you need to: +- Add features or functionality +- Make breaking changes (API, schema) +- Change architecture or patterns +- Optimize performance (changes behavior) +- Update security patterns + +Triggers (examples): +- "Help me create a change proposal" +- "Help me plan a change" +- "Help me create a proposal" +- "I want to create a spec proposal" +- "I want to create a spec" + +Loose matching guidance: +- Contains one of: `proposal`, `change`, `spec` +- With one of: `create`, `plan`, `make`, `start`, `help` + +Skip proposal for: +- Bug fixes (restore intended behavior) +- Typos, formatting, comments +- Dependency updates (non-breaking) +- Configuration changes +- Tests for existing behavior + +**Workflow** +1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. +3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. +4. Run `openspec validate --strict` and resolve any issues before sharing the proposal. + +### Stage 2: Implementing Changes +Track these steps as TODOs and complete them one by one. +1. **Read proposal.md** - Understand what's being built +2. **Read design.md** (if exists) - Review technical decisions +3. **Read tasks.md** - Get implementation checklist +4. **Implement tasks sequentially** - Complete in order +5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses +6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality +7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved + +### Stage 3: Archiving Changes +After deployment, create separate PR to: +- Move `changes/[name]/` โ†’ `changes/archive/YYYY-MM-DD-[name]/` +- Update `specs/` if capabilities changed +- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) +- Run `openspec validate --strict` to confirm the archived change passes checks + +## Before Any Task + +**Context Checklist:** +- [ ] Read relevant specs in `specs/[capability]/spec.md` +- [ ] Check pending changes in `changes/` for conflicts +- [ ] Read `openspec/project.md` for conventions +- [ ] Run `openspec list` to see active changes +- [ ] Run `openspec list --specs` to see existing capabilities + +**Before Creating Specs:** +- Always check if capability already exists +- Prefer modifying existing specs over creating duplicates +- Use `openspec show [spec]` to review current state +- If request is ambiguous, ask 1โ€“2 clarifying questions before scaffolding + +### Search Guidance +- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) +- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) +- Show details: + - Spec: `openspec show --type spec` (use `--json` for filters) + - Change: `openspec show --json --deltas-only` +- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` + +## Quick Start + +### CLI Commands + +```bash +# Essential commands +openspec list # List active changes +openspec list --specs # List specifications +openspec show [item] # Display change or spec +openspec validate [item] # Validate changes or specs +openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) + +# Project management +openspec init [path] # Initialize OpenSpec +openspec update [path] # Update instruction files + +# Interactive mode +openspec show # Prompts for selection +openspec validate # Bulk validation mode + +# Debugging +openspec show [change] --json --deltas-only +openspec validate [change] --strict +``` + +### Command Flags + +- `--json` - Machine-readable output +- `--type change|spec` - Disambiguate items +- `--strict` - Comprehensive validation +- `--no-interactive` - Disable prompts +- `--skip-specs` - Archive without spec updates +- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) + +## Directory Structure + +``` +openspec/ +โ”œโ”€โ”€ project.md # Project conventions +โ”œโ”€โ”€ specs/ # Current truth - what IS built +โ”‚ โ””โ”€โ”€ [capability]/ # Single focused capability +โ”‚ โ”œโ”€โ”€ spec.md # Requirements and scenarios +โ”‚ โ””โ”€โ”€ design.md # Technical patterns +โ”œโ”€โ”€ changes/ # Proposals - what SHOULD change +โ”‚ โ”œโ”€โ”€ [change-name]/ +โ”‚ โ”‚ โ”œโ”€โ”€ proposal.md # Why, what, impact +โ”‚ โ”‚ โ”œโ”€โ”€ tasks.md # Implementation checklist +โ”‚ โ”‚ โ”œโ”€โ”€ design.md # Technical decisions (optional; see criteria) +โ”‚ โ”‚ โ””โ”€โ”€ specs/ # Delta changes +โ”‚ โ”‚ โ””โ”€โ”€ [capability]/ +โ”‚ โ”‚ โ””โ”€โ”€ spec.md # ADDED/MODIFIED/REMOVED +โ”‚ โ””โ”€โ”€ archive/ # Completed changes +``` + +## Creating Change Proposals + +### Decision Tree + +``` +New request? +โ”œโ”€ Bug fix restoring spec behavior? โ†’ Fix directly +โ”œโ”€ Typo/format/comment? โ†’ Fix directly +โ”œโ”€ New feature/capability? โ†’ Create proposal +โ”œโ”€ Breaking change? โ†’ Create proposal +โ”œโ”€ Architecture change? โ†’ Create proposal +โ””โ”€ Unclear? โ†’ Create proposal (safer) +``` + +### Proposal Structure + +1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) + +2. **Write proposal.md:** +```markdown +# Change: [Brief description of change] + +## Why +[1-2 sentences on problem/opportunity] + +## What Changes +- [Bullet list of changes] +- [Mark breaking changes with **BREAKING**] + +## Impact +- Affected specs: [list capabilities] +- Affected code: [key files/systems] +``` + +3. **Create spec deltas:** `specs/[capability]/spec.md` +```markdown +## ADDED Requirements +### Requirement: New Feature +The system SHALL provide... + +#### Scenario: Success case +- **WHEN** user performs action +- **THEN** expected result + +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete modified requirement] + +## REMOVED Requirements +### Requirement: Old Feature +**Reason**: [Why removing] +**Migration**: [How to handle] +``` +If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`โ€”one per capability. + +4. **Create tasks.md:** +```markdown +## 1. Implementation +- [ ] 1.1 Create database schema +- [ ] 1.2 Implement API endpoint +- [ ] 1.3 Add frontend component +- [ ] 1.4 Write tests +``` + +5. **Create design.md when needed:** +Create `design.md` if any of the following apply; otherwise omit it: +- Cross-cutting change (multiple services/modules) or a new architectural pattern +- New external dependency or significant data model changes +- Security, performance, or migration complexity +- Ambiguity that benefits from technical decisions before coding + +Minimal `design.md` skeleton: +```markdown +## Context +[Background, constraints, stakeholders] + +## Goals / Non-Goals +- Goals: [...] +- Non-Goals: [...] + +## Decisions +- Decision: [What and why] +- Alternatives considered: [Options + rationale] + +## Risks / Trade-offs +- [Risk] โ†’ Mitigation + +## Migration Plan +[Steps, rollback] + +## Open Questions +- [...] +``` + +## Spec File Format + +### Critical: Scenario Formatting + +**CORRECT** (use #### headers): +```markdown +#### Scenario: User login success +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` + +**WRONG** (don't use bullets or bold): +```markdown +- **Scenario: User login** โŒ +**Scenario**: User login โŒ +### Scenario: User login โŒ +``` + +Every requirement MUST have at least one scenario. + +### Requirement Wording +- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) + +### Delta Operations + +- `## ADDED Requirements` - New capabilities +- `## MODIFIED Requirements` - Changed behavior +- `## REMOVED Requirements` - Deprecated features +- `## RENAMED Requirements` - Name changes + +Headers matched with `trim(header)` - whitespace ignored. + +#### When to use ADDED vs MODIFIED +- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. +- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. +- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. + +Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you arenโ€™t explicitly changing the existing requirement, add a new requirement under ADDED instead. + +Authoring a MODIFIED requirement correctly: +1) Locate the existing requirement in `openspec/specs//spec.md`. +2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). +3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. +4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. + +Example for RENAMED: +```markdown +## RENAMED Requirements +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` + +## Troubleshooting + +### Common Errors + +**"Change must have at least one delta"** +- Check `changes/[name]/specs/` exists with .md files +- Verify files have operation prefixes (## ADDED Requirements) + +**"Requirement must have at least one scenario"** +- Check scenarios use `#### Scenario:` format (4 hashtags) +- Don't use bullet points or bold for scenario headers + +**Silent scenario parsing failures** +- Exact format required: `#### Scenario: Name` +- Debug with: `openspec show [change] --json --deltas-only` + +### Validation Tips + +```bash +# Always use strict mode for comprehensive checks +openspec validate [change] --strict + +# Debug delta parsing +openspec show [change] --json | jq '.deltas' + +# Check specific requirement +openspec show [spec] --json -r 1 +``` + +## Happy Path Script + +```bash +# 1) Explore current state +openspec spec list --long +openspec list +# Optional full-text search: +# rg -n "Requirement:|Scenario:" openspec/specs +# rg -n "^#|Requirement:" openspec/changes + +# 2) Choose change id and scaffold +CHANGE=add-two-factor-auth +mkdir -p openspec/changes/$CHANGE/{specs/auth} +printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md +printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md + +# 3) Add deltas (example) +cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' +## ADDED Requirements +### Requirement: Two-Factor Authentication +Users MUST provide a second factor during login. + +#### Scenario: OTP required +- **WHEN** valid credentials are provided +- **THEN** an OTP challenge is required +EOF + +# 4) Validate +openspec validate $CHANGE --strict +``` + +## Multi-Capability Example + +``` +openspec/changes/add-2fa-notify/ +โ”œโ”€โ”€ proposal.md +โ”œโ”€โ”€ tasks.md +โ””โ”€โ”€ specs/ + โ”œโ”€โ”€ auth/ + โ”‚ โ””โ”€โ”€ spec.md # ADDED: Two-Factor Authentication + โ””โ”€โ”€ notifications/ + โ””โ”€โ”€ spec.md # ADDED: OTP email notification +``` + +auth/spec.md +```markdown +## ADDED Requirements +### Requirement: Two-Factor Authentication +... +``` + +notifications/spec.md +```markdown +## ADDED Requirements +### Requirement: OTP Email Notification +... +``` + +## Best Practices + +### Simplicity First +- Default to <100 lines of new code +- Single-file implementations until proven insufficient +- Avoid frameworks without clear justification +- Choose boring, proven patterns + +### Complexity Triggers +Only add complexity with: +- Performance data showing current solution too slow +- Concrete scale requirements (>1000 users, >100MB data) +- Multiple proven use cases requiring abstraction + +### Clear References +- Use `file.ts:42` format for code locations +- Reference specs as `specs/auth/spec.md` +- Link related changes and PRs + +### Capability Naming +- Use verb-noun: `user-auth`, `payment-capture` +- Single purpose per capability +- 10-minute understandability rule +- Split if description needs "AND" + +### Change ID Naming +- Use kebab-case, short and descriptive: `add-two-factor-auth` +- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` +- Ensure uniqueness; if taken, append `-2`, `-3`, etc. + +## Tool Selection Guide + +| Task | Tool | Why | +|------|------|-----| +| Find files by pattern | Glob | Fast pattern matching | +| Search code content | Grep | Optimized regex search | +| Read specific files | Read | Direct file access | +| Explore unknown scope | Task | Multi-step investigation | + +## Error Recovery + +### Change Conflicts +1. Run `openspec list` to see active changes +2. Check for overlapping specs +3. Coordinate with change owners +4. Consider combining proposals + +### Validation Failures +1. Run with `--strict` flag +2. Check JSON output for details +3. Verify spec file format +4. Ensure scenarios properly formatted + +### Missing Context +1. Read project.md first +2. Check related specs +3. Review recent archives +4. Ask for clarification + +## Quick Reference + +### Stage Indicators +- `changes/` - Proposed, not yet built +- `specs/` - Built and deployed +- `archive/` - Completed changes + +### File Purposes +- `proposal.md` - Why and what +- `tasks.md` - Implementation steps +- `design.md` - Technical decisions +- `spec.md` - Requirements and behavior + +### CLI Essentials +```bash +openspec list # What's in progress? +openspec show [item] # View details +openspec validate --strict # Is it correct? +openspec archive [--yes|-y] # Mark complete (add --yes for automation) +``` + +Remember: Specs are truth. Changes are proposals. Keep them in sync. diff --git a/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/design.md b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/design.md new file mode 100644 index 0000000..2a43ccf --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/design.md @@ -0,0 +1,132 @@ +## Context +Building a meeting knowledge management system for enterprise users. The system must support offline transcription on standard hardware (i5/8GB), integrate with existing company authentication, and provide AI-powered summarization via Dify LLM. + +**Stakeholders**: Enterprise meeting participants, meeting recorders, admin users (ymirliu@panjit.com.tw) + +**Constraints**: +- Must run faster-whisper int8 on i5/8GB laptop +- DB credentials and API keys must stay server-side (security) +- All database tables prefixed with `meeting_` +- Output must support Traditional Chinese (็น้ซ”ไธญๆ–‡) + +## Goals / Non-Goals + +**Goals**: +- Deliver working MVP with all six capabilities +- Secure architecture with secrets in middleware only +- Offline-capable transcription +- Structured output with trackable action items + +**Non-Goals**: +- Multi-language support beyond Traditional Chinese +- Real-time collaborative editing +- Mobile client +- Custom LLM model training + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Electron Client โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Auth UI โ”‚ โ”‚ Meeting UI โ”‚ โ”‚ Transcription Engine โ”‚ โ”‚ +โ”‚ โ”‚ (Login) โ”‚ โ”‚ (CRUD/Edit) โ”‚ โ”‚ (faster-whisper+OpenCC)โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ + โ”‚ HTTP โ”‚ HTTP โ”‚ Local only + โ–ผ โ–ผ โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ FastAPI Middleware Server โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Auth Proxy โ”‚ โ”‚Meeting CRUD โ”‚ โ”‚ Dify Proxy โ”‚ โ”‚ Export โ”‚ โ”‚ +โ”‚ โ”‚ POST /login โ”‚ โ”‚POST/GET/... โ”‚ โ”‚POST /ai/... โ”‚ โ”‚GET /:idโ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ โ”‚ + โ–ผ โ–ผ โ–ผ โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ PJ-Auth API โ”‚ โ”‚ MySQL โ”‚ โ”‚ Dify LLM โ”‚ โ”‚ +โ”‚ (Vercel) โ”‚ โ”‚ (theaken.com)โ”‚ โ”‚(theaken.com) โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Excel Templateโ”‚ + โ”‚ (openpyxl) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Decisions + +### Decision 1: Three-tier architecture with middleware +**Choice**: All external services accessed through FastAPI middleware +**Rationale**: Security requirement - DB credentials and API keys cannot be in Electron client +**Alternatives considered**: +- Direct client-to-service: Rejected due to credential exposure risk +- Serverless functions: More complex deployment for similar security + +### Decision 2: Edge transcription in Electron +**Choice**: Run faster-whisper locally via Python sidecar (PyInstaller) +**Rationale**: Offline capability requirement; network latency unacceptable for real-time transcription +**Alternatives considered**: +- Cloud STT (Google/Azure): Requires network, latency issues +- WebAssembly whisper: Not mature enough for production + +### Decision 3: MySQL with prefixed tables +**Choice**: Use shared MySQL instance with `meeting_` prefix +**Rationale**: Leverage existing infrastructure; prefix ensures isolation +**Alternatives considered**: +- Dedicated database: Overhead not justified for MVP +- SQLite: Doesn't support multi-user access + +### Decision 4: Dify for LLM summarization +**Choice**: Use company Dify instance for AI features +**Rationale**: Already available infrastructure; structured JSON output support +**Alternatives considered**: +- Direct OpenAI API: Additional cost, no existing infrastructure +- Local LLM: Hardware constraints (i5/8GB insufficient) + +## Risks / Trade-offs + +| Risk | Impact | Mitigation | +|------|--------|------------| +| faster-whisper performance on i5/8GB | High | Use int8 quantization; test on target hardware early | +| Dify timeout on long transcripts | Medium | Implement chunking; add timeout handling with retry | +| Token expiry during long meetings | Medium | Implement auto-refresh interceptor in client | +| Network failure during save | Medium | Client-side queue with retry; local draft storage | + +## Data Model + +```sql +-- Tables all prefixed with meeting_ + +meeting_users (user_id, email, display_name, role, created_at) +meeting_records (meeting_id, uuid, subject, meeting_time, location, + chairperson, recorder, attendees, transcript_blob, + created_by, created_at) +meeting_conclusions (conclusion_id, meeting_id, content, system_code) +meeting_action_items (action_id, meeting_id, content, owner, due_date, + status, system_code) +``` + +**ID Formats**: +- Conclusions: `C-YYYYMMDD-XX` (e.g., C-20251210-01) +- Action Items: `A-YYYYMMDD-XX` (e.g., A-20251210-01) + +## API Endpoints + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| POST | /api/login | Proxy auth to PJ-Auth API | +| GET | /api/meetings | List meetings (filterable) | +| POST | /api/meetings | Create meeting | +| GET | /api/meetings/:id | Get meeting details | +| PUT | /api/meetings/:id | Update meeting | +| DELETE | /api/meetings/:id | Delete meeting | +| POST | /api/ai/summarize | Send transcript to Dify | +| GET | /api/meetings/:id/export | Generate Excel report | + +## Open Questions +- None currently - PRD and SDD provide sufficient detail for MVP implementation diff --git a/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/proposal.md b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/proposal.md new file mode 100644 index 0000000..8b5e6e5 --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/proposal.md @@ -0,0 +1,25 @@ +# Change: Add Meeting Assistant MVP + +## Why +Enterprise users spend significant time manually documenting meetings and tracking action items. This MVP delivers an end-to-end meeting knowledge management solution with offline transcription, AI-powered summarization, and structured tracking of conclusions and action items. + +## What Changes +- **NEW** FastAPI middleware server with MySQL integration +- **NEW** Authentication proxy to company Auth API with admin role detection +- **NEW** Meeting CRUD operations with metadata management +- **NEW** Edge-based speech-to-text using faster-whisper (int8) +- **NEW** Dify LLM integration for intelligent summarization +- **NEW** Excel report generation from templates + +## Impact +- Affected specs: middleware, authentication, meeting-management, transcription, ai-summarization, excel-export +- Affected code: New Python FastAPI backend, new Electron frontend +- External dependencies: PJ-Auth API, MySQL database, Dify LLM service + +## Success Criteria +- Users can login via company SSO +- Meetings can be created with required metadata (subject, time, chairperson, location, recorder, attendees) +- Speech-to-text works offline on i5/8GB hardware +- AI generates structured conclusions and action items from transcripts +- Action items have trackable status (Open/In Progress/Done/Delayed) +- Excel reports can be exported with all meeting data diff --git a/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/ai-summarization/spec.md b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/ai-summarization/spec.md new file mode 100644 index 0000000..2136467 --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/ai-summarization/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Dify Integration +The middleware server SHALL integrate with Dify LLM at https://dify.theaken.com/v1 for transcript summarization. + +#### Scenario: Successful summarization +- **WHEN** user submits POST /api/ai/summarize with transcript text +- **THEN** the server SHALL call Dify API and return structured JSON with conclusions and action_items + +#### Scenario: Dify timeout handling +- **WHEN** Dify API does not respond within timeout period +- **THEN** the server SHALL return HTTP 504 with timeout error and client can retry + +#### Scenario: Dify error handling +- **WHEN** Dify API returns error (500, rate limit, etc.) +- **THEN** the server SHALL return appropriate HTTP error with details + +### Requirement: Structured Output Format +The AI summarization SHALL return structured data with conclusions and action items. + +#### Scenario: Complete structured response +- **WHEN** transcript contains clear decisions and assignments +- **THEN** response SHALL include conclusions array and action_items array with content, owner, due_date fields + +#### Scenario: Partial data extraction +- **WHEN** transcript lacks explicit owner or due_date for action items +- **THEN** those fields SHALL be empty strings allowing manual completion + +### Requirement: Dify Prompt Configuration +The Dify workflow SHALL be configured with appropriate system prompt for meeting summarization. + +#### Scenario: System prompt behavior +- **WHEN** transcript is sent to Dify +- **THEN** Dify SHALL use configured prompt to extract conclusions and action_items in JSON format + +### Requirement: Manual Data Completion +The Electron client SHALL allow users to manually complete missing AI-extracted data. + +#### Scenario: Fill missing owner +- **WHEN** AI returns action item without owner +- **THEN** user SHALL be able to select or type owner name in the UI + +#### Scenario: Fill missing due date +- **WHEN** AI returns action item without due_date +- **THEN** user SHALL be able to select date using date picker diff --git a/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/authentication/spec.md b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/authentication/spec.md new file mode 100644 index 0000000..dedeb8e --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/authentication/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Login Proxy +The middleware server SHALL proxy login requests to the company Auth API at https://pj-auth-api.vercel.app/api/auth/login. + +#### Scenario: Successful login +- **WHEN** user submits valid credentials to POST /api/login +- **THEN** the server SHALL forward to Auth API and return the JWT token + +#### Scenario: Admin role detection +- **WHEN** user logs in with email ymirliu@panjit.com.tw +- **THEN** the response JWT payload SHALL include role: "admin" + +#### Scenario: Invalid credentials +- **WHEN** user submits invalid credentials +- **THEN** the server SHALL return HTTP 401 with error message from Auth API + +### Requirement: Token Validation +The middleware server SHALL validate JWT tokens on protected endpoints. + +#### Scenario: Valid token access +- **WHEN** request includes valid JWT in Authorization header +- **THEN** the request SHALL proceed to the endpoint handler + +#### Scenario: Expired token +- **WHEN** request includes expired JWT +- **THEN** the server SHALL return HTTP 401 with "token_expired" error code + +#### Scenario: Missing token +- **WHEN** request to protected endpoint lacks Authorization header +- **THEN** the server SHALL return HTTP 401 with "token_required" error code + +### Requirement: Token Auto-Refresh +The Electron client SHALL implement automatic token refresh before expiration. + +#### Scenario: Proactive refresh +- **WHEN** token approaches expiration (within 5 minutes) during active session +- **THEN** the client SHALL request new token transparently without user interruption + +#### Scenario: Refresh during long meeting +- **WHEN** user is in a meeting session lasting longer than token validity +- **THEN** the client SHALL maintain authentication through automatic refresh diff --git a/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/excel-export/spec.md b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/excel-export/spec.md new file mode 100644 index 0000000..cffb23d --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/excel-export/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Excel Report Generation +The middleware server SHALL generate Excel reports from meeting data using templates. + +#### Scenario: Successful export +- **WHEN** user requests GET /api/meetings/:id/export +- **THEN** server SHALL generate Excel file and return as downloadable stream + +#### Scenario: Export non-existent meeting +- **WHEN** user requests export for non-existent meeting ID +- **THEN** server SHALL return HTTP 404 + +### Requirement: Template-based Generation +The Excel export SHALL use openpyxl with template files. + +#### Scenario: Placeholder replacement +- **WHEN** Excel is generated +- **THEN** placeholders ({{subject}}, {{time}}, {{chair}}, etc.) SHALL be replaced with actual meeting data + +#### Scenario: Dynamic row insertion +- **WHEN** meeting has multiple conclusions or action items +- **THEN** rows SHALL be dynamically inserted to accommodate all items + +### Requirement: Complete Data Inclusion +The exported Excel SHALL include all meeting metadata and AI-generated content. + +#### Scenario: Full metadata export +- **WHEN** Excel is generated +- **THEN** it SHALL include subject, meeting_time, location, chairperson, recorder, and attendees + +#### Scenario: Conclusions export +- **WHEN** Excel is generated +- **THEN** all conclusions SHALL be listed with their system codes + +#### Scenario: Action items export +- **WHEN** Excel is generated +- **THEN** all action items SHALL be listed with content, owner, due_date, status, and system code + +### Requirement: Template Management +Admin users SHALL be able to manage Excel templates. + +#### Scenario: Admin template access +- **WHEN** admin user accesses template management +- **THEN** they SHALL be able to upload, view, and update Excel templates diff --git a/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/meeting-management/spec.md b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/meeting-management/spec.md new file mode 100644 index 0000000..9ae2450 --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/meeting-management/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Create Meeting +The system SHALL allow users to create meetings with required metadata. + +#### Scenario: Create meeting with all fields +- **WHEN** user submits POST /api/meetings with subject, meeting_time, chairperson, location, recorder, attendees +- **THEN** a new meeting record SHALL be created with auto-generated UUID and the meeting data SHALL be returned + +#### Scenario: Create meeting with missing required fields +- **WHEN** user submits POST /api/meetings without subject or meeting_time +- **THEN** the server SHALL return HTTP 400 with validation error details + +#### Scenario: Recorder defaults to current user +- **WHEN** user creates meeting without specifying recorder +- **THEN** the recorder field SHALL default to the logged-in user's email + +### Requirement: List Meetings +The system SHALL allow users to retrieve a list of meetings. + +#### Scenario: List all meetings for admin +- **WHEN** admin user requests GET /api/meetings +- **THEN** all meetings SHALL be returned + +#### Scenario: List meetings for regular user +- **WHEN** regular user requests GET /api/meetings +- **THEN** only meetings where user is creator, recorder, or attendee SHALL be returned + +### Requirement: Get Meeting Details +The system SHALL allow users to retrieve full meeting details including conclusions and action items. + +#### Scenario: Get meeting with related data +- **WHEN** user requests GET /api/meetings/:id +- **THEN** meeting record with all conclusions and action_items SHALL be returned + +#### Scenario: Get non-existent meeting +- **WHEN** user requests GET /api/meetings/:id for non-existent ID +- **THEN** the server SHALL return HTTP 404 + +### Requirement: Update Meeting +The system SHALL allow users to update meeting data, conclusions, and action items. + +#### Scenario: Update meeting metadata +- **WHEN** user submits PUT /api/meetings/:id with updated fields +- **THEN** the meeting record SHALL be updated and new data returned + +#### Scenario: Update action item status +- **WHEN** user updates action item status to "Done" +- **THEN** the action_items record SHALL reflect the new status + +### Requirement: Delete Meeting +The system SHALL allow authorized users to delete meetings. + +#### Scenario: Admin deletes any meeting +- **WHEN** admin user requests DELETE /api/meetings/:id +- **THEN** the meeting and all related conclusions and action_items SHALL be deleted + +#### Scenario: User deletes own meeting +- **WHEN** user requests DELETE /api/meetings/:id for meeting they created +- **THEN** the meeting and all related data SHALL be deleted + +### Requirement: System Code Generation +The system SHALL auto-generate unique system codes for conclusions and action items. + +#### Scenario: Generate conclusion code +- **WHEN** a conclusion is created for a meeting on 2025-12-10 +- **THEN** the system_code SHALL follow format C-20251210-XX where XX is sequence number + +#### Scenario: Generate action item code +- **WHEN** an action item is created for a meeting on 2025-12-10 +- **THEN** the system_code SHALL follow format A-20251210-XX where XX is sequence number diff --git a/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/middleware/spec.md b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/middleware/spec.md new file mode 100644 index 0000000..aec5ff6 --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/middleware/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: FastAPI Server Configuration +The middleware server SHALL be implemented using Python FastAPI framework with environment-based configuration. + +#### Scenario: Server startup with valid configuration +- **WHEN** the server starts with valid .env file containing DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME, DIFY_API_URL, DIFY_API_KEY +- **THEN** the server SHALL start successfully and accept connections + +#### Scenario: Server startup with missing configuration +- **WHEN** the server starts with missing required environment variables +- **THEN** the server SHALL fail to start with descriptive error message + +### Requirement: Database Connection Pool +The middleware server SHALL maintain a connection pool to the MySQL database at mysql.theaken.com:33306. + +#### Scenario: Database connection success +- **WHEN** the server connects to MySQL with valid credentials +- **THEN** a connection pool SHALL be established and queries SHALL execute successfully + +#### Scenario: Database connection failure +- **WHEN** the database is unreachable +- **THEN** the server SHALL return HTTP 503 with error details for affected endpoints + +### Requirement: Table Initialization +The middleware server SHALL ensure all required tables exist on startup with the `meeting_` prefix. + +#### Scenario: Tables created on first run +- **WHEN** the server starts and tables do not exist +- **THEN** the server SHALL create meeting_users, meeting_records, meeting_conclusions, and meeting_action_items tables + +#### Scenario: Tables already exist +- **WHEN** the server starts and tables already exist +- **THEN** the server SHALL skip table creation and continue normally + +### Requirement: CORS Configuration +The middleware server SHALL allow cross-origin requests from the Electron client. + +#### Scenario: CORS preflight request +- **WHEN** Electron client sends OPTIONS request +- **THEN** the server SHALL respond with appropriate CORS headers allowing the request diff --git a/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/transcription/spec.md b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/transcription/spec.md new file mode 100644 index 0000000..fb7e13a --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/transcription/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Edge Speech-to-Text +The Electron client SHALL perform speech-to-text conversion locally using faster-whisper int8 model. + +#### Scenario: Successful transcription +- **WHEN** user records audio during a meeting +- **THEN** the audio SHALL be transcribed locally without network dependency + +#### Scenario: Transcription on target hardware +- **WHEN** running on i5 processor with 8GB RAM +- **THEN** transcription SHALL complete within acceptable latency for real-time display + +### Requirement: Traditional Chinese Output +The transcription engine SHALL output Traditional Chinese (็น้ซ”ไธญๆ–‡) text. + +#### Scenario: Simplified to Traditional conversion +- **WHEN** whisper outputs Simplified Chinese characters +- **THEN** OpenCC SHALL convert output to Traditional Chinese + +#### Scenario: Native Traditional Chinese +- **WHEN** whisper outputs Traditional Chinese directly +- **THEN** the text SHALL pass through unchanged + +### Requirement: Real-time Display +The Electron client SHALL display transcription results in real-time. + +#### Scenario: Streaming transcription +- **WHEN** user is recording +- **THEN** transcribed text SHALL appear in the left panel within seconds of speech + +### Requirement: Python Sidecar +The transcription engine SHALL be packaged as a Python sidecar using PyInstaller. + +#### Scenario: Sidecar startup +- **WHEN** Electron app launches +- **THEN** the Python sidecar containing faster-whisper and OpenCC SHALL be available + +#### Scenario: Sidecar communication +- **WHEN** Electron sends audio data to sidecar +- **THEN** transcribed text SHALL be returned via IPC diff --git a/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/tasks.md b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/tasks.md new file mode 100644 index 0000000..f4bcd17 --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/tasks.md @@ -0,0 +1,67 @@ +## 1. Middleware Server Foundation +- [x] 1.1 Initialize Python project with FastAPI, uvicorn, python-dotenv +- [x] 1.2 Create .env.example with all required environment variables +- [x] 1.3 Implement database connection pool with mysql-connector-python +- [x] 1.4 Create table initialization script (meeting_users, meeting_records, meeting_conclusions, meeting_action_items) +- [x] 1.5 Configure CORS middleware for Electron client +- [x] 1.6 Add health check endpoint GET /api/health + +## 2. Authentication +- [x] 2.1 Implement POST /api/login proxy to PJ-Auth API +- [x] 2.2 Add admin role detection for ymirliu@panjit.com.tw +- [x] 2.3 Create JWT validation middleware for protected routes +- [x] 2.4 Handle token expiration with appropriate error codes + +## 3. Meeting CRUD +- [x] 3.1 Implement POST /api/meetings (create meeting) +- [x] 3.2 Implement GET /api/meetings (list meetings with user filtering) +- [x] 3.3 Implement GET /api/meetings/:id (get meeting with conclusions and action items) +- [x] 3.4 Implement PUT /api/meetings/:id (update meeting) +- [x] 3.5 Implement DELETE /api/meetings/:id (delete meeting cascade) +- [x] 3.6 Implement system code generation (C-YYYYMMDD-XX, A-YYYYMMDD-XX) + +## 4. AI Summarization +- [x] 4.1 Implement POST /api/ai/summarize endpoint +- [x] 4.2 Configure Dify API client with timeout and retry +- [x] 4.3 Parse Dify response into conclusions and action_items structure +- [x] 4.4 Handle partial data (empty owner/due_date) + +## 5. Excel Export +- [x] 5.1 Create Excel template with placeholders +- [x] 5.2 Implement GET /api/meetings/:id/export endpoint +- [x] 5.3 Implement placeholder replacement ({{subject}}, {{time}}, etc.) +- [x] 5.4 Implement dynamic row insertion for conclusions and action items + +## 6. Electron Client - Core +- [x] 6.1 Initialize Electron project with electron-builder +- [x] 6.2 Create main window and basic navigation +- [x] 6.3 Implement login page with auth API integration +- [x] 6.4 Implement token storage and auto-refresh interceptor + +## 7. Electron Client - Meeting UI +- [x] 7.1 Create meeting list page +- [x] 7.2 Create meeting creation form (metadata fields) +- [x] 7.3 Create dual-panel meeting view (transcript left, notes right) +- [x] 7.4 Implement conclusion/action item editing with manual completion UI +- [x] 7.5 Add export button with download handling + +## 8. Transcription Engine +- [x] 8.1 Create Python sidecar project with faster-whisper and OpenCC +- [x] 8.2 Implement audio input capture +- [x] 8.3 Implement transcription with int8 model +- [x] 8.4 Implement OpenCC Traditional Chinese conversion +- [x] 8.5 Set up IPC communication between Electron and sidecar +- [x] 8.6 Package sidecar with PyInstaller + +## 9. Testing +- [x] 9.1 Unit tests: DB connection, table creation +- [x] 9.2 Unit tests: Dify proxy with mock responses +- [x] 9.3 Unit tests: Admin role detection +- [x] 9.4 Integration test: Auth flow with token refresh +- [x] 9.5 Integration test: Full meeting cycle (create โ†’ transcribe โ†’ summarize โ†’ save โ†’ export) + +## 10. Deployment Preparation +- [x] 10.1 Create requirements.txt with all dependencies +- [x] 10.2 Create deployment documentation +- [x] 10.3 Configure electron-builder for portable target +- [x] 10.4 Verify faster-whisper performance on i5/8GB hardware diff --git a/openspec/changes/archive/2025-12-10-add-realtime-transcription/design.md b/openspec/changes/archive/2025-12-10-add-realtime-transcription/design.md new file mode 100644 index 0000000..f0a8f17 --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-realtime-transcription/design.md @@ -0,0 +1,117 @@ +## Context +The Meeting Assistant currently uses batch transcription: audio is recorded, saved to file, then sent to Whisper for processing. This creates a poor UX where users must wait until recording stops to see any text. Users also cannot correct transcription errors. + +**Stakeholders**: End users recording meetings, admin reviewing transcripts +**Constraints**: i5/8GB hardware target, offline capability required + +## Goals / Non-Goals + +### Goals +- Real-time text display during recording (< 3 second latency) +- Segment-based editing without disrupting ongoing transcription +- Punctuation in output (Chinese: ใ€‚๏ผŒ๏ผŸ๏ผ๏ผ›๏ผš) +- Maintain offline capability (all processing local) + +### Non-Goals +- Speaker diarization (who said what) - future enhancement +- Multi-language mixing - Chinese only for MVP +- Cloud-based transcription fallback + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Renderer Process (meeting-detail.html) โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ MediaRecorderโ”‚โ”€โ”€โ”€โ–ถโ”‚ Editable Transcript Component โ”‚ โ”‚ +โ”‚ โ”‚ (audio chunks) โ”‚ [Segment 1] [Segment 2] [...] โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ IPC: stream-audio-chunk โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Main Process (main.js) โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Audio Buffer โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ Sidecar (stdin pipe) โ”‚ โ”‚ +โ”‚ โ”‚ (accumulate PCM) โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ IPC: transcription-segment +โ”‚ โ–ผ โ”‚ +โ”‚ Forward to renderer โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ stdin (WAV chunks) +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Sidecar Process (transcriber.py) โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ VAD Buffer โ”‚โ”€โ”€โ–ถโ”‚ Whisper โ”‚โ”€โ”€โ–ถโ”‚ Punctuator โ”‚ โ”‚ +โ”‚ โ”‚ (silero-vad) โ”‚ โ”‚ (transcribe) โ”‚ โ”‚ (rule-based) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ Detect speech end โ”‚ โ”‚ +โ”‚ โ–ผ โ–ผ โ”‚ +โ”‚ stdout: {"segment_id": 1, "text": "ไปŠๅคฉ้–‹ๆœƒ่จŽ่ซ–ใ€‚", ...} โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Decisions + +### Decision 1: VAD-triggered Segmentation +**What**: Use Silero VAD to detect speech boundaries, transcribe complete utterances +**Why**: +- More accurate than fixed-interval chunking +- Natural sentence boundaries +- Reduces partial/incomplete transcriptions +**Alternatives**: +- Fixed 5-second chunks (simpler but cuts mid-sentence) +- Word-level streaming (too fragmented, higher latency) + +### Decision 2: Segment-based Editing +**What**: Each VAD segment becomes an editable text block with unique ID +**Why**: +- Users can edit specific segments without affecting others +- New segments append without disrupting editing +- Simple merge on save (concatenate all segments) +**Alternatives**: +- Single textarea (editing conflicts with appending text) +- Contenteditable div (complex cursor management) + +### Decision 3: Audio Format Pipeline +**What**: WebM (MediaRecorder) โ†’ WAV conversion in main.js โ†’ raw PCM to sidecar +**Why**: +- MediaRecorder only outputs WebM/Opus in browsers +- Whisper works best with WAV/PCM +- Conversion in main.js keeps sidecar simple +**Alternatives**: +- ffmpeg in sidecar (adds large dependency) +- Raw PCM from AudioWorklet (complex, browser compatibility issues) + +### Decision 4: Punctuation via Whisper + Rules +**What**: Enable Whisper word_timestamps, apply rule-based punctuation after +**Why**: +- Whisper alone outputs minimal punctuation for Chinese +- Rule-based post-processing adds ใ€‚๏ผŒ๏ผŸ based on pauses and patterns +- No additional model needed +**Alternatives**: +- Separate punctuation model (adds latency and complexity) +- No punctuation (user requirement) + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|------------| +| Latency > 3s on slow hardware | Use "tiny" model option, skip VAD if needed | +| WebMโ†’WAV conversion quality loss | Use lossless conversion, test on various inputs | +| Memory usage with long meetings | Limit audio buffer to 30s, process and discard | +| Segment boundary splits words | Use VAD with 500ms silence threshold | + +## Implementation Phases + +1. **Phase 1**: Sidecar streaming mode with VAD +2. **Phase 2**: IPC audio streaming pipeline +3. **Phase 3**: Frontend editable segment component +4. **Phase 4**: Punctuation post-processing + +## Open Questions +- Should segments be auto-merged after N seconds of no editing? +- Maximum segment count before auto-archiving old segments? diff --git a/openspec/changes/archive/2025-12-10-add-realtime-transcription/proposal.md b/openspec/changes/archive/2025-12-10-add-realtime-transcription/proposal.md new file mode 100644 index 0000000..ddabcf5 --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-realtime-transcription/proposal.md @@ -0,0 +1,24 @@ +# Change: Add Real-time Streaming Transcription + +## Why +Current transcription workflow requires users to stop recording before seeing results. Users cannot edit transcription errors, and output lacks punctuation. For meeting scenarios, real-time feedback with editable text is essential for immediate correction and context awareness. + +## What Changes +- **Sidecar**: Implement streaming VAD-based transcription with sentence segmentation +- **IPC**: Add continuous audio streaming from renderer to main process to sidecar +- **Frontend**: Make transcript editable with real-time segment updates +- **Punctuation**: Enable Whisper's word timestamps and add sentence boundary detection + +## Impact +- Affected specs: `transcription` (new), `frontend-transcript` (new) +- Affected code: + - `sidecar/transcriber.py` - Add streaming mode with VAD + - `client/src/main.js` - Add audio streaming IPC handlers + - `client/src/preload.js` - Expose streaming APIs + - `client/src/pages/meeting-detail.html` - Editable transcript component + +## Success Criteria +1. User sees text appearing within 2-3 seconds of speaking +2. Each segment is individually editable +3. Output includes punctuation (ใ€‚๏ผŒ๏ผŸ๏ผ) +4. Recording can continue while user edits previous segments diff --git a/openspec/changes/archive/2025-12-10-add-realtime-transcription/specs/frontend-transcript/spec.md b/openspec/changes/archive/2025-12-10-add-realtime-transcription/specs/frontend-transcript/spec.md new file mode 100644 index 0000000..5ace179 --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-realtime-transcription/specs/frontend-transcript/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: Editable Transcript Segments +The frontend SHALL display transcribed text as individually editable segments that can be modified without disrupting ongoing transcription. + +#### Scenario: Display new segment +- **WHEN** a new transcription segment is received from sidecar +- **THEN** a new editable text block SHALL appear in the transcript area +- **AND** the block SHALL be visually distinct (e.g., border, background) +- **AND** the block SHALL be immediately editable + +#### Scenario: Edit existing segment +- **WHEN** user modifies text in a segment +- **THEN** only that segment's local data SHALL be updated +- **AND** new incoming segments SHALL continue to append below +- **AND** the edited segment SHALL show an "edited" indicator + +#### Scenario: Save merged transcript +- **WHEN** user clicks Save button +- **THEN** all segments (edited and unedited) SHALL be concatenated in order +- **AND** the merged text SHALL be saved as transcript_blob + +### Requirement: Real-time Streaming UI +The frontend SHALL provide clear visual feedback during streaming transcription. + +#### Scenario: Recording active indicator +- **WHEN** streaming recording is active +- **THEN** a pulsing recording indicator SHALL be visible +- **AND** the current/active segment SHALL have distinct styling (e.g., highlighted border) +- **AND** the Start Recording button SHALL change to Stop Recording + +#### Scenario: Processing indicator +- **WHEN** audio is being processed but no text has appeared yet +- **THEN** a "Processing..." indicator SHALL appear in the active segment area +- **AND** the indicator SHALL disappear when text arrives + +#### Scenario: Streaming status display +- **WHEN** streaming session is active +- **THEN** the UI SHALL display segment count (e.g., "Segment 5/5") +- **AND** total recording duration + +### Requirement: Audio Streaming IPC +The Electron main process SHALL provide IPC handlers for continuous audio streaming between renderer and sidecar. + +#### Scenario: Start streaming +- **WHEN** renderer calls `startRecordingStream()` +- **THEN** main process SHALL send start_stream command to sidecar +- **AND** return session confirmation to renderer + +#### Scenario: Stream audio data +- **WHEN** renderer sends audio chunk via `streamAudioChunk(arrayBuffer)` +- **THEN** main process SHALL convert WebM to PCM if needed +- **AND** forward to sidecar stdin as base64-encoded audio_chunk command + +#### Scenario: Receive transcription +- **WHEN** sidecar emits a segment result on stdout +- **THEN** main process SHALL parse the JSON +- **AND** forward to renderer via `transcription-segment` IPC event diff --git a/openspec/changes/archive/2025-12-10-add-realtime-transcription/specs/transcription/spec.md b/openspec/changes/archive/2025-12-10-add-realtime-transcription/specs/transcription/spec.md new file mode 100644 index 0000000..52024c9 --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-realtime-transcription/specs/transcription/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: Streaming Transcription Mode +The sidecar SHALL support a streaming mode where audio chunks are continuously received and transcribed in real-time with VAD-triggered segmentation. + +#### Scenario: Start streaming session +- **WHEN** sidecar receives `{"action": "start_stream"}` command +- **THEN** it SHALL initialize audio buffer and VAD processor +- **AND** respond with `{"status": "streaming", "session_id": ""}` + +#### Scenario: Process audio chunk +- **WHEN** sidecar receives `{"action": "audio_chunk", "data": ""}` during active stream +- **THEN** it SHALL append audio to buffer and run VAD detection +- **AND** if speech boundary detected, transcribe accumulated audio +- **AND** emit `{"segment_id": , "text": "", "is_final": true}` + +#### Scenario: Stop streaming session +- **WHEN** sidecar receives `{"action": "stop_stream"}` command +- **THEN** it SHALL transcribe any remaining buffered audio +- **AND** respond with `{"status": "stream_stopped", "total_segments": }` + +### Requirement: VAD-based Speech Segmentation +The sidecar SHALL use Voice Activity Detection to identify natural speech boundaries for segmentation. + +#### Scenario: Detect speech end +- **WHEN** VAD detects silence exceeding 500ms after speech +- **THEN** the accumulated speech audio SHALL be sent for transcription +- **AND** a new segment SHALL begin for subsequent speech + +#### Scenario: Handle continuous speech +- **WHEN** speech continues for more than 15 seconds without pause +- **THEN** the sidecar SHALL force a segment boundary +- **AND** transcribe the 15-second chunk to prevent excessive latency + +### Requirement: Punctuation in Transcription Output +The sidecar SHALL output transcribed text with appropriate Chinese punctuation marks. + +#### Scenario: Add sentence-ending punctuation +- **WHEN** transcription completes for a segment +- **THEN** the output SHALL include period (ใ€‚) at natural sentence boundaries +- **AND** question marks (๏ผŸ) for interrogative sentences +- **AND** commas (๏ผŒ) for clause breaks within sentences + +#### Scenario: Detect question patterns +- **WHEN** transcribed text ends with question particles (ๅ—Žใ€ๅ‘ขใ€ไป€้บผใ€ๆ€Ž้บผใ€็‚บไป€้บผ) +- **THEN** the punctuation processor SHALL append question mark (๏ผŸ) diff --git a/openspec/changes/archive/2025-12-10-add-realtime-transcription/tasks.md b/openspec/changes/archive/2025-12-10-add-realtime-transcription/tasks.md new file mode 100644 index 0000000..81cab14 --- /dev/null +++ b/openspec/changes/archive/2025-12-10-add-realtime-transcription/tasks.md @@ -0,0 +1,53 @@ +## 1. Sidecar Streaming Infrastructure +- [x] 1.1 Add silero-vad dependency to requirements.txt +- [x] 1.2 Implement VADProcessor class with speech boundary detection +- [x] 1.3 Add streaming mode to Transcriber (action: "start_stream", "audio_chunk", "stop_stream") +- [x] 1.4 Implement audio buffer with VAD-triggered transcription +- [x] 1.5 Add segment_id tracking for each utterance +- [x] 1.6 Test VAD with sample Chinese speech audio + +## 2. Punctuation Processing +- [x] 2.1 Enable word_timestamps in Whisper transcribe() +- [x] 2.2 Implement ChinesePunctuator class with rule-based punctuation +- [x] 2.3 Add pause-based sentence boundary detection (>500ms โ†’ period) +- [x] 2.4 Add question detection (ๅ—Žใ€ๅ‘ขใ€ไป€้บผ patterns โ†’ ๏ผŸ) +- [x] 2.5 Test punctuation output quality with sample transcripts + +## 3. IPC Audio Streaming +- [x] 3.1 Add "start-recording-stream" IPC handler in main.js +- [x] 3.2 Add "stream-audio-chunk" IPC handler to forward audio to sidecar +- [x] 3.3 Add "stop-recording-stream" IPC handler +- [x] 3.4 Implement WebM to PCM conversion using web-audio-api or ffmpeg.wasm +- [x] 3.5 Forward sidecar segment events to renderer via "transcription-segment" IPC +- [x] 3.6 Update preload.js with streaming API exposure + +## 4. Frontend Editable Transcript +- [x] 4.1 Create TranscriptSegment component (editable text block with segment_id) +- [x] 4.2 Implement segment container with append-only behavior during recording +- [x] 4.3 Add edit handler that updates local segment data +- [x] 4.4 Style active segment (currently receiving text) differently +- [x] 4.5 Update Save button to merge all segments into transcript_blob +- [x] 4.6 Add visual indicator for streaming status + +## 5. Integration & Testing +- [x] 5.1 End-to-end test: start recording โ†’ speak โ†’ see text appear +- [x] 5.2 Test editing segment while new segments arrive +- [x] 5.3 Test save with mixed edited/unedited segments +- [x] 5.4 Performance test on i5/8GB target hardware +- [x] 5.5 Test with 30+ minute continuous recording +- [x] 5.6 Update meeting-detail.html recording flow documentation + +## Dependencies +- Task 3 depends on Task 1 (sidecar must support streaming first) +- Task 4 depends on Task 3 (frontend needs IPC to receive segments) +- Task 2 can run in parallel with Task 3 + +## Parallelizable Work +- Tasks 1 and 4 can start simultaneously (sidecar and frontend scaffolding) +- Task 2 can run in parallel with Task 3 + +## Implementation Notes +- VAD uses Silero VAD with fallback to 5-second time-based segmentation if torch unavailable +- Audio captured at 16kHz mono, converted to int16 PCM, sent as base64 +- ChinesePunctuator uses regex patterns for question detection +- Segments are editable immediately, edited segments marked with orange border diff --git a/openspec/project.md b/openspec/project.md new file mode 100644 index 0000000..5408938 --- /dev/null +++ b/openspec/project.md @@ -0,0 +1,56 @@ +# Project Context + +## Purpose +Enterprise meeting knowledge management solution that automates meeting transcription and generates structured summaries. Solves the time-consuming problem of manual meeting notes by using edge AI for speech-to-text and LLM for intelligent summarization with action item tracking. + +## Tech Stack +- **Frontend**: Electron (edge computing for offline transcription) +- **Backend**: Python FastAPI (middleware server) +- **Database**: MySQL (shared instance at mysql.theaken.com:33306) +- **AI/ML**: + - faster-whisper (int8) for local speech-to-text + - OpenCC for Traditional Chinese conversion + - Dify LLM for summarization +- **Key Libraries**: mysql-connector-python, fastapi, requests, openpyxl, PyInstaller + +## Project Conventions + +### Code Style +- Database tables must use `meeting_` prefix +- System IDs follow format: `C-YYYYMMDD-XX` (conclusions), `A-YYYYMMDD-XX` (action items) +- API endpoints use `/api/` prefix +- Environment variables for sensitive config (DB credentials, API keys) + +### Architecture Patterns +- **Three-tier architecture**: Electron Client โ†’ FastAPI Middleware โ†’ MySQL/Dify +- **Security**: DB connections and API keys must NOT be in Electron client; all secrets stay in middleware +- **Edge Computing**: Speech-to-text runs locally in Electron for offline capability +- **Proxy Pattern**: Middleware proxies auth requests to external Auth API + +### Testing Strategy +- **Unit Tests**: DB connectivity, Dify proxy, admin role detection +- **Integration Tests**: Auth flow with token refresh, full meeting cycle (create โ†’ record โ†’ summarize โ†’ save โ†’ export) +- **Deployment Checklist**: Environment validation, table creation, package verification + +### Git Workflow +- Feature branches for new capabilities +- OpenSpec change proposals for significant features + +## Domain Context +- **ๆœƒ่ญฐ่จ˜้Œ„ (Meeting Records)**: Core entity with metadata (subject, time, chairperson, location, recorder, attendees) +- **้€ๅญ—็จฟ (Transcript)**: Raw AI-generated speech-to-text output +- **ๆœƒ่ญฐ็ต่ซ– (Conclusions)**: Summarized key decisions from meetings +- **ๅพ…่พฆไบ‹้ … (Action Items)**: Tracked tasks with owner, due date, and status (Open/In Progress/Done/Delayed) +- **Admin User**: ymirliu@panjit.com.tw has full access to all meetings and Excel template management + +## Important Constraints +- Target hardware: i5/8GB laptop must run faster-whisper int8 locally +- Security: No DB credentials or API keys in client-side code +- Language: Must support Traditional Chinese (็น้ซ”ไธญๆ–‡) output +- Data Isolation: All tables prefixed with `meeting_` +- Token Management: Client must implement auto-refresh for long meetings + +## External Dependencies +- **Auth API**: https://pj-auth-api.vercel.app/api/auth/login (company SSO) +- **Dify LLM**: https://dify.theaken.com/v1 (AI summarization) +- **MySQL**: mysql.theaken.com:33306, database `db_A060` diff --git a/openspec/specs/ai-summarization/spec.md b/openspec/specs/ai-summarization/spec.md new file mode 100644 index 0000000..3d35f36 --- /dev/null +++ b/openspec/specs/ai-summarization/spec.md @@ -0,0 +1,49 @@ +# ai-summarization Specification + +## Purpose +TBD - created by archiving change add-meeting-assistant-mvp. Update Purpose after archive. +## Requirements +### Requirement: Dify Integration +The middleware server SHALL integrate with Dify LLM at https://dify.theaken.com/v1 for transcript summarization. + +#### Scenario: Successful summarization +- **WHEN** user submits POST /api/ai/summarize with transcript text +- **THEN** the server SHALL call Dify API and return structured JSON with conclusions and action_items + +#### Scenario: Dify timeout handling +- **WHEN** Dify API does not respond within timeout period +- **THEN** the server SHALL return HTTP 504 with timeout error and client can retry + +#### Scenario: Dify error handling +- **WHEN** Dify API returns error (500, rate limit, etc.) +- **THEN** the server SHALL return appropriate HTTP error with details + +### Requirement: Structured Output Format +The AI summarization SHALL return structured data with conclusions and action items. + +#### Scenario: Complete structured response +- **WHEN** transcript contains clear decisions and assignments +- **THEN** response SHALL include conclusions array and action_items array with content, owner, due_date fields + +#### Scenario: Partial data extraction +- **WHEN** transcript lacks explicit owner or due_date for action items +- **THEN** those fields SHALL be empty strings allowing manual completion + +### Requirement: Dify Prompt Configuration +The Dify workflow SHALL be configured with appropriate system prompt for meeting summarization. + +#### Scenario: System prompt behavior +- **WHEN** transcript is sent to Dify +- **THEN** Dify SHALL use configured prompt to extract conclusions and action_items in JSON format + +### Requirement: Manual Data Completion +The Electron client SHALL allow users to manually complete missing AI-extracted data. + +#### Scenario: Fill missing owner +- **WHEN** AI returns action item without owner +- **THEN** user SHALL be able to select or type owner name in the UI + +#### Scenario: Fill missing due date +- **WHEN** AI returns action item without due_date +- **THEN** user SHALL be able to select date using date picker + diff --git a/openspec/specs/authentication/spec.md b/openspec/specs/authentication/spec.md new file mode 100644 index 0000000..69fe49d --- /dev/null +++ b/openspec/specs/authentication/spec.md @@ -0,0 +1,46 @@ +# authentication Specification + +## Purpose +TBD - created by archiving change add-meeting-assistant-mvp. Update Purpose after archive. +## Requirements +### Requirement: Login Proxy +The middleware server SHALL proxy login requests to the company Auth API at https://pj-auth-api.vercel.app/api/auth/login. + +#### Scenario: Successful login +- **WHEN** user submits valid credentials to POST /api/login +- **THEN** the server SHALL forward to Auth API and return the JWT token + +#### Scenario: Admin role detection +- **WHEN** user logs in with email ymirliu@panjit.com.tw +- **THEN** the response JWT payload SHALL include role: "admin" + +#### Scenario: Invalid credentials +- **WHEN** user submits invalid credentials +- **THEN** the server SHALL return HTTP 401 with error message from Auth API + +### Requirement: Token Validation +The middleware server SHALL validate JWT tokens on protected endpoints. + +#### Scenario: Valid token access +- **WHEN** request includes valid JWT in Authorization header +- **THEN** the request SHALL proceed to the endpoint handler + +#### Scenario: Expired token +- **WHEN** request includes expired JWT +- **THEN** the server SHALL return HTTP 401 with "token_expired" error code + +#### Scenario: Missing token +- **WHEN** request to protected endpoint lacks Authorization header +- **THEN** the server SHALL return HTTP 401 with "token_required" error code + +### Requirement: Token Auto-Refresh +The Electron client SHALL implement automatic token refresh before expiration. + +#### Scenario: Proactive refresh +- **WHEN** token approaches expiration (within 5 minutes) during active session +- **THEN** the client SHALL request new token transparently without user interruption + +#### Scenario: Refresh during long meeting +- **WHEN** user is in a meeting session lasting longer than token validity +- **THEN** the client SHALL maintain authentication through automatic refresh + diff --git a/openspec/specs/excel-export/spec.md b/openspec/specs/excel-export/spec.md new file mode 100644 index 0000000..b9d3fc2 --- /dev/null +++ b/openspec/specs/excel-export/spec.md @@ -0,0 +1,49 @@ +# excel-export Specification + +## Purpose +TBD - created by archiving change add-meeting-assistant-mvp. Update Purpose after archive. +## Requirements +### Requirement: Excel Report Generation +The middleware server SHALL generate Excel reports from meeting data using templates. + +#### Scenario: Successful export +- **WHEN** user requests GET /api/meetings/:id/export +- **THEN** server SHALL generate Excel file and return as downloadable stream + +#### Scenario: Export non-existent meeting +- **WHEN** user requests export for non-existent meeting ID +- **THEN** server SHALL return HTTP 404 + +### Requirement: Template-based Generation +The Excel export SHALL use openpyxl with template files. + +#### Scenario: Placeholder replacement +- **WHEN** Excel is generated +- **THEN** placeholders ({{subject}}, {{time}}, {{chair}}, etc.) SHALL be replaced with actual meeting data + +#### Scenario: Dynamic row insertion +- **WHEN** meeting has multiple conclusions or action items +- **THEN** rows SHALL be dynamically inserted to accommodate all items + +### Requirement: Complete Data Inclusion +The exported Excel SHALL include all meeting metadata and AI-generated content. + +#### Scenario: Full metadata export +- **WHEN** Excel is generated +- **THEN** it SHALL include subject, meeting_time, location, chairperson, recorder, and attendees + +#### Scenario: Conclusions export +- **WHEN** Excel is generated +- **THEN** all conclusions SHALL be listed with their system codes + +#### Scenario: Action items export +- **WHEN** Excel is generated +- **THEN** all action items SHALL be listed with content, owner, due_date, status, and system code + +### Requirement: Template Management +Admin users SHALL be able to manage Excel templates. + +#### Scenario: Admin template access +- **WHEN** admin user accesses template management +- **THEN** they SHALL be able to upload, view, and update Excel templates + diff --git a/openspec/specs/frontend-transcript/spec.md b/openspec/specs/frontend-transcript/spec.md new file mode 100644 index 0000000..ef0ae7f --- /dev/null +++ b/openspec/specs/frontend-transcript/spec.md @@ -0,0 +1,62 @@ +# frontend-transcript Specification + +## Purpose +TBD - created by archiving change add-realtime-transcription. Update Purpose after archive. +## Requirements +### Requirement: Editable Transcript Segments +The frontend SHALL display transcribed text as individually editable segments that can be modified without disrupting ongoing transcription. + +#### Scenario: Display new segment +- **WHEN** a new transcription segment is received from sidecar +- **THEN** a new editable text block SHALL appear in the transcript area +- **AND** the block SHALL be visually distinct (e.g., border, background) +- **AND** the block SHALL be immediately editable + +#### Scenario: Edit existing segment +- **WHEN** user modifies text in a segment +- **THEN** only that segment's local data SHALL be updated +- **AND** new incoming segments SHALL continue to append below +- **AND** the edited segment SHALL show an "edited" indicator + +#### Scenario: Save merged transcript +- **WHEN** user clicks Save button +- **THEN** all segments (edited and unedited) SHALL be concatenated in order +- **AND** the merged text SHALL be saved as transcript_blob + +### Requirement: Real-time Streaming UI +The frontend SHALL provide clear visual feedback during streaming transcription. + +#### Scenario: Recording active indicator +- **WHEN** streaming recording is active +- **THEN** a pulsing recording indicator SHALL be visible +- **AND** the current/active segment SHALL have distinct styling (e.g., highlighted border) +- **AND** the Start Recording button SHALL change to Stop Recording + +#### Scenario: Processing indicator +- **WHEN** audio is being processed but no text has appeared yet +- **THEN** a "Processing..." indicator SHALL appear in the active segment area +- **AND** the indicator SHALL disappear when text arrives + +#### Scenario: Streaming status display +- **WHEN** streaming session is active +- **THEN** the UI SHALL display segment count (e.g., "Segment 5/5") +- **AND** total recording duration + +### Requirement: Audio Streaming IPC +The Electron main process SHALL provide IPC handlers for continuous audio streaming between renderer and sidecar. + +#### Scenario: Start streaming +- **WHEN** renderer calls `startRecordingStream()` +- **THEN** main process SHALL send start_stream command to sidecar +- **AND** return session confirmation to renderer + +#### Scenario: Stream audio data +- **WHEN** renderer sends audio chunk via `streamAudioChunk(arrayBuffer)` +- **THEN** main process SHALL convert WebM to PCM if needed +- **AND** forward to sidecar stdin as base64-encoded audio_chunk command + +#### Scenario: Receive transcription +- **WHEN** sidecar emits a segment result on stdout +- **THEN** main process SHALL parse the JSON +- **AND** forward to renderer via `transcription-segment` IPC event + diff --git a/openspec/specs/meeting-management/spec.md b/openspec/specs/meeting-management/spec.md new file mode 100644 index 0000000..3c6f52f --- /dev/null +++ b/openspec/specs/meeting-management/spec.md @@ -0,0 +1,75 @@ +# meeting-management Specification + +## Purpose +TBD - created by archiving change add-meeting-assistant-mvp. Update Purpose after archive. +## Requirements +### Requirement: Create Meeting +The system SHALL allow users to create meetings with required metadata. + +#### Scenario: Create meeting with all fields +- **WHEN** user submits POST /api/meetings with subject, meeting_time, chairperson, location, recorder, attendees +- **THEN** a new meeting record SHALL be created with auto-generated UUID and the meeting data SHALL be returned + +#### Scenario: Create meeting with missing required fields +- **WHEN** user submits POST /api/meetings without subject or meeting_time +- **THEN** the server SHALL return HTTP 400 with validation error details + +#### Scenario: Recorder defaults to current user +- **WHEN** user creates meeting without specifying recorder +- **THEN** the recorder field SHALL default to the logged-in user's email + +### Requirement: List Meetings +The system SHALL allow users to retrieve a list of meetings. + +#### Scenario: List all meetings for admin +- **WHEN** admin user requests GET /api/meetings +- **THEN** all meetings SHALL be returned + +#### Scenario: List meetings for regular user +- **WHEN** regular user requests GET /api/meetings +- **THEN** only meetings where user is creator, recorder, or attendee SHALL be returned + +### Requirement: Get Meeting Details +The system SHALL allow users to retrieve full meeting details including conclusions and action items. + +#### Scenario: Get meeting with related data +- **WHEN** user requests GET /api/meetings/:id +- **THEN** meeting record with all conclusions and action_items SHALL be returned + +#### Scenario: Get non-existent meeting +- **WHEN** user requests GET /api/meetings/:id for non-existent ID +- **THEN** the server SHALL return HTTP 404 + +### Requirement: Update Meeting +The system SHALL allow users to update meeting data, conclusions, and action items. + +#### Scenario: Update meeting metadata +- **WHEN** user submits PUT /api/meetings/:id with updated fields +- **THEN** the meeting record SHALL be updated and new data returned + +#### Scenario: Update action item status +- **WHEN** user updates action item status to "Done" +- **THEN** the action_items record SHALL reflect the new status + +### Requirement: Delete Meeting +The system SHALL allow authorized users to delete meetings. + +#### Scenario: Admin deletes any meeting +- **WHEN** admin user requests DELETE /api/meetings/:id +- **THEN** the meeting and all related conclusions and action_items SHALL be deleted + +#### Scenario: User deletes own meeting +- **WHEN** user requests DELETE /api/meetings/:id for meeting they created +- **THEN** the meeting and all related data SHALL be deleted + +### Requirement: System Code Generation +The system SHALL auto-generate unique system codes for conclusions and action items. + +#### Scenario: Generate conclusion code +- **WHEN** a conclusion is created for a meeting on 2025-12-10 +- **THEN** the system_code SHALL follow format C-20251210-XX where XX is sequence number + +#### Scenario: Generate action item code +- **WHEN** an action item is created for a meeting on 2025-12-10 +- **THEN** the system_code SHALL follow format A-20251210-XX where XX is sequence number + diff --git a/openspec/specs/middleware/spec.md b/openspec/specs/middleware/spec.md new file mode 100644 index 0000000..8dd84db --- /dev/null +++ b/openspec/specs/middleware/spec.md @@ -0,0 +1,45 @@ +# middleware Specification + +## Purpose +TBD - created by archiving change add-meeting-assistant-mvp. Update Purpose after archive. +## Requirements +### Requirement: FastAPI Server Configuration +The middleware server SHALL be implemented using Python FastAPI framework with environment-based configuration. + +#### Scenario: Server startup with valid configuration +- **WHEN** the server starts with valid .env file containing DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME, DIFY_API_URL, DIFY_API_KEY +- **THEN** the server SHALL start successfully and accept connections + +#### Scenario: Server startup with missing configuration +- **WHEN** the server starts with missing required environment variables +- **THEN** the server SHALL fail to start with descriptive error message + +### Requirement: Database Connection Pool +The middleware server SHALL maintain a connection pool to the MySQL database at mysql.theaken.com:33306. + +#### Scenario: Database connection success +- **WHEN** the server connects to MySQL with valid credentials +- **THEN** a connection pool SHALL be established and queries SHALL execute successfully + +#### Scenario: Database connection failure +- **WHEN** the database is unreachable +- **THEN** the server SHALL return HTTP 503 with error details for affected endpoints + +### Requirement: Table Initialization +The middleware server SHALL ensure all required tables exist on startup with the `meeting_` prefix. + +#### Scenario: Tables created on first run +- **WHEN** the server starts and tables do not exist +- **THEN** the server SHALL create meeting_users, meeting_records, meeting_conclusions, and meeting_action_items tables + +#### Scenario: Tables already exist +- **WHEN** the server starts and tables already exist +- **THEN** the server SHALL skip table creation and continue normally + +### Requirement: CORS Configuration +The middleware server SHALL allow cross-origin requests from the Electron client. + +#### Scenario: CORS preflight request +- **WHEN** Electron client sends OPTIONS request +- **THEN** the server SHALL respond with appropriate CORS headers allowing the request + diff --git a/openspec/specs/transcription/spec.md b/openspec/specs/transcription/spec.md new file mode 100644 index 0000000..bce3a7c --- /dev/null +++ b/openspec/specs/transcription/spec.md @@ -0,0 +1,90 @@ +# transcription Specification + +## Purpose +TBD - created by archiving change add-meeting-assistant-mvp. Update Purpose after archive. +## Requirements +### Requirement: Edge Speech-to-Text +The Electron client SHALL perform speech-to-text conversion locally using faster-whisper int8 model. + +#### Scenario: Successful transcription +- **WHEN** user records audio during a meeting +- **THEN** the audio SHALL be transcribed locally without network dependency + +#### Scenario: Transcription on target hardware +- **WHEN** running on i5 processor with 8GB RAM +- **THEN** transcription SHALL complete within acceptable latency for real-time display + +### Requirement: Traditional Chinese Output +The transcription engine SHALL output Traditional Chinese (็น้ซ”ไธญๆ–‡) text. + +#### Scenario: Simplified to Traditional conversion +- **WHEN** whisper outputs Simplified Chinese characters +- **THEN** OpenCC SHALL convert output to Traditional Chinese + +#### Scenario: Native Traditional Chinese +- **WHEN** whisper outputs Traditional Chinese directly +- **THEN** the text SHALL pass through unchanged + +### Requirement: Real-time Display +The Electron client SHALL display transcription results in real-time. + +#### Scenario: Streaming transcription +- **WHEN** user is recording +- **THEN** transcribed text SHALL appear in the left panel within seconds of speech + +### Requirement: Python Sidecar +The transcription engine SHALL be packaged as a Python sidecar using PyInstaller. + +#### Scenario: Sidecar startup +- **WHEN** Electron app launches +- **THEN** the Python sidecar containing faster-whisper and OpenCC SHALL be available + +#### Scenario: Sidecar communication +- **WHEN** Electron sends audio data to sidecar +- **THEN** transcribed text SHALL be returned via IPC + +### Requirement: Streaming Transcription Mode +The sidecar SHALL support a streaming mode where audio chunks are continuously received and transcribed in real-time with VAD-triggered segmentation. + +#### Scenario: Start streaming session +- **WHEN** sidecar receives `{"action": "start_stream"}` command +- **THEN** it SHALL initialize audio buffer and VAD processor +- **AND** respond with `{"status": "streaming", "session_id": ""}` + +#### Scenario: Process audio chunk +- **WHEN** sidecar receives `{"action": "audio_chunk", "data": ""}` during active stream +- **THEN** it SHALL append audio to buffer and run VAD detection +- **AND** if speech boundary detected, transcribe accumulated audio +- **AND** emit `{"segment_id": , "text": "", "is_final": true}` + +#### Scenario: Stop streaming session +- **WHEN** sidecar receives `{"action": "stop_stream"}` command +- **THEN** it SHALL transcribe any remaining buffered audio +- **AND** respond with `{"status": "stream_stopped", "total_segments": }` + +### Requirement: VAD-based Speech Segmentation +The sidecar SHALL use Voice Activity Detection to identify natural speech boundaries for segmentation. + +#### Scenario: Detect speech end +- **WHEN** VAD detects silence exceeding 500ms after speech +- **THEN** the accumulated speech audio SHALL be sent for transcription +- **AND** a new segment SHALL begin for subsequent speech + +#### Scenario: Handle continuous speech +- **WHEN** speech continues for more than 15 seconds without pause +- **THEN** the sidecar SHALL force a segment boundary +- **AND** transcribe the 15-second chunk to prevent excessive latency + +### Requirement: Punctuation in Transcription Output +The sidecar SHALL output transcribed text with appropriate Chinese punctuation marks. + +#### Scenario: Add sentence-ending punctuation +- **WHEN** transcription completes for a segment +- **THEN** the output SHALL include period (ใ€‚) at natural sentence boundaries +- **AND** question marks (๏ผŸ) for interrogative sentences +- **AND** commas (๏ผŒ) for clause breaks within sentences + +#### Scenario: Detect question patterns +- **WHEN** transcribed text ends with question particles (ๅ—Žใ€ๅ‘ขใ€ไป€้บผใ€ๆ€Ž้บผใ€็‚บไป€้บผ) +- **THEN** the punctuation processor SHALL append question mark (๏ผŸ) + diff --git a/sidecar/build.py b/sidecar/build.py new file mode 100644 index 0000000..5632a5d --- /dev/null +++ b/sidecar/build.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +""" +Build script for creating standalone transcriber executable using PyInstaller. +""" + +import subprocess +import sys +import os + + +def build(): + """Build the transcriber executable.""" + # PyInstaller command + cmd = [ + sys.executable, "-m", "PyInstaller", + "--onefile", + "--name", "transcriber", + "--distpath", "dist", + "--workpath", "build", + "--specpath", "build", + "--hidden-import", "faster_whisper", + "--hidden-import", "opencc", + "--hidden-import", "numpy", + "--hidden-import", "ctranslate2", + "--hidden-import", "huggingface_hub", + "--hidden-import", "tokenizers", + "--collect-data", "faster_whisper", + "--collect-data", "opencc", + "transcriber.py" + ] + + print("Building transcriber executable...") + print(f"Command: {' '.join(cmd)}") + + result = subprocess.run(cmd, cwd=os.path.dirname(os.path.abspath(__file__))) + + if result.returncode == 0: + print("\nBuild successful! Executable created at: dist/transcriber") + else: + print("\nBuild failed!") + sys.exit(1) + + +if __name__ == "__main__": + build() diff --git a/sidecar/requirements-dev.txt b/sidecar/requirements-dev.txt new file mode 100644 index 0000000..ab53049 --- /dev/null +++ b/sidecar/requirements-dev.txt @@ -0,0 +1,3 @@ +# Development/Build dependencies +-r requirements.txt +pyinstaller>=6.0.0 diff --git a/sidecar/requirements.txt b/sidecar/requirements.txt new file mode 100644 index 0000000..8ef0f9a --- /dev/null +++ b/sidecar/requirements.txt @@ -0,0 +1,5 @@ +# Runtime dependencies +faster-whisper>=1.0.0 +opencc-python-reimplemented>=0.1.7 +numpy>=1.26.0 +onnxruntime>=1.16.0 diff --git a/sidecar/transcriber.py b/sidecar/transcriber.py new file mode 100644 index 0000000..b65aeec --- /dev/null +++ b/sidecar/transcriber.py @@ -0,0 +1,510 @@ +#!/usr/bin/env python3 +""" +Meeting Assistant Transcription Sidecar + +Provides speech-to-text transcription using faster-whisper +with automatic Traditional Chinese conversion via OpenCC. + +Modes: +1. File mode: transcriber.py +2. Server mode: transcriber.py (default, listens on stdin for JSON commands) +3. Streaming mode: Continuous audio processing with VAD segmentation + +Uses ONNX Runtime for VAD (lightweight, ~20MB vs PyTorch ~2GB) +""" + +import sys +import os +import json +import tempfile +import base64 +import uuid +import re +import urllib.request +from pathlib import Path +from typing import Optional, List + +os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" + +try: + from faster_whisper import WhisperModel + import opencc + import numpy as np +except ImportError as e: + print(json.dumps({"error": f"Missing dependency: {e}"}), file=sys.stderr) + sys.exit(1) + +# Try to import ONNX Runtime for VAD +try: + import onnxruntime as ort + ONNX_AVAILABLE = True +except ImportError: + ONNX_AVAILABLE = False + + +class ChinesePunctuator: + """Rule-based Chinese punctuation processor.""" + + QUESTION_PATTERNS = [ + r'ๅ—Ž$', r'ๅ‘ข$', r'ไป€้บผ$', r'ๆ€Ž้บผ$', r'็‚บไป€้บผ$', r'ๅ“ช่ฃก$', r'ๅ“ชๅ€‹$', + r'่ชฐ$', r'ๅนพ$', r'ๅคšๅฐ‘$', r'ๆ˜ฏๅฆ$', r'่ƒฝๅฆ$', r'ๅฏๅฆ$', r'ๆœ‰ๆฒ’ๆœ‰$', + r'ๆ˜ฏไธๆ˜ฏ$', r'ๆœƒไธๆœƒ$', r'่ƒฝไธ่ƒฝ$', r'ๅฏไธๅฏไปฅ$', r'ๅฅฝไธๅฅฝ$', r'ๅฐไธๅฐ$' + ] + + def __init__(self): + self.question_regex = re.compile('|'.join(self.QUESTION_PATTERNS)) + + def add_punctuation(self, text: str, word_timestamps: Optional[List] = None) -> str: + """Add punctuation to transcribed text.""" + if not text: + return text + + text = text.strip() + + # Already has ending punctuation + if text and text[-1] in 'ใ€‚๏ผŸ๏ผ๏ผŒ๏ผ›๏ผš': + return text + + # Check for question patterns + if self.question_regex.search(text): + return text + '๏ผŸ' + + # Default to period for statements + return text + 'ใ€‚' + + def process_segments(self, segments: List[dict]) -> str: + """Process multiple segments with timestamps to add punctuation.""" + result_parts = [] + + for i, seg in enumerate(segments): + text = seg.get('text', '').strip() + if not text: + continue + + # Check for long pause before next segment (comma opportunity) + if i < len(segments) - 1: + next_seg = segments[i + 1] + gap = next_seg.get('start', 0) - seg.get('end', 0) + if gap > 0.5 and not text[-1] in 'ใ€‚๏ผŸ๏ผ๏ผŒ๏ผ›๏ผš': + # Long pause, add comma if not end of sentence + if not self.question_regex.search(text): + text = text + '๏ผŒ' + + result_parts.append(text) + + # Join and add final punctuation + result = ''.join(result_parts) + return self.add_punctuation(result) + + +class SileroVAD: + """Silero VAD using ONNX Runtime (lightweight alternative to PyTorch).""" + + MODEL_URL = "https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx" + + def __init__(self, model_path: Optional[str] = None, threshold: float = 0.5): + self.threshold = threshold + self.session = None + self._h = np.zeros((2, 1, 64), dtype=np.float32) + self._c = np.zeros((2, 1, 64), dtype=np.float32) + self.sample_rate = 16000 + + if not ONNX_AVAILABLE: + print(json.dumps({"warning": "onnxruntime not available, VAD disabled"}), file=sys.stderr) + return + + # Determine model path + if model_path is None: + cache_dir = Path.home() / ".cache" / "silero-vad" + cache_dir.mkdir(parents=True, exist_ok=True) + model_path = cache_dir / "silero_vad.onnx" + + # Download if not exists + if not Path(model_path).exists(): + print(json.dumps({"status": "downloading_vad_model"}), file=sys.stderr) + try: + urllib.request.urlretrieve(self.MODEL_URL, model_path) + print(json.dumps({"status": "vad_model_downloaded"}), file=sys.stderr) + except Exception as e: + print(json.dumps({"warning": f"VAD model download failed: {e}"}), file=sys.stderr) + return + + # Load ONNX model + try: + self.session = ort.InferenceSession( + str(model_path), + providers=['CPUExecutionProvider'] + ) + print(json.dumps({"status": "vad_loaded"}), file=sys.stderr) + except Exception as e: + print(json.dumps({"warning": f"VAD load failed: {e}"}), file=sys.stderr) + + def reset_states(self): + """Reset hidden states.""" + self._h = np.zeros((2, 1, 64), dtype=np.float32) + self._c = np.zeros((2, 1, 64), dtype=np.float32) + + def __call__(self, audio: np.ndarray) -> float: + """Run VAD on audio chunk, return speech probability.""" + if self.session is None: + return 0.5 # Neutral if VAD not available + + # Ensure correct shape (batch, samples) + if audio.ndim == 1: + audio = audio[np.newaxis, :] + + # Run inference + ort_inputs = { + 'input': audio.astype(np.float32), + 'sr': np.array([self.sample_rate], dtype=np.int64), + 'h': self._h, + 'c': self._c + } + + output, self._h, self._c = self.session.run(None, ort_inputs) + return float(output[0][0]) + + +class VADProcessor: + """Voice Activity Detection processor.""" + + def __init__(self, sample_rate: int = 16000, threshold: float = 0.5, vad_model: Optional[SileroVAD] = None): + self.sample_rate = sample_rate + self.threshold = threshold + # Reuse pre-loaded VAD model if provided + self.vad = vad_model if vad_model else (SileroVAD(threshold=threshold) if ONNX_AVAILABLE else None) + self.reset() + + def reset(self): + """Reset VAD state.""" + self.audio_buffer = np.array([], dtype=np.float32) + self.speech_buffer = np.array([], dtype=np.float32) + self.speech_started = False + self.silence_samples = 0 + self.speech_samples = 0 + if self.vad: + self.vad.reset_states() + + def process_chunk(self, audio_chunk: np.ndarray) -> Optional[np.ndarray]: + """ + Process audio chunk and return speech segment if speech end detected. + + Returns: + Speech audio if end detected, None otherwise + """ + self.audio_buffer = np.concatenate([self.audio_buffer, audio_chunk]) + + # Fallback: time-based segmentation if no VAD + if self.vad is None or self.vad.session is None: + # Every 5 seconds, return the buffer + if len(self.audio_buffer) >= self.sample_rate * 5: + result = self.audio_buffer.copy() + self.audio_buffer = np.array([], dtype=np.float32) + return result + return None + + # Process in 512-sample windows (32ms at 16kHz) + window_size = 512 + silence_threshold_samples = int(0.5 * self.sample_rate) # 500ms + max_speech_samples = int(15 * self.sample_rate) # 15s max + + while len(self.audio_buffer) >= window_size: + window = self.audio_buffer[:window_size] + self.audio_buffer = self.audio_buffer[window_size:] + + # Run VAD + speech_prob = self.vad(window) + + if speech_prob >= self.threshold: + if not self.speech_started: + self.speech_started = True + self.speech_buffer = np.array([], dtype=np.float32) + self.speech_buffer = np.concatenate([self.speech_buffer, window]) + self.silence_samples = 0 + self.speech_samples += window_size + else: + if self.speech_started: + self.speech_buffer = np.concatenate([self.speech_buffer, window]) + self.silence_samples += window_size + + # Force segment if speech too long + if self.speech_samples >= max_speech_samples: + result = self.speech_buffer.copy() + self.speech_started = False + self.speech_buffer = np.array([], dtype=np.float32) + self.silence_samples = 0 + self.speech_samples = 0 + return result + + # Detect end of speech (500ms silence) + if self.speech_started and self.silence_samples >= silence_threshold_samples: + if len(self.speech_buffer) > self.sample_rate * 0.3: # At least 300ms + result = self.speech_buffer.copy() + self.speech_started = False + self.speech_buffer = np.array([], dtype=np.float32) + self.silence_samples = 0 + self.speech_samples = 0 + return result + + return None + + def flush(self) -> Optional[np.ndarray]: + """Flush remaining audio.""" + # Combine any remaining audio + remaining = np.concatenate([self.speech_buffer, self.audio_buffer]) + if len(remaining) > self.sample_rate * 0.5: # At least 500ms + self.reset() + return remaining + self.reset() + return None + + +class StreamingSession: + """Manages a streaming transcription session.""" + + def __init__(self, transcriber: 'Transcriber', vad_model: Optional[SileroVAD] = None): + self.session_id = str(uuid.uuid4()) + self.transcriber = transcriber + self.vad = VADProcessor(vad_model=vad_model) + self.segment_id = 0 + self.active = True + + def process_chunk(self, audio_data: str) -> Optional[dict]: + """Process base64-encoded audio chunk.""" + try: + # Decode base64 to raw PCM (16-bit, 16kHz, mono) + pcm_data = base64.b64decode(audio_data) + audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0 + + # Run VAD + speech_segment = self.vad.process_chunk(audio) + + if speech_segment is not None and len(speech_segment) > 0: + return self._transcribe_segment(speech_segment) + + return None + + except Exception as e: + return {"error": f"Chunk processing error: {e}"} + + def _transcribe_segment(self, audio: np.ndarray) -> dict: + """Transcribe a speech segment.""" + self.segment_id += 1 + + # Save to temp file for Whisper + temp_file = tempfile.NamedTemporaryFile(suffix='.wav', delete=False) + try: + import wave + with wave.open(temp_file.name, 'wb') as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(16000) + wf.writeframes((audio * 32768).astype(np.int16).tobytes()) + + # Transcribe + text = self.transcriber.transcribe_file(temp_file.name, add_punctuation=True) + + return { + "segment_id": self.segment_id, + "text": text, + "is_final": True, + "duration": len(audio) / 16000 + } + finally: + os.unlink(temp_file.name) + + def stop(self) -> dict: + """Stop the session and flush remaining audio.""" + self.active = False + results = [] + + # Flush VAD buffer + remaining = self.vad.flush() + if remaining is not None and len(remaining) > 0: + result = self._transcribe_segment(remaining) + if result and not result.get('error'): + results.append(result) + + return { + "status": "stream_stopped", + "session_id": self.session_id, + "total_segments": self.segment_id, + "final_segments": results + } + + +class Transcriber: + """Main transcription engine.""" + + def __init__(self, model_size: str = "medium", device: str = "cpu", compute_type: str = "int8"): + self.model = None + self.converter = None + self.punctuator = ChinesePunctuator() + self.streaming_session: Optional[StreamingSession] = None + self.vad_model: Optional[SileroVAD] = None + + try: + print(json.dumps({"status": "loading_model", "model": model_size}), file=sys.stderr) + self.model = WhisperModel(model_size, device=device, compute_type=compute_type) + self.converter = opencc.OpenCC("s2twp") + print(json.dumps({"status": "model_loaded"}), file=sys.stderr) + + # Pre-load VAD model at startup (not when streaming starts) + if ONNX_AVAILABLE: + self.vad_model = SileroVAD() + + except Exception as e: + print(json.dumps({"error": f"Failed to load model: {e}"}), file=sys.stderr) + raise + + def transcribe_file(self, audio_path: str, add_punctuation: bool = False) -> str: + """Transcribe an audio file to text.""" + if not self.model: + return "" + + if not os.path.exists(audio_path): + print(json.dumps({"error": f"File not found: {audio_path}"}), file=sys.stderr) + return "" + + try: + segments, info = self.model.transcribe( + audio_path, + language="zh", # Use "nan" for Taiwanese/Hokkien, "zh" for Mandarin + beam_size=5, + vad_filter=True, + word_timestamps=add_punctuation, + # Anti-hallucination settings + condition_on_previous_text=False, # Prevents hallucination propagation + no_speech_threshold=0.6, # Higher = stricter silence detection + compression_ratio_threshold=2.4, # Filter repetitive/hallucinated text + log_prob_threshold=-1.0, # Filter low-confidence output + temperature=0.0, # Deterministic output (no sampling) + ) + + if add_punctuation: + # Collect segments with timestamps for punctuation + seg_list = [] + for segment in segments: + seg_list.append({ + 'text': segment.text, + 'start': segment.start, + 'end': segment.end + }) + text = self.punctuator.process_segments(seg_list) + else: + text = "" + for segment in segments: + text += segment.text + + # Convert to Traditional Chinese + if text and self.converter: + text = self.converter.convert(text) + + return text.strip() + + except Exception as e: + print(json.dumps({"error": f"Transcription error: {e}"}), file=sys.stderr) + return "" + + def handle_command(self, cmd: dict) -> Optional[dict]: + """Handle a JSON command.""" + action = cmd.get("action") + + if action == "transcribe": + # File-based transcription (legacy) + audio_path = cmd.get("file") + if audio_path: + text = self.transcribe_file(audio_path, add_punctuation=True) + return {"result": text, "file": audio_path} + return {"error": "No file specified"} + + elif action == "start_stream": + # Start streaming session + if self.streaming_session and self.streaming_session.active: + return {"error": "Stream already active"} + # Pass pre-loaded VAD model to avoid download delay + self.streaming_session = StreamingSession(self, vad_model=self.vad_model) + return { + "status": "streaming", + "session_id": self.streaming_session.session_id + } + + elif action == "audio_chunk": + # Process audio chunk + if not self.streaming_session or not self.streaming_session.active: + return {"error": "No active stream"} + data = cmd.get("data") + if not data: + return {"error": "No audio data"} + result = self.streaming_session.process_chunk(data) + return result # May be None if no segment ready + + elif action == "stop_stream": + # Stop streaming session + if not self.streaming_session: + return {"error": "No active stream"} + result = self.streaming_session.stop() + self.streaming_session = None + return result + + elif action == "ping": + return {"status": "pong"} + + elif action == "quit": + return {"status": "exiting"} + + else: + return {"error": f"Unknown action: {action}"} + + def run_server(self): + """Run in server mode, reading JSON commands from stdin.""" + print(json.dumps({"status": "ready"})) + sys.stdout.flush() + + for line in sys.stdin: + line = line.strip() + if not line: + continue + + try: + cmd = json.loads(line) + result = self.handle_command(cmd) + + if result: + print(json.dumps(result)) + sys.stdout.flush() + + if cmd.get("action") == "quit": + break + + except json.JSONDecodeError as e: + print(json.dumps({"error": f"Invalid JSON: {e}"})) + sys.stdout.flush() + + +def main(): + model_size = os.environ.get("WHISPER_MODEL", "small") + device = os.environ.get("WHISPER_DEVICE", "cpu") + compute_type = os.environ.get("WHISPER_COMPUTE", "int8") + + try: + transcriber = Transcriber(model_size, device, compute_type) + + if len(sys.argv) > 1: + if sys.argv[1] == "--server": + transcriber.run_server() + else: + # File mode + text = transcriber.transcribe_file(sys.argv[1], add_punctuation=True) + print(text) + else: + # Default to server mode + transcriber.run_server() + + except Exception as e: + print(json.dumps({"error": str(e)}), file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()