From 8b6184ecc5fcb09b7d485c26b30be23b53c4a3f0 Mon Sep 17 00:00:00 2001 From: egg Date: Wed, 10 Dec 2025 20:17:44 +0800 Subject: [PATCH] feat: Meeting Assistant MVP - Complete implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/commands/openspec/apply.md | 23 + .claude/commands/openspec/archive.md | 27 + .claude/commands/openspec/proposal.md | 28 + .gitignore | 49 + AGENTS.md | 18 + CLAUDE.md | 18 + DEPLOYMENT.md | 177 + PRD.md | 61 + SDD.md | 143 + TDD.md | 46 + backend/.env.example | 15 + backend/app/__init__.py | 1 + backend/app/config.py | 24 + backend/app/database.py | 96 + backend/app/main.py | 44 + backend/app/models/__init__.py | 37 + backend/app/models/schemas.py | 128 + backend/app/routers/__init__.py | 1 + backend/app/routers/ai.py | 102 + backend/app/routers/auth.py | 109 + backend/app/routers/export.py | 177 + backend/app/routers/meetings.py | 372 ++ backend/pytest.ini | 3 + backend/requirements.txt | 10 + backend/tests/__init__.py | 1 + backend/tests/conftest.py | 48 + backend/tests/test_ai.py | 191 + backend/tests/test_auth.py | 138 + backend/tests/test_database.py | 95 + client/package-lock.json | 4118 +++++++++++++++++ client/package.json | 47 + client/src/main.js | 278 ++ client/src/pages/login.html | 58 + client/src/pages/meeting-detail.html | 685 +++ client/src/pages/meetings.html | 201 + client/src/preload.js | 32 + client/src/services/api.js | 149 + client/src/styles/main.css | 462 ++ openspec/AGENTS.md | 456 ++ .../design.md | 132 + .../proposal.md | 25 + .../specs/ai-summarization/spec.md | 45 + .../specs/authentication/spec.md | 42 + .../specs/excel-export/spec.md | 45 + .../specs/meeting-management/spec.md | 71 + .../specs/middleware/spec.md | 41 + .../specs/transcription/spec.md | 41 + .../tasks.md | 67 + .../design.md | 117 + .../proposal.md | 24 + .../specs/frontend-transcript/spec.md | 58 + .../specs/transcription/spec.md | 46 + .../tasks.md | 53 + openspec/project.md | 56 + openspec/specs/ai-summarization/spec.md | 49 + openspec/specs/authentication/spec.md | 46 + openspec/specs/excel-export/spec.md | 49 + openspec/specs/frontend-transcript/spec.md | 62 + openspec/specs/meeting-management/spec.md | 75 + openspec/specs/middleware/spec.md | 45 + openspec/specs/transcription/spec.md | 90 + sidecar/build.py | 45 + sidecar/requirements-dev.txt | 3 + sidecar/requirements.txt | 5 + sidecar/transcriber.py | 510 ++ 65 files changed, 10510 insertions(+) create mode 100644 .claude/commands/openspec/apply.md create mode 100644 .claude/commands/openspec/archive.md create mode 100644 .claude/commands/openspec/proposal.md create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 DEPLOYMENT.md create mode 100644 PRD.md create mode 100644 SDD.md create mode 100644 TDD.md create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/schemas.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/ai.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/export.py create mode 100644 backend/app/routers/meetings.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_ai.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_database.py create mode 100644 client/package-lock.json create mode 100644 client/package.json create mode 100644 client/src/main.js create mode 100644 client/src/pages/login.html create mode 100644 client/src/pages/meeting-detail.html create mode 100644 client/src/pages/meetings.html create mode 100644 client/src/preload.js create mode 100644 client/src/services/api.js create mode 100644 client/src/styles/main.css create mode 100644 openspec/AGENTS.md create mode 100644 openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/design.md create mode 100644 openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/proposal.md create mode 100644 openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/ai-summarization/spec.md create mode 100644 openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/authentication/spec.md create mode 100644 openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/excel-export/spec.md create mode 100644 openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/meeting-management/spec.md create mode 100644 openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/middleware/spec.md create mode 100644 openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/specs/transcription/spec.md create mode 100644 openspec/changes/archive/2025-12-10-add-meeting-assistant-mvp/tasks.md create mode 100644 openspec/changes/archive/2025-12-10-add-realtime-transcription/design.md create mode 100644 openspec/changes/archive/2025-12-10-add-realtime-transcription/proposal.md create mode 100644 openspec/changes/archive/2025-12-10-add-realtime-transcription/specs/frontend-transcript/spec.md create mode 100644 openspec/changes/archive/2025-12-10-add-realtime-transcription/specs/transcription/spec.md create mode 100644 openspec/changes/archive/2025-12-10-add-realtime-transcription/tasks.md create mode 100644 openspec/project.md create mode 100644 openspec/specs/ai-summarization/spec.md create mode 100644 openspec/specs/authentication/spec.md create mode 100644 openspec/specs/excel-export/spec.md create mode 100644 openspec/specs/frontend-transcript/spec.md create mode 100644 openspec/specs/meeting-management/spec.md create mode 100644 openspec/specs/middleware/spec.md create mode 100644 openspec/specs/transcription/spec.md create mode 100644 sidecar/build.py create mode 100644 sidecar/requirements-dev.txt create mode 100644 sidecar/requirements.txt create mode 100644 sidecar/transcriber.py 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()