From d4928ffa60f66334fce16c6e04a317b6c0454210 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 8 Apr 2026 09:53:54 +0900 Subject: [PATCH] =?UTF-8?q?Phase=2014.5=20=EA=B5=AC=ED=98=84:=20=ED=9A=8C?= =?UTF-8?q?=EC=9D=98=20=EB=AA=A8=EB=93=9C=20=EA=B3=A0=EB=8F=84=ED=99=94=20?= =?UTF-8?q?=E2=80=94=20=EB=8B=A4=EC=A4=91=20=EB=AC=B8=EC=84=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1/=ED=8E=B8=EC=A7=91/=EB=82=B4=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAUD 수준의 기능 확장: - 전사 편집: 인라인 편집 + 원본 보존 + 수정됨 표시(점선 밑줄) - 다중 문서 생성: 회의록/보고서/아이디어노트/커스텀 템플릿 - MD 에디터: 렌더링/편집 토글 (react-markdown + remark-gfm) - 상세 페이지 리디자인: 풀스크린 탭 전환 구조 - 내보내기: MD, PDF, TXT, DOCX 4종 (docx 패키지) - 커스텀 프롬프트 템플릿 저장/재사용 (electron-store) - SSOT: 마크다운 파싱 유틸 추출, 후처리에서 자동 문서 생성 제거 DB: meeting_documents 테이블 + edited_transcript 컬럼 IPC: 8+4 채널, 서비스 2개(MeetingModeService 확장 + MeetingDocTemplateService 신규) UI: 8개 신규 컴포넌트 (meeting/ 디렉토리), 12개 locale i18n --- docs/phases/phase-14.5-meeting-enhancement.md | 514 ++++++ package-lock.json | 1598 ++++++++++++++++- package.json | 3 + src/main/bootstrap.ts | 3 + src/main/db/index.ts | 28 + src/main/db/schema.ts | 26 + src/main/ipc/index.ts | 2 + src/main/ipc/meeting-doc-template-handlers.ts | 63 + src/main/ipc/meeting-mode-handlers.ts | 89 +- .../services/MeetingDocTemplateService.ts | 245 +++ src/main/services/MeetingModeService.ts | 513 +++--- src/main/utils/markdown-to-docx.ts | 31 + src/main/utils/meeting-markdown.ts | 116 ++ src/preload/index.ts | 45 + .../components/meeting/AddDocumentDialog.tsx | 190 ++ .../components/meeting/DocumentTab.tsx | 89 + .../components/meeting/EditableSegment.tsx | 143 ++ .../components/meeting/ExportMenu.tsx | 96 + .../components/meeting/MarkdownEditor.tsx | 83 + .../components/meeting/MarkdownRenderer.tsx | 198 ++ .../components/meeting/MeetingDetailTabs.tsx | 336 ++++ .../components/meeting/TranscriptTab.tsx | 173 ++ src/renderer/i18n/de.json | 26 +- src/renderer/i18n/en.json | 26 +- src/renderer/i18n/es.json | 26 +- src/renderer/i18n/fr.json | 26 +- src/renderer/i18n/ja.json | 26 +- src/renderer/i18n/ko.json | 26 +- src/renderer/i18n/pt.json | 26 +- src/renderer/i18n/ru.json | 26 +- src/renderer/i18n/th.json | 26 +- src/renderer/i18n/vi.json | 26 +- src/renderer/i18n/zh-TW.json | 26 +- src/renderer/i18n/zh.json | 26 +- src/renderer/pages/MeetingModePage.tsx | 213 +-- src/shared/errors.ts | 7 + src/shared/ipc-channels.ts | 18 + src/shared/types.ts | 95 + 38 files changed, 4791 insertions(+), 438 deletions(-) create mode 100644 docs/phases/phase-14.5-meeting-enhancement.md create mode 100644 src/main/ipc/meeting-doc-template-handlers.ts create mode 100644 src/main/services/MeetingDocTemplateService.ts create mode 100644 src/main/utils/markdown-to-docx.ts create mode 100644 src/main/utils/meeting-markdown.ts create mode 100644 src/renderer/components/meeting/AddDocumentDialog.tsx create mode 100644 src/renderer/components/meeting/DocumentTab.tsx create mode 100644 src/renderer/components/meeting/EditableSegment.tsx create mode 100644 src/renderer/components/meeting/ExportMenu.tsx create mode 100644 src/renderer/components/meeting/MarkdownEditor.tsx create mode 100644 src/renderer/components/meeting/MarkdownRenderer.tsx create mode 100644 src/renderer/components/meeting/MeetingDetailTabs.tsx create mode 100644 src/renderer/components/meeting/TranscriptTab.tsx diff --git a/docs/phases/phase-14.5-meeting-enhancement.md b/docs/phases/phase-14.5-meeting-enhancement.md new file mode 100644 index 0000000..b544d31 --- /dev/null +++ b/docs/phases/phase-14.5-meeting-enhancement.md @@ -0,0 +1,514 @@ +# Phase 14.5: 회의 모드 고도화 — 문서 생성/편집/내보내기 + +> PLAUD/Otter.ai 수준의 다중 문서 생성 + 전사 편집 + MD 에디터 + 다운로드 확장 +> 기존 Phase 14 인프라(녹음+전사+메모+LLM) 위에 확장 + +--- + +## 1. 개요 + +### 1.1 사용자 플로우 + +``` +[회의 종료 후 상세 페이지] + │ + ├── [Transcript 탭] ← 전사 원문 편집 + 원본/수정본 토글 + │ ├── 세그먼트 클릭 → 인라인 편집 (점선 밑줄 + hover "수정됨") + │ ├── 원본 보기 토글 (rawTranscript ↔ editedTranscript) + │ └── [TXT 다운로드] 버튼 + │ + ├── [+ 버튼] → 문서 생성 다이얼로그 + │ ├── 템플릿 선택 (회의록 / 보고서 / 아이디어 노트 / 커스텀) + │ ├── 커스텀: 이름 + 프롬프트 입력 (저장/재사용 가능) + │ └── [생성] → LLM 호출 → 새 탭 추가 + │ + └── [문서 탭 (동적)] ← 회의록, 보고서 등 + ├── 미리보기 모드: react-markdown 렌더링 + ├── 편집 모드: 마크다운 원본 textarea + ├── 토글 버튼 (미리보기 ↔ 편집) + ├── debounce 자동 저장 (500ms) + └── [내보내기 ▼] 드롭다운 (MD / PDF / TXT / DOCX) +``` + +### 1.2 핵심 차별점 +- **다중 문서**: 하나의 전사에서 여러 양식의 문서 생성 (PLAUD Multidimensional Summary) +- **전사 편집**: 녹음 중 + 종료 후 인라인 편집, 원본 보존, 수정 표시 +- **커스텀 프롬프트**: 사용자 정의 AI 프롬프트 저장/재사용 +- **4종 내보내기**: MD, PDF, TXT, DOCX +- **MD 에디터**: 렌더링/편집 토글 + +--- + +## 2. DB 스키마 + +### 2.1 `meeting_sessions` 테이블 확장 + +```sql +-- 마이그레이션: 전사 편집 원본 보존용 컬럼 추가 +ALTER TABLE meeting_sessions ADD COLUMN edited_transcript TEXT; +``` + +### 2.2 `meeting_documents` 테이블 (신규) + +```sql +CREATE TABLE IF NOT EXISTS meeting_documents ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + template_type TEXT NOT NULL, -- 'minutes' | 'report' | 'idea-note' | 'custom' + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + prompt_used TEXT, -- 커스텀 프롬프트 기록용 + llm_model TEXT, + llm_latency_ms INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id); +``` + +### 2.3 drizzle-orm 스키마 (`src/main/db/schema.ts`) + +```typescript +// meetingSessions에 추가 +editedTranscript: text('edited_transcript'), + +// 신규 테이블 +export const meetingDocuments = sqliteTable( + 'meeting_documents', + { + id: text('id').primaryKey(), + sessionId: text('session_id').notNull(), + templateType: text('template_type', { + enum: ['minutes', 'report', 'idea-note', 'custom'], + }).notNull(), + title: text('title').notNull(), + content: text('content').notNull().default(''), + promptUsed: text('prompt_used'), + llmModel: text('llm_model'), + llmLatencyMs: integer('llm_latency_ms'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + }, + (table) => [ + index('idx_meeting_documents_session_id').on(table.sessionId), + ] +) +``` + +--- + +## 3. 공유 타입 (`src/shared/types.ts`) + +```typescript +// ============================================================ +// Phase 14.5: Meeting Document Types +// ============================================================ + +export type MeetingDocTemplateType = 'minutes' | 'report' | 'idea-note' | 'custom' + +export interface MeetingDocTemplate { + id: string + name: string + description: string + templateType: MeetingDocTemplateType + systemPrompt: string + isBuiltin: boolean + createdAt: number + updatedAt: number +} + +export interface MeetingDocument { + id: string + sessionId: string + templateType: MeetingDocTemplateType + title: string + content: string + promptUsed: string | null + llmModel: string | null + llmLatencyMs: number | null + createdAt: number + updatedAt: number +} + +export interface MeetingGenerateDocParams { + sessionId: string + templateId: string + customPrompt?: string + customTitle?: string +} + +export interface MeetingGenerateDocResult { + document: MeetingDocument +} + +export interface MeetingUpdateDocParams { + documentId: string + content: string +} + +export interface MeetingDeleteDocParams { + documentId: string +} + +export interface MeetingGetDocsParams { + sessionId: string +} + +export interface MeetingUpdateTranscriptParams { + sessionId: string + editedTranscript: string +} + +export type MeetingExportFormat = 'md' | 'pdf' | 'txt' | 'docx' + +export interface MeetingExportDocParams { + documentId: string + format: MeetingExportFormat +} + +export interface MeetingExportTranscriptParams { + sessionId: string +} + +export interface MeetingDocGeneratingProgress { + sessionId: string + templateId: string + percent: number +} + +export interface CreateMeetingDocTemplateParams { + name: string + description: string + systemPrompt: string +} + +export interface UpdateMeetingDocTemplateParams { + id: string + name?: string + description?: string + systemPrompt?: string +} + +export interface DeleteMeetingDocTemplateParams { + id: string +} +``` + +**MeetingSessionDetail 확장:** +```typescript +export interface MeetingSessionDetail extends MeetingSessionSummary { + rawTranscript: string | null + editedTranscript: string | null // 추가 + minutesMarkdown: string | null + minutes: MeetingMinutes | null + memos: MeetingMemo[] + documents: MeetingDocument[] // 추가 + sttModel: string | null + llmModel: string | null + errorMessage: string | null +} +``` + +--- + +## 4. 에러 코드 (`src/shared/errors.ts`) + +```typescript +// === Phase 14.5: Meeting Enhancement (886-892) === +MeetingDocumentNotFound = 886, +MeetingDocGenerationFailed = 887, +MeetingDocExportFailed = 888, +MeetingTranscriptUpdateFailed = 889, +MeetingDocTemplateNotFound = 890, +MeetingDocTemplateBuiltinDelete = 891, +MeetingDocxExportFailed = 892, +``` + +--- + +## 5. IPC 채널 (`src/shared/ipc-channels.ts`) + +```typescript +// MEETING_MODE 블록에 추가 +UPDATE_TRANSCRIPT: 'meetingMode:updateTranscript', +GENERATE_DOCUMENT: 'meetingMode:generateDocument', +GET_DOCUMENTS: 'meetingMode:getDocuments', +UPDATE_DOCUMENT: 'meetingMode:updateDocument', +DELETE_DOCUMENT: 'meetingMode:deleteDocument', +EXPORT_DOCUMENT: 'meetingMode:exportDocument', +EXPORT_TRANSCRIPT: 'meetingMode:exportTranscript', +DOC_GENERATING_PROGRESS: 'meetingMode:docGeneratingProgress', + +// 신규 그룹 +MEETING_DOC_TEMPLATE: { + GET_ALL: 'meetingDocTemplate:getAll', + CREATE: 'meetingDocTemplate:create', + UPDATE: 'meetingDocTemplate:update', + DELETE: 'meetingDocTemplate:delete', +}, +``` + +--- + +## 6. 서비스 명세 + +### 6.1 MeetingDocTemplateService (신규) + +**파일**: `src/main/services/MeetingDocTemplateService.ts` +**패턴**: electron-store 전용 인스턴스 (DictationTemplateService 패턴) + +빌트인 3종: + +```typescript +const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [ + { + id: 'builtin-minutes', + name: '회의록', + templateType: 'minutes', + systemPrompt: `전사록을 분석하여 구조화된 회의록을 작성하세요. +## 요약 (3-5줄) +## 핵심 결정사항 (- 항목) +## 할 일 목록 (- [ ] 담당자: 할일) +## 타임라인 (| 시간 | 내용 |)`, + isBuiltin: true, + }, + { + id: 'builtin-report', + name: '보고서', + templateType: 'report', + systemPrompt: `전사록을 분석하여 제출용 보고서를 작성하세요. +## 개요 (배경, 목적) +## 핵심 내용 (주요 논의 사항) +## 결론 및 제안 +## 첨부 참고사항`, + isBuiltin: true, + }, + { + id: 'builtin-idea-note', + name: '아이디어 노트', + templateType: 'idea-note', + systemPrompt: `전사록에서 아이디어와 인사이트를 추출하세요. +## 핵심 아이디어 (번호 매기기) +## 각 아이디어의 장단점 +## 우선순위 제안 +## 다음 단계`, + isBuiltin: true, + }, +] +``` + +메서드: `getAll()`, `getById()`, `create()`, `update()`, `delete()` + +### 6.2 MeetingModeService 확장 + +**새 메서드:** + +| 메서드 | 설명 | +|--------|------| +| `updateTranscript(sessionId, editedTranscript)` | 수정 전사 저장 | +| `generateDocument(params)` | 전사+템플릿→LLM→meeting_documents 저장 | +| `getDocuments(sessionId)` | 세션의 문서 목록 | +| `updateDocument(documentId, content)` | MD 편집 저장 | +| `deleteDocument(documentId)` | 문서 삭제 | +| `exportDocument(documentId, format)` | MD/PDF/TXT/DOCX 내보내기 | +| `exportTranscript(sessionId)` | 전사 TXT 다운로드 | + +**후처리 변경:** `_runPostProcessing()`에서 자동 회의록 생성 제거. 전사 합산 + DB 저장(rawTranscript, status=completed)만 수행. 사용자가 [+]로 직접 문서 생성. + +**_loadSessionDetail 확장:** editedTranscript + documents[] 포함 + +### 6.3 유틸 추출 (SSOT) + +**`src/main/utils/meeting-markdown.ts`**: +- `parseMinutes(md)` — MeetingModeService._parseMinutes() 추출 +- `buildExportMarkdown(session)` — _buildExportMarkdown() 추출 +- `markdownToSimpleHtml(md)` — _markdownToSimpleHtml() 추출 + +**`src/main/utils/markdown-to-docx.ts`**: +- `markdownToDocx(md, title): Promise` — docx 패키지 + +--- + +## 7. Preload API (`src/preload/index.ts`) + +```typescript +// meetingMode 확장 +updateTranscript: (params) => invoke(ch.UPDATE_TRANSCRIPT, params), +generateDocument: (params) => invoke(ch.GENERATE_DOCUMENT, params), +getDocuments: (params) => invoke(ch.GET_DOCUMENTS, params), +updateDocument: (params) => invoke(ch.UPDATE_DOCUMENT, params), +deleteDocument: (params) => invoke(ch.DELETE_DOCUMENT, params), +exportDocument: (params) => invoke(ch.EXPORT_DOCUMENT, params), +exportTranscript: (params) => invoke(ch.EXPORT_TRANSCRIPT, params), +onDocGeneratingProgress: (cb) => on(ch.DOC_GENERATING_PROGRESS, cb), + +// 신규 그룹 +meetingDocTemplate: { + getAll: () => invoke(ch.GET_ALL), + create: (params) => invoke(ch.CREATE, params), + update: (params) => invoke(ch.UPDATE, params), + delete: (params) => invoke(ch.DELETE, params), +}, +``` + +--- + +## 8. UI 컴포넌트 + +### 8.1 컴포넌트 디렉토리: `src/renderer/components/meeting/` + +| 컴포넌트 | 설명 | +|----------|------| +| `EditableSegment.tsx` | 전사 세그먼트 인라인 편집. 클릭→TextField, 점선 밑줄, hover "수정됨" 툴팁 | +| `MarkdownRenderer.tsx` | react-markdown + remark-gfm 래퍼. d3roPalette 스타일 | +| `MarkdownEditor.tsx` | 미리보기/편집 토글. 편집 모드=textarea, debounce 자동저장(500ms) | +| `ExportMenu.tsx` | MD/PDF/TXT/DOCX 드롭다운 | +| `TranscriptTab.tsx` | 전사 편집 탭. 원본/수정본 토글, EditableSegment 목록, TXT 다운로드 | +| `DocumentTab.tsx` | 문서 탭. MarkdownEditor + ExportMenu + 삭제 | +| `AddDocumentDialog.tsx` | [+] 버튼 다이얼로그. 템플릿 선택(빌트인3+커스텀), 프롬프트 입력, 생성 | +| `MeetingDetailTabs.tsx` | 탭 바 + 콘텐츠 라우팅 컨테이너 | + +### 8.2 상세 뷰 레이아웃 + +``` +┌──────────────────────────────────────────────────┐ +│ [←] 프로젝트 킥오프 ✏️ [Export ▼] [🗑] │ +│ 2026-04-06 14:30 ~ 15:12 · 42분 │ +├──────────────────────────────────────────────────┤ +│ [📝 Transcript] [회의록] [보고서] [+ 추가] │ +├──────────────────────────────────────────────────┤ +│ │ +│ (선택된 탭의 풀스크린 콘텐츠) │ +│ │ +│ Transcript탭: EditableSegment 목록 │ +│ 문서탭: MarkdownEditor (렌더링↔편집 토글) │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +### 8.3 녹음 뷰 세그먼트 편집 + +``` +┌─ 실시간 전사 ScreenPanel ────────────────┐ +│ [00:15] 안녕하세요 오늘 회의를... │ ← 클릭 시 편집 가능 +│ ✏̣[̣0̣1̣:̣3̣0̣]̣ ̣예̣산̣ ̣관̣련̣인̣데̣요̣.̣.̣.̣ │ ← 수정됨: 점선 밑줄 +│ [02:00] 다음 안건은... │ +└──────────────────────────────────────────┘ +``` + +--- + +## 9. i18n 키 + +### ko.json 추가 +```json +"meeting.transcriptTab": "전사", +"meeting.addDocument": "문서 추가", +"meeting.selectTemplate": "템플릿 선택", +"meeting.generate": "생성", +"meeting.generating": "문서 생성 중...", +"meeting.editMode": "편집", +"meeting.previewMode": "미리보기", +"meeting.modified": "수정됨", +"meeting.originalText": "원본 보기", +"meeting.editedText": "수정본 보기", +"meeting.exportDocx": "DOCX 내보내기", +"meeting.exportTxt": "TXT 내보내기", +"meeting.exportFormat": "내보내기 형식", +"meeting.customPrompt": "커스텀 프롬프트", +"meeting.templateName": "템플릿 이름", +"meeting.templatePrompt": "AI 프롬프트", +"meeting.templateSave": "템플릿 저장", +"meeting.documentDeleted": "문서가 삭제되었습니다", +"meeting.autoSaved": "자동 저장됨", +"meeting.minutes": "회의록", +"meeting.report": "보고서", +"meeting.ideaNote": "아이디어 노트", +"meeting.custom": "커스텀" +``` + +--- + +## 10. 구현 순서 + +### Step A: 기반 인프라 (타입 + DB + IPC 채널 + 에러코드) +1. `src/shared/types.ts` — 새 타입 추가 + MeetingSessionDetail 확장 +2. `src/shared/ipc-channels.ts` — MEETING_MODE 확장 + MEETING_DOC_TEMPLATE 추가 +3. `src/shared/errors.ts` — 886-892 추가 +4. `src/main/db/schema.ts` — meetingDocuments + editedTranscript +5. `src/main/db/index.ts` — 마이그레이션 SQL + +### Step B: npm 패키지 설치 +6. `npm install react-markdown remark-gfm docx` + +### Step C: 유틸 추출 (SSOT) +7. `src/main/utils/meeting-markdown.ts` — 파싱/내보내기 로직 추출 +8. `src/main/utils/markdown-to-docx.ts` — DOCX 변환 + +### Step D: 서비스 계층 +9. `src/main/services/MeetingDocTemplateService.ts` — 신규 +10. `src/main/services/MeetingModeService.ts` — 확장 (7 메서드 + 후처리 변경 + _loadSessionDetail) + +### Step E: IPC + Preload +11. `src/main/ipc/meeting-doc-template-handlers.ts` — 신규 +12. `src/main/ipc/meeting-mode-handlers.ts` — 확장 +13. `src/main/ipc/index.ts` — import 추가 +14. `src/preload/index.ts` — API 확장 + +### Step F: UI 컴포넌트 +15-22. `src/renderer/components/meeting/` — 8개 컴포넌트 + +### Step G: 페이지 통합 +23. `src/renderer/pages/MeetingModePage.tsx` — 상세 뷰 리디자인 + 녹음 뷰 편집 + +### Step H: i18n +24. 12개 locale 키 추가 + +### Step I: 마무리 +25. typecheck + build + test +26. bootstrap 초기화 단계 추가 (MeetingDocTemplateService) + +--- + +## 11. TODO 항목 (향후 구현) + +- [ ] AI 채팅: 회의록 레퍼런스 기반 Q&A (여러 레퍼런스 선택 + 대화) +- [ ] 웹 공유: 만료 링크 공유 (웹 서비스 연동 대비 설계) +- [ ] 마인드맵: AI 시각적 마인드맵 생성 (JPEG/SVG 내보내기) +- [ ] Auto Polish: AI 전사 다듬기 (필러 제거, 가독성 향상) +- [ ] 화자 구분: 스피커 라벨링 + 이름 지정 + +--- + +## 파일 목록 + +### 신규 (12개) +| 파일 | 설명 | +|------|------| +| `src/main/services/MeetingDocTemplateService.ts` | 문서 템플릿 CRUD | +| `src/main/ipc/meeting-doc-template-handlers.ts` | 템플릿 IPC | +| `src/main/utils/meeting-markdown.ts` | MD 파싱 유틸 | +| `src/main/utils/markdown-to-docx.ts` | DOCX 변환 | +| `src/renderer/components/meeting/MeetingDetailTabs.tsx` | 탭 컨테이너 | +| `src/renderer/components/meeting/TranscriptTab.tsx` | 전사 편집 탭 | +| `src/renderer/components/meeting/DocumentTab.tsx` | 문서 뷰/편집 탭 | +| `src/renderer/components/meeting/AddDocumentDialog.tsx` | 문서 생성 다이얼로그 | +| `src/renderer/components/meeting/MarkdownRenderer.tsx` | MD 렌더러 래퍼 | +| `src/renderer/components/meeting/MarkdownEditor.tsx` | 렌더링/편집 토글 | +| `src/renderer/components/meeting/EditableSegment.tsx` | 인라인 편집 세그먼트 | +| `src/renderer/components/meeting/ExportMenu.tsx` | 내보내기 드롭다운 | + +### 수정 (10+12 locale) +| 파일 | 변경 | +|------|------| +| `src/shared/types.ts` | 새 타입 + MeetingSessionDetail 확장 | +| `src/shared/ipc-channels.ts` | 8+4 채널 | +| `src/shared/errors.ts` | 7개 에러 코드 | +| `src/main/db/schema.ts` | meetingDocuments + editedTranscript | +| `src/main/db/index.ts` | 마이그레이션 | +| `src/main/services/MeetingModeService.ts` | 7 메서드 + 후처리 변경 | +| `src/main/ipc/meeting-mode-handlers.ts` | 7 핸들러 | +| `src/main/ipc/index.ts` | import 추가 | +| `src/preload/index.ts` | API 확장 | +| `src/renderer/pages/MeetingModePage.tsx` | 상세 뷰 리디자인 | +| `src/renderer/i18n/*.json` | 12개 locale | diff --git a/package-lock.json b/package-lock.json index 5ba8188..6bb855c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nut-tree-fork/nut-js": "^4.2.6", "@rollup/rollup-win32-x64-msvc": "^4.60.1", "better-sqlite3": "^12.8.0", + "docx": "^9.6.1", "drizzle-orm": "^0.45.2", "electron-audio-loopback": "^1.0.6", "electron-log": "^5.2.0", @@ -29,6 +30,8 @@ "pdf-parse": "^2.4.5", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "uiohook-napi": "^1.5.5" }, "devDependencies": { @@ -3555,7 +3558,6 @@ "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "dev": true, "license": "MIT", "dependencies": { "@types/ms": "*" @@ -3565,9 +3567,17 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/fluent-ffmpeg": { "version": "2.1.28", "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz", @@ -3588,6 +3598,15 @@ "@types/node": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -3603,11 +3622,19 @@ "@types/node": "*" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "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": { @@ -3680,6 +3707,12 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -3935,7 +3968,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { @@ -4513,6 +4545,16 @@ "npm": ">=6" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -4958,6 +5000,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/centra": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/centra/-/centra-2.7.0.tgz", @@ -5001,6 +5053,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -5177,6 +5269,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -5249,9 +5351,7 @@ "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", - "optional": true + "license": "MIT" }, "node_modules/cosmiconfig": { "version": "7.1.0", @@ -5342,6 +5442,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -5463,6 +5576,15 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5479,6 +5601,19 @@ "license": "MIT", "optional": true }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -5642,6 +5777,38 @@ "node": ">=6.0.0" } }, + "node_modules/docx": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", + "integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -6539,6 +6706,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -6705,6 +6882,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -7425,6 +7608,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -7437,6 +7630,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -7485,6 +7718,16 @@ "dev": true, "license": "ISC" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -7616,6 +7859,12 @@ "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", "license": "MIT" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7666,6 +7915,12 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -7676,6 +7931,30 @@ "node": ">= 12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -7697,6 +7976,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -7751,6 +8040,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -7771,6 +8070,18 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -7805,6 +8116,12 @@ "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==", + "license": "MIT" + }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -7987,6 +8304,48 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/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==", + "license": "MIT", + "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/jszip/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==", + "license": "MIT" + }, + "node_modules/jszip/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==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8017,6 +8376,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8086,6 +8454,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -8157,6 +8535,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -8180,6 +8568,851 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -8255,6 +9488,12 @@ "dom-walk": "^0.1.0" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -8905,6 +10144,31 @@ "xml2js": "^0.5.0" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-headers": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", @@ -9318,6 +10582,12 @@ "node": ">= 0.6.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==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -9370,6 +10640,16 @@ "signal-exit": "^3.0.2" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -9474,6 +10754,33 @@ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -9575,6 +10882,72 @@ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", "license": "MIT" }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9898,6 +11271,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10089,6 +11468,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -10173,6 +11562,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -10254,6 +11657,24 @@ "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -10598,6 +12019,26 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -10703,6 +12144,25 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unique-filename": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", @@ -10729,6 +12189,74 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -10817,6 +12345,34 @@ "node": ">=0.6.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -11102,6 +12658,24 @@ "xtend": "^4.0.0" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xml-parse-from-string": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", @@ -11216,6 +12790,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 8ad7dd4..d65dc3f 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@nut-tree-fork/nut-js": "^4.2.6", "@rollup/rollup-win32-x64-msvc": "^4.60.1", "better-sqlite3": "^12.8.0", + "docx": "^9.6.1", "drizzle-orm": "^0.45.2", "electron-audio-loopback": "^1.0.6", "electron-log": "^5.2.0", @@ -65,6 +66,8 @@ "pdf-parse": "^2.4.5", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "uiohook-napi": "^1.5.5" } } diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts index 2760d09..0d9f4e7 100644 --- a/src/main/bootstrap.ts +++ b/src/main/bootstrap.ts @@ -299,6 +299,9 @@ async function initMeetingMode(): Promise { // MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성) const { getMeetingModeService } = await import('./services/MeetingModeService') getMeetingModeService() + // MeetingDocTemplateService 싱글톤 초기화 (빌트인 템플릿 보장) + const { getMeetingDocTemplateService } = await import('./services/MeetingDocTemplateService') + getMeetingDocTemplateService() } // ── HistoryPopup IPC 연동 ──────────────────────────── diff --git a/src/main/db/index.ts b/src/main/db/index.ts index 69279aa..ba3ef89 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -163,6 +163,34 @@ export function initDatabase(): BetterSQLite3Database { CREATE INDEX IF NOT EXISTS idx_meeting_memos_timestamp ON meeting_memos(timestamp_ms); `) + // Phase 14.5: meeting_documents 테이블 + edited_transcript 컬럼 + sqlite.exec(` + CREATE TABLE IF NOT EXISTS meeting_documents ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + template_type TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + prompt_used TEXT, + llm_model TEXT, + llm_latency_ms INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id); + `) + + // Phase 14.5: meeting_sessions에 edited_transcript 컬럼 마이그레이션 + try { + const msCols = sqlite.pragma('table_info(meeting_sessions)') as Array<{ name: string }> + if (!msCols.some((c) => c.name === 'edited_transcript')) { + sqlite.exec('ALTER TABLE meeting_sessions ADD COLUMN edited_transcript TEXT') + logger.info('Migrated: added edited_transcript column to meeting_sessions') + } + } catch (err) { + logger.warn('edited_transcript migration check failed:', err) + } + // Phase 12.2: history 테이블에 summary_text 컬럼 마이그레이션 try { const columns = sqlite.pragma('table_info(history)') as Array<{ name: string }> diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 58f2920..750d498 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -165,6 +165,7 @@ export const meetingSessions = sqliteTable( endedAt: integer('ended_at'), durationMs: integer('duration_ms'), rawTranscript: text('raw_transcript'), + editedTranscript: text('edited_transcript'), minutesMarkdown: text('minutes_markdown'), minutesJson: text('minutes_json'), sttModel: text('stt_model'), @@ -202,6 +203,31 @@ export type NewMeetingSessionRow = typeof meetingSessions.$inferInsert export type MeetingMemoRow = typeof meetingMemos.$inferSelect export type NewMeetingMemoRow = typeof meetingMemos.$inferInsert +// ── meetingDocuments (Phase 14.5) ─────────────────────── +export const meetingDocuments = sqliteTable( + 'meeting_documents', + { + id: text('id').primaryKey(), + sessionId: text('session_id').notNull(), + templateType: text('template_type', { + enum: ['minutes', 'report', 'idea-note', 'custom'], + }).notNull(), + title: text('title').notNull(), + content: text('content').notNull().default(''), + promptUsed: text('prompt_used'), + llmModel: text('llm_model'), + llmLatencyMs: integer('llm_latency_ms'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + }, + (table) => [ + index('idx_meeting_documents_session_id').on(table.sessionId), + ] +) + +export type MeetingDocumentRow = typeof meetingDocuments.$inferSelect +export type NewMeetingDocumentRow = typeof meetingDocuments.$inferInsert + // ── 타입 추출 ──────────────────────────────────────────── export type History = typeof history.$inferSelect export type NewHistory = typeof history.$inferInsert diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index bcdcc6a..ab73907 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -24,6 +24,7 @@ import { registerVoiceConversationHandlers } from './voice-conversation-handlers import { registerRAGHandlers } from './rag-handlers' import { registerVoiceActionHandlers } from './voice-action-handlers' import { registerMeetingModeHandlers } from './meeting-mode-handlers' +import { registerMeetingDocTemplateHandlers } from './meeting-doc-template-handlers' import { getLogger } from '../services/LoggerService' const logger = getLogger('ipc') @@ -53,5 +54,6 @@ export function registerAllIpcHandlers(): void { registerRAGHandlers() registerVoiceActionHandlers() registerMeetingModeHandlers() + registerMeetingDocTemplateHandlers() logger.info('All IPC handlers registered') } diff --git a/src/main/ipc/meeting-doc-template-handlers.ts b/src/main/ipc/meeting-doc-template-handlers.ts new file mode 100644 index 0000000..a6db9c3 --- /dev/null +++ b/src/main/ipc/meeting-doc-template-handlers.ts @@ -0,0 +1,63 @@ +// src/main/ipc/meeting-doc-template-handlers.ts +// Phase 14.5: Meeting Document Template IPC 핸들러 + +import { ipcMain } from 'electron' +import { IPC_CHANNELS } from '@shared/ipc-channels' +import { ErrorCode, ipcSuccess, ipcError } from '@shared/errors' +import { getMeetingDocTemplateService } from '../services/MeetingDocTemplateService' +import { getLogger } from '../services/LoggerService' +import type { + CreateMeetingDocTemplateParams, + UpdateMeetingDocTemplateParams, + DeleteMeetingDocTemplateParams, +} from '@shared/types' + +const logger = getLogger('meeting-doc-template-handlers') + +export function registerMeetingDocTemplateHandlers(): void { + const ch = IPC_CHANNELS.MEETING_DOC_TEMPLATE + + ipcMain.handle(ch.GET_ALL, () => { + try { + return ipcSuccess(getMeetingDocTemplateService().getAll()) + } catch (err) { + return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err)) + } + }) + + ipcMain.handle(ch.CREATE, (_event, params: CreateMeetingDocTemplateParams) => { + try { + const result = getMeetingDocTemplateService().create(params) + return ipcSuccess(result) + } catch (err) { + logger.error(`meetingDocTemplate create 실패: ${err instanceof Error ? err.message : String(err)}`) + return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err)) + } + }) + + ipcMain.handle(ch.UPDATE, (_event, params: UpdateMeetingDocTemplateParams) => { + try { + const result = getMeetingDocTemplateService().update(params) + return ipcSuccess(result) + } catch (err) { + return ipcError( + ErrorCode.MeetingDocTemplateNotFound, + err instanceof Error ? err.message : String(err), + ) + } + }) + + ipcMain.handle(ch.DELETE, (_event, params: DeleteMeetingDocTemplateParams) => { + try { + getMeetingDocTemplateService().delete(params.id) + return ipcSuccess(undefined) + } catch (err) { + return ipcError( + ErrorCode.MeetingDocTemplateNotFound, + err instanceof Error ? err.message : String(err), + ) + } + }) + + logger.info('Meeting Doc Template IPC handlers registered') +} diff --git a/src/main/ipc/meeting-mode-handlers.ts b/src/main/ipc/meeting-mode-handlers.ts index 260a8ba..3c2bc34 100644 --- a/src/main/ipc/meeting-mode-handlers.ts +++ b/src/main/ipc/meeting-mode-handlers.ts @@ -1,5 +1,5 @@ // src/main/ipc/meeting-mode-handlers.ts -// Phase 14: Meeting Mode IPC 핸들러 +// Phase 14 / 14.5: Meeting Mode IPC 핸들러 import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@shared/ipc-channels' @@ -12,6 +12,8 @@ import type { MeetingGetSessionParams, MeetingDeleteSessionParams, MeetingExportParams, + MeetingGenerateDocParams, + MeetingExportFormat, } from '@shared/types' const logger = getLogger('meeting-mode-handlers') @@ -111,5 +113,90 @@ export function registerMeetingModeHandlers(): void { } }) + // ── Phase 14.5 핸들러 ── + + ipcMain.handle(ch.UPDATE_TRANSCRIPT, (_event, params: { sessionId: string; editedTranscript: string }) => { + try { + getMeetingModeService().updateTranscript(params.sessionId, params.editedTranscript) + return ipcSuccess(undefined) + } catch (err) { + return ipcError( + ErrorCode.MeetingTranscriptUpdateFailed, + err instanceof Error ? err.message : String(err), + ) + } + }) + + ipcMain.handle(ch.GENERATE_DOCUMENT, async (_event, params: MeetingGenerateDocParams) => { + try { + const doc = await getMeetingModeService().generateDocument(params) + return ipcSuccess(doc) + } catch (err) { + logger.error(`generateDocument 실패: ${err instanceof Error ? err.message : String(err)}`) + return ipcError( + ErrorCode.MeetingDocGenerationFailed, + err instanceof Error ? err.message : String(err), + ) + } + }) + + ipcMain.handle(ch.GET_DOCUMENTS, (_event, params: { sessionId: string }) => { + try { + return ipcSuccess(getMeetingModeService().getDocuments(params.sessionId)) + } catch (err) { + return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err)) + } + }) + + ipcMain.handle(ch.UPDATE_DOCUMENT, (_event, params: { documentId: string; content: string }) => { + try { + getMeetingModeService().updateDocument(params.documentId, params.content) + return ipcSuccess(undefined) + } catch (err) { + return ipcError( + ErrorCode.MeetingDocumentNotFound, + err instanceof Error ? err.message : String(err), + ) + } + }) + + ipcMain.handle(ch.DELETE_DOCUMENT, (_event, params: { documentId: string }) => { + try { + getMeetingModeService().deleteDocument(params.documentId) + return ipcSuccess(undefined) + } catch (err) { + return ipcError( + ErrorCode.MeetingDocumentNotFound, + err instanceof Error ? err.message : String(err), + ) + } + }) + + ipcMain.handle(ch.EXPORT_DOCUMENT, async (_event, params: { documentId: string; format: MeetingExportFormat }) => { + try { + const filePath = await getMeetingModeService().exportDocument(params.documentId, params.format) + return ipcSuccess(filePath) + } catch (err) { + logger.error(`exportDocument 실패: ${err instanceof Error ? err.message : String(err)}`) + return ipcError( + ErrorCode.MeetingDocExportFailed, + err instanceof Error ? err.message : String(err), + ) + } + }) + + ipcMain.handle(ch.EXPORT_TRANSCRIPT, async (_event, params: { sessionId: string }) => { + try { + const filePath = await getMeetingModeService().exportTranscript(params.sessionId) + return ipcSuccess(filePath) + } catch (err) { + logger.error(`exportTranscript 실패: ${err instanceof Error ? err.message : String(err)}`) + return ipcError( + ErrorCode.MeetingExportFailed, + err instanceof Error ? err.message : String(err), + ) + } + }) + logger.info('Meeting Mode IPC handlers registered') } diff --git a/src/main/services/MeetingDocTemplateService.ts b/src/main/services/MeetingDocTemplateService.ts new file mode 100644 index 0000000..46d24a1 --- /dev/null +++ b/src/main/services/MeetingDocTemplateService.ts @@ -0,0 +1,245 @@ +// src/main/services/MeetingDocTemplateService.ts +// Phase 14.5: 회의 문서 템플릿 서비스 (electron-store 기반) + +import { EventEmitter } from 'events' +import { nanoid } from 'nanoid' +import Store from 'electron-store' +import { getLogger } from './LoggerService' +import { D3ROError, ErrorCode } from '@shared/errors' +import type { + MeetingDocTemplate, + CreateMeetingDocTemplateParams, + UpdateMeetingDocTemplateParams, + MeetingDocTemplateType, +} from '@shared/types' + +const logger = getLogger('MeetingDocTemplateService') + +// ── 빌트인 템플릿 ── +const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [ + { + id: 'builtin-minutes', + name: '회의록', + description: '결정사항, 할 일, 타임라인 구조의 표준 회의록', + templateType: 'minutes' as MeetingDocTemplateType, + systemPrompt: `당신은 전문 회의록 작성 비서입니다. +전사록을 분석하여 구조화된 회의록을 작성합니다. + +규칙: +- 전사록에 없는 내용을 추가하지 마세요 +- 시간 순서를 유지하세요 +- 한국어로 작성하세요 (원문이 영어면 원어 유지) + +출력 형식: +## 요약 +(3-5줄 핵심 요약) + +## 핵심 결정사항 +- (결정 1) +- (결정 2) + +## 할 일 목록 +- [ ] (담당자가 있으면 포함) (할 일) + +## 타임라인 +| 시간 | 내용 | +|------|------| +| MM:SS | 주요 발언/이벤트 |`, + isBuiltin: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + id: 'builtin-report', + name: '보고서', + description: '개요, 핵심 내용, 결론, 제안 구조의 보고서', + templateType: 'report' as MeetingDocTemplateType, + systemPrompt: `당신은 전문 비즈니스 보고서 작성 비서입니다. +회의 전사록을 분석하여 체계적인 보고서를 작성합니다. + +규칙: +- 전사록에 없는 내용을 추가하지 마세요 +- 사실 기반으로 작성하세요 +- 한국어로 작성하세요 + +출력 형식: +## 개요 +(회의 목적과 배경 2-3줄) + +## 핵심 내용 +- (주요 논의 사항 1) +- (주요 논의 사항 2) +- (주요 논의 사항 3) + +## 결론 +(합의된 결론 및 방향 2-3줄) + +## 제안사항 +- (제안 1) +- (제안 2)`, + isBuiltin: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + id: 'builtin-idea-note', + name: '아이디어 노트', + description: '핵심 아이디어, 장단점, 우선순위, 다음 단계 구조', + templateType: 'idea-note' as MeetingDocTemplateType, + systemPrompt: `당신은 창의적 아이디어 정리 전문가입니다. +회의 전사록에서 아이디어와 제안을 추출하여 정리합니다. + +규칙: +- 전사록에 등장한 아이디어만 정리하세요 +- 실행 가능성 중심으로 평가하세요 +- 한국어로 작성하세요 + +출력 형식: +## 핵심 아이디어 +- (아이디어 1) +- (아이디어 2) + +## 장점 +- (장점 1) +- (장점 2) + +## 단점 / 리스크 +- (단점 1) +- (단점 2) + +## 우선순위 +1. (최우선 항목) +2. (차순위 항목) + +## 다음 단계 +- [ ] (액션 아이템 1) +- [ ] (액션 아이템 2)`, + isBuiltin: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, +] + +interface MeetingDocTemplateStoreSchema { + templates: MeetingDocTemplate[] +} + +class MeetingDocTemplateService extends EventEmitter { + private _store: Store + + constructor() { + super() + this._store = new Store({ + name: 'meeting-doc-templates', + defaults: { + templates: [...BUILTIN_TEMPLATES], + }, + }) + this._ensureBuiltins() + } + + // ── CRUD ── + + getAll(): MeetingDocTemplate[] { + return this._store.get('templates', []) + } + + getById(id: string): MeetingDocTemplate | null { + return this.getAll().find((t) => t.id === id) ?? null + } + + create(params: CreateMeetingDocTemplateParams): MeetingDocTemplate { + const template: MeetingDocTemplate = { + id: nanoid(), + name: params.name, + description: params.description, + templateType: 'custom' as MeetingDocTemplateType, + systemPrompt: params.systemPrompt, + isBuiltin: false, + createdAt: Date.now(), + updatedAt: Date.now(), + } + + const templates = this.getAll() + templates.push(template) + this._store.set('templates', templates) + logger.info(`회의 문서 템플릿 생성: ${template.id} (${template.name})`) + return template + } + + update(params: UpdateMeetingDocTemplateParams): MeetingDocTemplate { + const templates = this.getAll() + const idx = templates.findIndex((t) => t.id === params.id) + if (idx === -1) { + throw new D3ROError( + ErrorCode.MeetingDocTemplateNotFound, + `템플릿을 찾을 수 없습니다: ${params.id}`, + ) + } + + const existing = templates[idx] + const updated: MeetingDocTemplate = { + ...existing, + ...(params.name !== undefined && { name: params.name }), + ...(params.description !== undefined && { description: params.description }), + ...(params.systemPrompt !== undefined && { systemPrompt: params.systemPrompt }), + updatedAt: Date.now(), + } + + templates[idx] = updated + this._store.set('templates', templates) + logger.info(`회의 문서 템플릿 수정: ${params.id}`) + return updated + } + + delete(id: string): void { + const templates = this.getAll() + const template = templates.find((t) => t.id === id) + if (!template) { + throw new D3ROError( + ErrorCode.MeetingDocTemplateNotFound, + `템플릿을 찾을 수 없습니다: ${id}`, + ) + } + if (template.isBuiltin) { + throw new D3ROError( + ErrorCode.MeetingDocTemplateBuiltinDelete, + '빌트인 템플릿은 삭제할 수 없습니다', + ) + } + + this._store.set( + 'templates', + templates.filter((t) => t.id !== id), + ) + logger.info(`회의 문서 템플릿 삭제: ${id}`) + } + + private _ensureBuiltins(): void { + const templates = this.getAll() + let changed = false + for (const builtin of BUILTIN_TEMPLATES) { + if (!templates.find((t) => t.id === builtin.id)) { + templates.push(builtin) + changed = true + } + } + if (changed) { + this._store.set('templates', templates) + } + } + + dispose(): void { + this.removeAllListeners() + } +} + +// ── 싱글톤 ── +let instance: MeetingDocTemplateService | null = null + +export function getMeetingDocTemplateService(): MeetingDocTemplateService { + if (!instance) { + instance = new MeetingDocTemplateService() + } + return instance +} diff --git a/src/main/services/MeetingModeService.ts b/src/main/services/MeetingModeService.ts index 19b5483..6f16797 100644 --- a/src/main/services/MeetingModeService.ts +++ b/src/main/services/MeetingModeService.ts @@ -1,5 +1,5 @@ // src/main/services/MeetingModeService.ts -// Phase 14: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + LLM 구조화 회의록 +// Phase 14 / 14.5: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + 문서 생성/내보내기 // 싱글톤 + EventEmitter 패턴. CaptionService 연동. import { EventEmitter } from 'events' @@ -11,9 +11,18 @@ import { getLogger } from './LoggerService' import { configGet, configSet } from './ConfigService' import { getMainWindow } from '../windows/WindowManager' import { getDatabase } from '../db' -import { meetingSessions, meetingMemos } from '../db/schema' +import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema' import { IPC_CHANNELS } from '@shared/ipc-channels' import { D3ROError, ErrorCode } from '@shared/errors' +import { + parseMinutes, + buildExportMarkdown, + markdownToSimpleHtml, + formatTime, + formatDateFile, + formatDateRange, +} from '../utils/meeting-markdown' +import { markdownToDocx } from '../utils/markdown-to-docx' import type { MeetingModeState, MeetingMemo, @@ -25,40 +34,15 @@ import type { MeetingModeStateInfo, MeetingProcessingStep, MeetingProcessingProgress, + MeetingDocument, + MeetingGenerateDocParams, + MeetingExportFormat, + MeetingDocGeneratingProgress, CaptionSegment, } from '@shared/types' const logger = getLogger('MeetingModeService') -const MEETING_MODE_SYSTEM_PROMPT = `당신은 전문 회의록 작성 비서입니다. -전사록과 참석자 메모를 분석하여 구조화된 회의록을 작성합니다. - -규칙: -- 전사록/메모에 없는 내용을 추가하지 마세요 -- 메모(📝)는 참석자가 직접 작성한 핵심 포인트이므로 우선 반영하세요 -- 시간 순서를 유지하세요 -- 한국어로 작성하세요 (원문이 영어면 원어 유지) - -출력 형식: -## 요약 -(3-5줄 핵심 요약) - -## 핵심 결정사항 -- (결정 1) -- (결정 2) - -## 할 일 목록 -- [ ] (담당자가 있으면 포함) (할 일) - -## 타임라인 -| 시간 | 내용 | -|------|------| -| MM:SS | 주요 발언/이벤트 |` - -/** 긴 회의 청크 분할 기준 (자) */ -const LONG_MEETING_THRESHOLD = 20000 -const CHUNK_SIZE = 5000 - interface MeetingModeServiceEvents { 'state-changed': (state: MeetingModeState) => void 'segment': (segment: CaptionSegment) => void @@ -234,7 +218,7 @@ class MeetingModeService extends EventEmitter { }).run() this.emit('memo-added', memo) - logger.debug(`메모 추가: [${this._formatTime(memo.timestampMs)}] ${content}`) + logger.debug(`메모 추가: [${formatTime(memo.timestampMs)}] ${content}`) return memo } @@ -302,90 +286,27 @@ class MeetingModeService extends EventEmitter { }).where(eq(meetingSessions.id, sessionId)).run() // Step 1: 전사 텍스트 합산 - this._sendProgress(sessionId, 'merging', 20) + this._sendProgress(sessionId, 'merging', 40) const rawTranscript = this._segments - .map((s) => `[${this._formatTime(s.timestamp)}] ${s.text}`) + .map((s) => `[${formatTime(s.timestamp)}] ${s.text}`) .join('\n') - // Step 2: 메모 포맷팅 - const formattedMemos = this._memos - .map((m) => `[${this._formatTime(m.timestampMs)}] 📝 ${m.content}`) - .join('\n') - - // Step 3: LLM 구조화 - this._sendProgress(sessionId, 'generating', 40) - const { getLocalLLMService } = await import('./LocalLLMService') - const llmService = getLocalLLMService() - const prompt = this._buildPrompt(rawTranscript, formattedMemos) - const sttEnd = Date.now() - - let resultText: string - let resultModel: string - - if (rawTranscript.length > LONG_MEETING_THRESHOLD) { - // 긴 회의: 2-pass 요약 - const chunks = this._splitIntoChunks(rawTranscript, CHUNK_SIZE) - const chunkResults = [] - for (let i = 0; i < chunks.length; i++) { - const pct = 40 + Math.floor((i / chunks.length) * 30) - this._sendProgress(sessionId, 'generating', pct) - const chunkResult = await llmService.generate( - `다음 회의 전사 일부를 3줄로 요약하세요:\n\n${chunks[i]}`, - { temperature: 0.3 }, - ) - chunkResults.push(chunkResult.text) - } - const condensed = chunkResults.join('\n\n') - const finalResult = await llmService.generate( - this._buildPrompt(condensed, formattedMemos), - { - systemPrompt: MEETING_MODE_SYSTEM_PROMPT, - temperature: 0.3, - maxTokens: 4096, - }, - ) - resultText = finalResult.text - resultModel = finalResult.model - } else { - const result = await llmService.generate(prompt, { - systemPrompt: MEETING_MODE_SYSTEM_PROMPT, - temperature: 0.3, - maxTokens: 4096, - }) - resultText = result.text - resultModel = result.model - } - const llmEnd = Date.now() - - // Step 4: 마크다운 파싱 - this._sendProgress(sessionId, 'parsing', 85) - const minutes = this._parseMinutes(resultText) - - // Step 5: DB 저장 - this._sendProgress(sessionId, 'saving', 90) + // Step 2: DB 저장 (rawTranscript + completed) + this._sendProgress(sessionId, 'saving', 80) const now = Date.now() db.update(meetingSessions).set({ status: 'completed', rawTranscript, - minutesMarkdown: resultText, - minutesJson: JSON.stringify(minutes), sttModel: configGet('sttModelId') as string | undefined, - llmModel: resultModel, - sttLatencyMs: sttEnd - (this._sessionStartedAt ?? sttEnd), - llmLatencyMs: llmEnd - sttEnd, updatedAt: now, }).where(eq(meetingSessions.id, sessionId)).run() - // 자동 제목 생성 - const autoTitle = minutes.summary.split('\n')[0].slice(0, 50) || '무제 회의' - db.update(meetingSessions).set({ - title: autoTitle, - updatedAt: now, - }).where(eq(meetingSessions.id, sessionId)).run() - - // Step 6: Windows 알림 + // Step 3: 알림 this._sendProgress(sessionId, 'notifying', 95) - new Notification({ title: '회의록 준비 완료', body: '회의록이 생성되었습니다.' }).show() + new Notification({ + title: '녹음 완료', + body: '전사가 완료되었습니다. 문서 생성 탭에서 회의록을 만들어보세요.', + }).show() // 완료 이벤트 this._sendProgress(sessionId, 'notifying', 100) @@ -398,7 +319,7 @@ class MeetingModeService extends EventEmitter { this._resetSession() this._setState('idle') this._sendStateToRenderer() - logger.info(`회의록 생성 완료: sessionId=${sessionId}`) + logger.info(`회의 전사 저장 완료: sessionId=${sessionId}`) } catch (err) { db.update(meetingSessions).set({ status: 'error', @@ -412,7 +333,7 @@ class MeetingModeService extends EventEmitter { const d3roErr = new D3ROError( ErrorCode.MeetingProcessingFailed, - `회의록 생성 실패: ${err instanceof Error ? err.message : String(err)}`, + `후처리 실패: ${err instanceof Error ? err.message : String(err)}`, ) this.emit('error', d3roErr) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, { @@ -509,7 +430,7 @@ class MeetingModeService extends EventEmitter { const { filePath } = await dialog.showSaveDialog({ title: 'PDF로 내보내기', - defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.pdf`, + defaultPath: `회의록_${formatDateFile(session.startedAt)}.pdf`, filters: [{ name: 'PDF', extensions: ['pdf'] }], }) if (!filePath) return '' @@ -538,12 +459,12 @@ class MeetingModeService extends EventEmitter { const { filePath } = await dialog.showSaveDialog({ title: '마크다운으로 내보내기', - defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.md`, + defaultPath: `회의록_${formatDateFile(session.startedAt)}.md`, filters: [{ name: 'Markdown', extensions: ['md'] }], }) if (!filePath) return '' - const md = this._buildExportMarkdown(session) + const md = buildExportMarkdown(session) fs.writeFileSync(filePath, md, 'utf-8') logger.info(`마크다운 내보내기 완료: ${filePath}`) return filePath @@ -581,80 +502,263 @@ class MeetingModeService extends EventEmitter { this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, progress) } - private _formatTime(ms: number): string { - const totalSeconds = Math.floor(ms / 1000) - const minutes = Math.floor(totalSeconds / 60) - const seconds = totalSeconds % 60 - return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` - } + // ── Phase 14.5: 전사 수정 ── - private _formatDateFile(epochMs: number): string { - const d = new Date(epochMs) - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}` - } - - private _formatDateRange(startedAt: number, endedAt: number | null): string { - const fmt = (ts: number): string => { - const d = new Date(ts) - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + updateTranscript(sessionId: string, editedTranscript: string): void { + const db = getDatabase() + const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() + if (!row) { + throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`) } - if (!endedAt) return fmt(startedAt) - return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}` + db.update(meetingSessions).set({ + editedTranscript, + updatedAt: Date.now(), + }).where(eq(meetingSessions.id, sessionId)).run() + logger.info(`전사 수정 저장: sessionId=${sessionId}`) } - private _buildPrompt(rawTranscript: string, formattedMemos: string): string { - return `## 전사록\n${rawTranscript}\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}\n\n위 전사록과 메모를 바탕으로 회의록을 작성해주세요.` - } + // ── Phase 14.5: 문서 생성 ── - private _parseMinutes(markdown: string): MeetingMinutes { - const minutes: MeetingMinutes = { - summary: '', - decisions: [], - actionItems: [], - timeline: [], + async generateDocument(params: MeetingGenerateDocParams): Promise { + const db = getDatabase() + const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, params.sessionId)).get() + if (!row) { + throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${params.sessionId}`) } - const sections = markdown.split(/^## /m) - for (const section of sections) { - const lines = section.trim().split('\n') - const heading = lines[0]?.toLowerCase() ?? '' - const body = lines.slice(1).join('\n').trim() + const { getMeetingDocTemplateService } = await import('./MeetingDocTemplateService') + const templateService = getMeetingDocTemplateService() + const template = templateService.getById(params.templateId) + if (!template) { + throw new D3ROError(ErrorCode.MeetingDocTemplateNotFound, `템플릿을 찾을 수 없습니다: ${params.templateId}`) + } - if (heading.includes('요약') || heading.includes('summary')) { - minutes.summary = body - } else if (heading.includes('결정') || heading.includes('decision')) { - minutes.decisions = body - .split('\n') - .filter((l) => l.startsWith('-') || l.startsWith('*')) - .map((l) => l.replace(/^[-*]\s*/, '').trim()) - } else if (heading.includes('할 일') || heading.includes('action')) { - minutes.actionItems = body - .split('\n') - .filter((l) => l.startsWith('-') || l.startsWith('*')) - .map((l) => { - const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim() - return { task: text } + const transcript = row.editedTranscript ?? row.rawTranscript ?? '' + const systemPrompt = params.customPrompt ?? template.systemPrompt + const docTitle = params.customTitle ?? `${template.name} — ${row.title ?? '무제 회의'}` + + // 진행률 전송 + const sendProgress = (percent: number): void => { + const progress: MeetingDocGeneratingProgress = { + sessionId: params.sessionId, + templateId: params.templateId, + percent, + } + this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DOC_GENERATING_PROGRESS, progress) + } + + sendProgress(10) + + const { getLocalLLMService } = await import('./LocalLLMService') + const llmService = getLocalLLMService() + + sendProgress(30) + + const llmStart = Date.now() + const result = await llmService.generate( + `다음 회의 전사록을 분석하여 문서를 작성해주세요:\n\n${transcript}`, + { + systemPrompt, + temperature: 0.3, + maxTokens: 4096, + }, + ) + const llmLatencyMs = Date.now() - llmStart + + sendProgress(85) + + const now = Date.now() + const docId = nanoid() + db.insert(meetingDocuments).values({ + id: docId, + sessionId: params.sessionId, + templateType: template.templateType, + title: docTitle, + content: result.text, + promptUsed: systemPrompt, + llmModel: result.model, + llmLatencyMs, + createdAt: now, + updatedAt: now, + }).run() + + sendProgress(100) + logger.info(`문서 생성 완료: docId=${docId}, sessionId=${params.sessionId}`) + + return { + id: docId, + sessionId: params.sessionId, + templateType: template.templateType, + title: docTitle, + content: result.text, + promptUsed: systemPrompt, + llmModel: result.model, + llmLatencyMs, + createdAt: now, + updatedAt: now, + } + } + + // ── Phase 14.5: 문서 조회/수정/삭제 ── + + getDocuments(sessionId: string): MeetingDocument[] { + const db = getDatabase() + const rows = db.select().from(meetingDocuments) + .where(eq(meetingDocuments.sessionId, sessionId)) + .all() + + return rows.map((r) => ({ + id: r.id, + sessionId: r.sessionId, + templateType: r.templateType, + title: r.title, + content: r.content, + promptUsed: r.promptUsed, + llmModel: r.llmModel, + llmLatencyMs: r.llmLatencyMs, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })) + } + + updateDocument(documentId: string, content: string): void { + const db = getDatabase() + const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get() + if (!row) { + throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`) + } + db.update(meetingDocuments).set({ content, updatedAt: Date.now() }) + .where(eq(meetingDocuments.id, documentId)).run() + logger.info(`문서 수정: docId=${documentId}`) + } + + deleteDocument(documentId: string): void { + const db = getDatabase() + const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get() + if (!row) { + throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`) + } + db.delete(meetingDocuments).where(eq(meetingDocuments.id, documentId)).run() + logger.info(`문서 삭제: docId=${documentId}`) + } + + // ── Phase 14.5: 문서 내보내기 ── + + async exportDocument(documentId: string, format: MeetingExportFormat): Promise { + const db = getDatabase() + const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get() + if (!row) { + throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`) + } + + const safeTitle = row.title.replace(/[/\\?%*:|"<>]/g, '-').slice(0, 50) + const dateStr = formatDateFile(row.createdAt) + + let filters: Electron.FileFilter[] + let defaultName: string + + switch (format) { + case 'md': + filters = [{ name: 'Markdown', extensions: ['md'] }] + defaultName = `${safeTitle}_${dateStr}.md` + break + case 'txt': + filters = [{ name: 'Text', extensions: ['txt'] }] + defaultName = `${safeTitle}_${dateStr}.txt` + break + case 'docx': + filters = [{ name: 'Word Document', extensions: ['docx'] }] + defaultName = `${safeTitle}_${dateStr}.docx` + break + case 'pdf': + filters = [{ name: 'PDF', extensions: ['pdf'] }] + defaultName = `${safeTitle}_${dateStr}.pdf` + break + } + + const { filePath } = await dialog.showSaveDialog({ + title: '문서 내보내기', + defaultPath: defaultName, + filters, + }) + if (!filePath) return '' + + switch (format) { + case 'md': + case 'txt': + fs.writeFileSync(filePath, row.content, 'utf-8') + break + case 'docx': { + const buf = await markdownToDocx(row.content, row.title) + fs.writeFileSync(filePath, buf) + break + } + case 'pdf': { + const html = ` + + + + + + +

${row.title}

+ ${markdownToSimpleHtml(row.content)} + +` + const win = new BrowserWindow({ show: false, width: 800, height: 600 }) + try { + await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) + const pdfBuffer = await win.webContents.printToPDF({ + printBackground: true, + pageSize: 'A4', + margins: { top: 20, bottom: 20, left: 20, right: 20 }, }) - } else if (heading.includes('타임라인') || heading.includes('timeline')) { - const tableRows = body - .split('\n') - .filter((l) => l.includes('|') && !l.includes('---')) - for (const row of tableRows) { - const cells = row.split('|').map((c) => c.trim()).filter(Boolean) - if (cells.length >= 2) { - minutes.timeline.push({ - time: cells[0], - content: cells[1], - type: cells[1].includes('📝') ? 'memo' : 'transcript', - }) - } + fs.writeFileSync(filePath, pdfBuffer) + } finally { + win.close() } + break } } - return minutes + logger.info(`문서 내보내기 완료: format=${format}, path=${filePath}`) + return filePath } + async exportTranscript(sessionId: string): Promise { + const db = getDatabase() + const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() + if (!row) { + throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`) + } + + const transcript = row.editedTranscript ?? row.rawTranscript ?? '' + const dateStr = formatDateFile(row.startedAt) + const safeTitle = (row.title ?? '무제_회의').replace(/[/\\?%*:|"<>]/g, '-').slice(0, 50) + + const { filePath } = await dialog.showSaveDialog({ + title: '전사 내보내기', + defaultPath: `전사_${safeTitle}_${dateStr}.txt`, + filters: [{ name: 'Text', extensions: ['txt'] }], + }) + if (!filePath) return '' + + fs.writeFileSync(filePath, transcript, 'utf-8') + logger.info(`전사 내보내기 완료: path=${filePath}`) + return filePath + } + + // ── 내부 헬퍼 ── + private _loadSessionDetail(sessionId: string): MeetingSessionDetail | null { const db = getDatabase() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() @@ -673,6 +777,23 @@ class MeetingModeService extends EventEmitter { createdAt: m.createdAt, })) + const docRows = db.select().from(meetingDocuments) + .where(eq(meetingDocuments.sessionId, sessionId)) + .all() + + const documents: MeetingDocument[] = docRows.map((d) => ({ + id: d.id, + sessionId: d.sessionId, + templateType: d.templateType, + title: d.title, + content: d.content, + promptUsed: d.promptUsed, + llmModel: d.llmModel, + llmLatencyMs: d.llmLatencyMs, + createdAt: d.createdAt, + updatedAt: d.updatedAt, + })) + let minutes: MeetingMinutes | null = null if (row.minutesJson) { try { @@ -691,27 +812,21 @@ class MeetingModeService extends EventEmitter { durationMs: row.durationMs, memoCount: memos.length, rawTranscript: row.rawTranscript, + editedTranscript: row.editedTranscript, minutesMarkdown: row.minutesMarkdown, minutes, memos, + documents, sttModel: row.sttModel, llmModel: row.llmModel, errorMessage: row.errorMessage, } } - private _splitIntoChunks(text: string, chunkSize: number): string[] { - const chunks: string[] = [] - for (let i = 0; i < text.length; i += chunkSize) { - chunks.push(text.slice(i, i + chunkSize)) - } - return chunks - } - private _buildPdfHtml(session: MeetingSessionDetail): string { const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 const minutesHtml = session.minutesMarkdown - ? this._markdownToSimpleHtml(session.minutesMarkdown) + ? markdownToSimpleHtml(session.minutesMarkdown) : '

회의록이 생성되지 않았습니다.

' return ` @@ -733,53 +848,11 @@ class MeetingModeService extends EventEmitter {

${session.title ?? '무제 회의'}

-

일시: ${this._formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분

+

일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분

${minutesHtml} ` } - - private _buildExportMarkdown(session: MeetingSessionDetail): string { - const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 - const memoTable = session.memos.length > 0 - ? `## 참석자 메모\n\n| 시간 | 메모 |\n|------|------|\n${session.memos.map((m) => `| ${this._formatTime(m.timestampMs)} | ${m.content} |`).join('\n')}` - : '' - - return `# 회의록 — ${session.title ?? '무제 회의'} - -- **일시**: ${this._formatDateRange(session.startedAt, session.endedAt)} -- **소요 시간**: ${durationMin}분 -- **STT 모델**: ${session.sttModel ?? '-'} -- **LLM 모델**: ${session.llmModel ?? '-'} - ---- - -${session.minutesMarkdown ?? '회의록이 생성되지 않았습니다.'} - ---- - -${memoTable} - ---- - -## 원문 전사 - -${session.rawTranscript ?? '(없음)'} -` - } - - /** 간단한 마크다운→HTML 변환 (PDF 생성용) */ - private _markdownToSimpleHtml(md: string): string { - return md - .replace(/^### (.+)$/gm, '

$1

') - .replace(/^## (.+)$/gm, '

$1

') - .replace(/^# (.+)$/gm, '

$1

') - .replace(/^\- \[.\] (.+)$/gm, '
  • $1
  • ') - .replace(/^\- (.+)$/gm, '
  • $1
  • ') - .replace(/^\* (.+)$/gm, '
  • $1
  • ') - .replace(/\n{2,}/g, '

    ') - .replace(/^(?!<[h|l|t|p])/gm, '') - } } // ── 싱글톤 ── diff --git a/src/main/utils/markdown-to-docx.ts b/src/main/utils/markdown-to-docx.ts new file mode 100644 index 0000000..fa89271 --- /dev/null +++ b/src/main/utils/markdown-to-docx.ts @@ -0,0 +1,31 @@ +// src/main/utils/markdown-to-docx.ts +// Phase 14.5: 마크다운 → DOCX 변환 유틸리티 + +import { Document, Packer, Paragraph, TextRun, HeadingLevel } from 'docx' + +export async function markdownToDocx(markdown: string, title: string): Promise { + const paragraphs: Paragraph[] = [] + + // 타이틀 추가 + paragraphs.push(new Paragraph({ text: title, heading: HeadingLevel.TITLE })) + + // 줄 단위 파싱 + for (const line of markdown.split('\n')) { + if (line.startsWith('## ')) { + paragraphs.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 })) + } else if (line.startsWith('### ')) { + paragraphs.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 })) + } else if (line.startsWith('# ')) { + paragraphs.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 })) + } else if (line.startsWith('- [ ] ')) { + paragraphs.push(new Paragraph({ text: `\u2610 ${line.slice(6)}`, bullet: { level: 0 } })) + } else if (line.startsWith('- ') || line.startsWith('* ')) { + paragraphs.push(new Paragraph({ text: line.slice(2), bullet: { level: 0 } })) + } else if (line.trim()) { + paragraphs.push(new Paragraph({ children: [new TextRun(line)] })) + } + } + + const doc = new Document({ sections: [{ children: paragraphs }] }) + return Buffer.from(await Packer.toBuffer(doc)) +} diff --git a/src/main/utils/meeting-markdown.ts b/src/main/utils/meeting-markdown.ts new file mode 100644 index 0000000..7069cbb --- /dev/null +++ b/src/main/utils/meeting-markdown.ts @@ -0,0 +1,116 @@ +// src/main/utils/meeting-markdown.ts +// Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티 + +import type { MeetingMinutes, MeetingSessionDetail } from '@shared/types' + +export function formatTime(ms: number): string { + const totalSeconds = Math.floor(ms / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` +} + +export function formatDateFile(epochMs: number): string { + const d = new Date(epochMs) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}` +} + +export function formatDateRange(startedAt: number, endedAt: number | null): string { + const fmt = (ts: number): string => { + const d = new Date(ts) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + } + if (!endedAt) return fmt(startedAt) + return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}` +} + +export function parseMinutes(markdown: string): MeetingMinutes { + const minutes: MeetingMinutes = { + summary: '', + decisions: [], + actionItems: [], + timeline: [], + } + + const sections = markdown.split(/^## /m) + for (const section of sections) { + const lines = section.trim().split('\n') + const heading = lines[0]?.toLowerCase() ?? '' + const body = lines.slice(1).join('\n').trim() + + if (heading.includes('요약') || heading.includes('summary')) { + minutes.summary = body + } else if (heading.includes('결정') || heading.includes('decision')) { + minutes.decisions = body + .split('\n') + .filter((l) => l.startsWith('-') || l.startsWith('*')) + .map((l) => l.replace(/^[-*]\s*/, '').trim()) + } else if (heading.includes('할 일') || heading.includes('action')) { + minutes.actionItems = body + .split('\n') + .filter((l) => l.startsWith('-') || l.startsWith('*')) + .map((l) => { + const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim() + return { task: text } + }) + } else if (heading.includes('타임라인') || heading.includes('timeline')) { + const tableRows = body + .split('\n') + .filter((l) => l.includes('|') && !l.includes('---')) + for (const row of tableRows) { + const cells = row.split('|').map((c) => c.trim()).filter(Boolean) + if (cells.length >= 2) { + minutes.timeline.push({ + time: cells[0], + content: cells[1], + type: cells[1].includes('📝') ? 'memo' : 'transcript', + }) + } + } + } + } + + return minutes +} + +export function buildExportMarkdown(session: MeetingSessionDetail): string { + const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 + const memoTable = session.memos.length > 0 + ? `## 참석자 메모\n\n| 시간 | 메모 |\n|------|------|\n${session.memos.map((m) => `| ${formatTime(m.timestampMs)} | ${m.content} |`).join('\n')}` + : '' + + return `# 회의록 — ${session.title ?? '무제 회의'} + +- **일시**: ${formatDateRange(session.startedAt, session.endedAt)} +- **소요 시간**: ${durationMin}분 +- **STT 모델**: ${session.sttModel ?? '-'} +- **LLM 모델**: ${session.llmModel ?? '-'} + +--- + +${session.minutesMarkdown ?? '회의록이 생성되지 않았습니다.'} + +--- + +${memoTable} + +--- + +## 원문 전사 + +${session.rawTranscript ?? '(없음)'} +` +} + +/** 간단한 마크다운→HTML 변환 (PDF 생성용) */ +export function markdownToSimpleHtml(md: string): string { + return md + .replace(/^### (.+)$/gm, '

    $1

    ') + .replace(/^## (.+)$/gm, '

    $1

    ') + .replace(/^# (.+)$/gm, '

    $1

    ') + .replace(/^\- \[.\] (.+)$/gm, '
  • $1
  • ') + .replace(/^\- (.+)$/gm, '
  • $1
  • ') + .replace(/^\* (.+)$/gm, '
  • $1
  • ') + .replace(/\n{2,}/g, '

    ') + .replace(/^(?!<[h|l|t|p])/gm, '') +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 7cee286..e3e84b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -148,6 +148,20 @@ import type { MeetingExportParams, MeetingProcessingProgress, CaptionSegment, + MeetingDocument, + MeetingDocTemplate, + MeetingGenerateDocParams, + MeetingGenerateDocResult, + MeetingUpdateDocParams, + MeetingDeleteDocParams, + MeetingGetDocsParams, + MeetingUpdateTranscriptParams, + MeetingExportDocParams, + MeetingExportFormat, + MeetingDocGeneratingProgress, + CreateMeetingDocTemplateParams, + UpdateMeetingDocTemplateParams, + DeleteMeetingDocTemplateParams, } from '@shared/types' import { Feature } from '@shared/types' import type { IPCResult } from '@shared/errors' @@ -602,6 +616,37 @@ const electronAPI = { on(IPC_CHANNELS.MEETING_MODE.ERROR, cb), onAudioLevel: (cb: (e: { level: number }) => void): Unsubscribe => on('meetingMode:audioLevel', cb), + // Phase 14.5 + updateTranscript: (params: MeetingUpdateTranscriptParams) => + invoke(IPC_CHANNELS.MEETING_MODE.UPDATE_TRANSCRIPT, params), + generateDocument: (params: MeetingGenerateDocParams) => + invoke(IPC_CHANNELS.MEETING_MODE.GENERATE_DOCUMENT, params), + getDocuments: (params: MeetingGetDocsParams) => + invoke(IPC_CHANNELS.MEETING_MODE.GET_DOCUMENTS, params), + updateDocument: (params: MeetingUpdateDocParams) => + invoke(IPC_CHANNELS.MEETING_MODE.UPDATE_DOCUMENT, params), + deleteDocument: (params: MeetingDeleteDocParams) => + invoke(IPC_CHANNELS.MEETING_MODE.DELETE_DOCUMENT, params), + exportDocument: (params: MeetingExportDocParams) => + invoke(IPC_CHANNELS.MEETING_MODE.EXPORT_DOCUMENT, params), + exportTranscript: (params: { sessionId: string; format: MeetingExportFormat }) => + invoke(IPC_CHANNELS.MEETING_MODE.EXPORT_TRANSCRIPT, params), + editSegment: (params: { sessionId: string; segmentId: string; text: string }) => + invoke(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, params), + onDocGeneratingProgress: (cb: (e: MeetingDocGeneratingProgress) => void): Unsubscribe => + on(IPC_CHANNELS.MEETING_MODE.DOC_GENERATING_PROGRESS, cb), + }, + + // ── Meeting Doc Templates (Phase 14.5) ─────────────── + meetingDocTemplate: { + getAll: () => + invoke(IPC_CHANNELS.MEETING_DOC_TEMPLATE.GET_ALL), + create: (params: CreateMeetingDocTemplateParams) => + invoke(IPC_CHANNELS.MEETING_DOC_TEMPLATE.CREATE, params), + update: (params: UpdateMeetingDocTemplateParams) => + invoke(IPC_CHANNELS.MEETING_DOC_TEMPLATE.UPDATE, params), + delete: (params: DeleteMeetingDocTemplateParams) => + invoke(IPC_CHANNELS.MEETING_DOC_TEMPLATE.DELETE, params), }, } as const diff --git a/src/renderer/components/meeting/AddDocumentDialog.tsx b/src/renderer/components/meeting/AddDocumentDialog.tsx new file mode 100644 index 0000000..4742466 --- /dev/null +++ b/src/renderer/components/meeting/AddDocumentDialog.tsx @@ -0,0 +1,190 @@ +// src/renderer/components/meeting/AddDocumentDialog.tsx +// Phase 14.5: 문서 생성 다이얼로그 — 템플릿 선택 + 커스텀 프롬프트 + +import { useState, useCallback } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Box, + TextField, + LinearProgress, +} from '@mui/material' +import { MetalCard } from '../ds/MetalCard' +import { PhosphorText } from '../ds/PhosphorText' +import { PhysicalButton } from '../ds/PhysicalButton' +import { d3roPalette, d3roTypo, d3roRadius, d3roShadow } from '../../theme' +import { useI18n } from '../../i18n' + +interface TemplateItem { + id: string + name: string + description: string + templateType: string + isBuiltin: boolean +} + +interface AddDocumentDialogProps { + open: boolean + onClose: () => void + onGenerate: (templateId: string, customPrompt?: string, customTitle?: string) => void + templates: TemplateItem[] + generating: boolean +} + +export function AddDocumentDialog({ + open, + onClose, + onGenerate, + templates, + generating, +}: AddDocumentDialogProps): React.ReactElement { + const { t } = useI18n() + const [selectedId, setSelectedId] = useState(null) + const [customPrompt, setCustomPrompt] = useState('') + const [customTitle, setCustomTitle] = useState('') + + const selectedTemplate = templates.find((tp) => tp.id === selectedId) + const isCustom = selectedTemplate?.templateType === 'custom' + + const handleGenerate = useCallback(() => { + if (!selectedId) return + onGenerate( + selectedId, + isCustom && customPrompt.trim() ? customPrompt.trim() : undefined, + isCustom && customTitle.trim() ? customTitle.trim() : undefined, + ) + }, [selectedId, isCustom, customPrompt, customTitle, onGenerate]) + + const handleClose = useCallback(() => { + if (!generating) { + setSelectedId(null) + setCustomPrompt('') + setCustomTitle('') + onClose() + } + }, [generating, onClose]) + + return ( +

    + + {t('meeting.selectTemplate')} + + + + {generating && ( + + + + {t('meeting.generating')} + + + )} + + {/* 템플릿 목록 */} + + {templates.map((tp) => ( + !generating && setSelectedId(tp.id)} + sx={{ + cursor: generating ? 'default' : 'pointer', + opacity: generating ? 0.6 : 1, + border: + selectedId === tp.id + ? `1px solid ${d3roPalette.accent.amber}` + : `1px solid ${d3roPalette.border.subtle}`, + transition: 'border-color 0.15s', + '&:hover': { + borderColor: generating ? undefined : d3roPalette.accent.amberDim, + }, + }} + > + {tp.name} + + {tp.description} + + + ))} + + + {/* 커스텀 템플릿 추가 입력 */} + {isCustom && ( + + setCustomTitle(e.target.value)} + disabled={generating} + sx={{ + '& .MuiInputBase-root': { fontSize: d3roTypo.compact.size }, + }} + /> + setCustomPrompt(e.target.value)} + disabled={generating} + placeholder={t('meeting.templatePrompt')} + sx={{ + '& .MuiInputBase-root': { fontSize: d3roTypo.compact.size }, + }} + /> + + )} + + + + + {t('common.cancel')} + + + {generating ? t('meeting.generating') : t('meeting.generate')} + + + + ) +} diff --git a/src/renderer/components/meeting/DocumentTab.tsx b/src/renderer/components/meeting/DocumentTab.tsx new file mode 100644 index 0000000..45dea9a --- /dev/null +++ b/src/renderer/components/meeting/DocumentTab.tsx @@ -0,0 +1,89 @@ +// src/renderer/components/meeting/DocumentTab.tsx +// Phase 14.5: 문서 탭 — MarkdownEditor + 자동저장 + 내보내기 + +import { useCallback, useRef } from 'react' +import { Box } from '@mui/material' +import DeleteIcon from '@mui/icons-material/Delete' +import { MarkdownEditor } from './MarkdownEditor' +import { ExportMenu } from './ExportMenu' +import { PhysicalButton } from '../ds/PhysicalButton' +import { d3roPalette, d3roTypo } from '../../theme' +import { useI18n } from '../../i18n' +import type { MeetingExportFormat } from '@shared/types' + +interface DocumentTabDoc { + id: string + title: string + content: string + templateType: string +} + +interface DocumentTabProps { + document: DocumentTabDoc + onContentChange: (content: string) => void + onExport: (format: MeetingExportFormat) => void + onDelete: () => void +} + +const AUTOSAVE_DELAY = 500 + +export function DocumentTab({ + document: doc, + onContentChange, + onExport, + onDelete, +}: DocumentTabProps): React.ReactElement { + const { t } = useI18n() + const timerRef = useRef | null>(null) + + const handleChange = useCallback( + (content: string) => { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + onContentChange(content) + }, AUTOSAVE_DELAY) + }, + [onContentChange], + ) + + return ( + + {/* 콘텐츠 편집기 */} + + + + + {/* 하단 액션 바 */} + + + + + {t('common.delete')} + + + + ) +} diff --git a/src/renderer/components/meeting/EditableSegment.tsx b/src/renderer/components/meeting/EditableSegment.tsx new file mode 100644 index 0000000..ebafb5b --- /dev/null +++ b/src/renderer/components/meeting/EditableSegment.tsx @@ -0,0 +1,143 @@ +// src/renderer/components/meeting/EditableSegment.tsx +// Phase 14.5: 전사 세그먼트 인라인 편집 컴포넌트 + +import { useState, useRef, useCallback } from 'react' +import { Box, TextField, Tooltip } from '@mui/material' +import { d3roPalette, d3roFontMono } from '../../theme' +import { useI18n } from '../../i18n' + +interface EditableSegmentProps { + segmentId: string + timestamp: number // ms, 상대 시간 + text: string + edited: boolean // 수정 여부 + onEdit: (segmentId: string, newText: string) => void +} + +function formatTimestamp(ms: number): string { + const totalSec = Math.floor(ms / 1000) + const min = Math.floor(totalSec / 60) + const sec = totalSec % 60 + return `[${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}]` +} + +export function EditableSegment({ + segmentId, + timestamp, + text, + edited, + onEdit, +}: EditableSegmentProps): React.ReactElement { + const { t } = useI18n() + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(text) + const inputRef = useRef(null) + + const handleClick = useCallback(() => { + setDraft(text) + setEditing(true) + }, [text]) + + const handleCommit = useCallback(() => { + const trimmed = draft.trim() + if (trimmed && trimmed !== text) { + onEdit(segmentId, trimmed) + } + setEditing(false) + }, [draft, text, segmentId, onEdit]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleCommit() + } else if (e.key === 'Escape') { + setEditing(false) + } + }, + [handleCommit], + ) + + if (editing) { + return ( + + + {formatTimestamp(timestamp)} + + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleCommit} + autoFocus + size="small" + fullWidth + multiline + sx={{ + '& .MuiInputBase-root': { + fontFamily: d3roFontMono, + fontSize: 13, + bgcolor: d3roPalette.bg.input, + }, + }} + /> + + ) + } + + const textNode = ( + + {text} + + ) + + return ( + + + {formatTimestamp(timestamp)} + + {edited ? ( + + {textNode} + + ) : ( + textNode + )} + + ) +} diff --git a/src/renderer/components/meeting/ExportMenu.tsx b/src/renderer/components/meeting/ExportMenu.tsx new file mode 100644 index 0000000..af24598 --- /dev/null +++ b/src/renderer/components/meeting/ExportMenu.tsx @@ -0,0 +1,96 @@ +// src/renderer/components/meeting/ExportMenu.tsx +// Phase 14.5: 다운로드 형식 선택 드롭다운 + +import { useState, useCallback } from 'react' +import { Menu, MenuItem } from '@mui/material' +import FileDownloadIcon from '@mui/icons-material/FileDownload' +import { PhysicalButton } from '../ds/PhysicalButton' +import { d3roPalette, d3roTypo, d3roFontMono, d3roRadius, d3roShadow } from '../../theme' +import { useI18n } from '../../i18n' +import type { MeetingExportFormat } from '@shared/types' + +interface ExportMenuProps { + onExport: (format: MeetingExportFormat) => void +} + +interface FormatItem { + format: MeetingExportFormat + label: string +} + +export function ExportMenu({ onExport }: ExportMenuProps): React.ReactElement { + const { t } = useI18n() + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + + const handleOpen = useCallback((e: React.MouseEvent) => { + setAnchorEl(e.currentTarget) + }, []) + + const handleClose = useCallback(() => { + setAnchorEl(null) + }, []) + + const handleSelect = useCallback( + (format: MeetingExportFormat) => { + onExport(format) + handleClose() + }, + [onExport, handleClose], + ) + + const formats: FormatItem[] = [ + { format: 'md', label: 'Markdown (.md)' }, + { format: 'txt', label: t('meeting.exportTxt') }, + { format: 'pdf', label: 'PDF (.pdf)' }, + { format: 'docx', label: t('meeting.exportDocx') }, + ] + + return ( + <> + + + {t('meeting.exportFormat')} + + + + {formats.map(({ format, label }) => ( + handleSelect(format)} + sx={{ + fontFamily: d3roFontMono, + fontSize: d3roTypo.compact.size, + color: d3roPalette.text.primary, + '&:hover': { + bgcolor: d3roPalette.bg.cardHover, + color: d3roPalette.accent.amber, + }, + }} + > + {label} + + ))} + + + ) +} diff --git a/src/renderer/components/meeting/MarkdownEditor.tsx b/src/renderer/components/meeting/MarkdownEditor.tsx new file mode 100644 index 0000000..7f4e61b --- /dev/null +++ b/src/renderer/components/meeting/MarkdownEditor.tsx @@ -0,0 +1,83 @@ +// src/renderer/components/meeting/MarkdownEditor.tsx +// Phase 14.5: 마크다운 렌더링/편집 토글 컴포넌트 + +import { useState } from 'react' +import { Box } from '@mui/material' +import { MarkdownRenderer } from './MarkdownRenderer' +import { PhysicalButton } from '../ds/PhysicalButton' +import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '../../theme' +import { useI18n } from '../../i18n' + +interface MarkdownEditorProps { + content: string + onChange: (content: string) => void + readOnly?: boolean +} + +export function MarkdownEditor({ + content, + onChange, + readOnly = false, +}: MarkdownEditorProps): React.ReactElement { + const { t } = useI18n() + const [preview, setPreview] = useState(true) + + return ( + + {/* 상단 토글 */} + {!readOnly && ( + + setPreview(true)} + sx={{ height: 32, fontSize: d3roTypo.engrave.size }} + > + {t('meeting.previewMode')} + + setPreview(false)} + sx={{ height: 32, fontSize: d3roTypo.engrave.size }} + > + {t('meeting.editMode')} + + + )} + + {/* 콘텐츠 영역 */} + + {preview || readOnly ? ( + + ) : ( + ) => onChange(e.target.value)} + sx={{ + width: '100%', + height: '100%', + minHeight: 300, + resize: 'none', + fontFamily: d3roFontMono, + fontSize: d3roTypo.compact.size, + color: d3roPalette.text.primary, + bgcolor: d3roPalette.bg.input, + border: `1px solid ${d3roPalette.border.default}`, + borderRadius: d3roRadius.small, + boxShadow: d3roShadow.inset, + p: 1.5, + outline: 'none', + lineHeight: 1.7, + boxSizing: 'border-box', + '&:focus': { + borderColor: d3roPalette.accent.amber, + }, + }} + /> + )} + + + ) +} diff --git a/src/renderer/components/meeting/MarkdownRenderer.tsx b/src/renderer/components/meeting/MarkdownRenderer.tsx new file mode 100644 index 0000000..29159c1 --- /dev/null +++ b/src/renderer/components/meeting/MarkdownRenderer.tsx @@ -0,0 +1,198 @@ +// src/renderer/components/meeting/MarkdownRenderer.tsx +// Phase 14.5: react-markdown + remark-gfm 래퍼 — d3roPalette 기반 스타일 + +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { Box } from '@mui/material' +import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../../theme' +import type { Components } from 'react-markdown' + +interface MarkdownRendererProps { + content: string +} + +const components: Components = { + h1: ({ children }) => ( + + {children} + + ), + h2: ({ children }) => ( + + {children} + + ), + h3: ({ children }) => ( + + {children} + + ), + p: ({ children }) => ( + + {children} + + ), + code: ({ children, className }) => { + const isBlock = className?.startsWith('language-') ?? false + if (isBlock) { + return ( + + + {children} + + + ) + } + return ( + + {children} + + ) + }, + table: ({ children }) => ( + + {children} + + ), + li: ({ children }) => ( + + {children} + + ), + ul: ({ children }) => ( + + {children} + + ), + ol: ({ children }) => ( + + {children} + + ), + blockquote: ({ children }) => ( + + {children} + + ), +} + +export function MarkdownRenderer({ content }: MarkdownRendererProps): React.ReactElement { + return ( + + + {content} + + + ) +} diff --git a/src/renderer/components/meeting/MeetingDetailTabs.tsx b/src/renderer/components/meeting/MeetingDetailTabs.tsx new file mode 100644 index 0000000..b932916 --- /dev/null +++ b/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -0,0 +1,336 @@ +// src/renderer/components/meeting/MeetingDetailTabs.tsx +// Phase 14.5: 풀스크린 탭 전환 컨테이너 — 전사 + 동적 문서 탭 + +import { useState, useEffect, useCallback, useRef } from 'react' +import { + Box, + Tabs, + Tab, + IconButton, + TextField, + Tooltip, +} from '@mui/material' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import AddIcon from '@mui/icons-material/Add' +import EditIcon from '@mui/icons-material/Edit' +import CheckIcon from '@mui/icons-material/Check' +import { TranscriptTab } from './TranscriptTab' +import { DocumentTab } from './DocumentTab' +import { AddDocumentDialog } from './AddDocumentDialog' +import { PhosphorText } from '../ds/PhosphorText' +import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme' +import { useI18n } from '../../i18n' +import type { + MeetingSessionDetail, + MeetingDocument, + MeetingDocTemplate, + MeetingExportFormat, +} from '@shared/types' + +interface MeetingDetailTabsProps { + detail: MeetingSessionDetail + onBack: () => void +} + +const AUTOSAVE_DELAY = 500 + +// 세그먼트 파싱: rawTranscript를 줄 단위로 분리 +function parseSegments( + rawTranscript: string | null, + editedTranscript: string | null, +): Array<{ id: string; timestamp: number; text: string; edited: boolean }> { + const source = rawTranscript ?? '' + const edited = editedTranscript ?? '' + // [MM:SS] 텍스트 패턴 파싱 시도 + const regex = /^\[(\d{2}):(\d{2})\]\s*(.+)$/ + return source + .split('\n') + .filter((line) => line.trim()) + .map((line, idx) => { + const m = regex.exec(line.trim()) + if (m) { + const min = parseInt(m[1], 10) + const sec = parseInt(m[2], 10) + const text = m[3] + // 수정본이 있으면 edited 판단 + const editedLine = edited.split('\n')[idx]?.trim() ?? '' + return { + id: `seg-${idx}`, + timestamp: (min * 60 + sec) * 1000, + text, + edited: Boolean(editedLine) && editedLine !== line.trim(), + } + } + // 타임스탬프 없는 라인: 인덱스 기반 + return { + id: `seg-${idx}`, + timestamp: idx * 5000, + text: line.trim(), + edited: false, + } + }) +} + +export function MeetingDetailTabs({ + detail: initialDetail, + onBack, +}: MeetingDetailTabsProps): React.ReactElement { + const { t } = useI18n() + const [detail, setDetail] = useState(initialDetail) + const [documents, setDocuments] = useState(initialDetail.documents) + const [templates, setTemplates] = useState([]) + const [tabIndex, setTabIndex] = useState(0) + const [dialogOpen, setDialogOpen] = useState(false) + const [generating, setGenerating] = useState(false) + const [editingTitle, setEditingTitle] = useState(false) + const [titleDraft, setTitleDraft] = useState(detail.title ?? '') + const autoSaveTimers = useRef>>({}) + + // 세그먼트 파싱 (rawTranscript 기반) + const segments = parseSegments(detail.rawTranscript, detail.editedTranscript) + + // 템플릿 로드 + useEffect(() => { + window.electronAPI.meetingDocTemplate.getAll().then((resp) => { + if (resp.success) setTemplates(resp.data) + }) + }, []) + + // ── 제목 저장 ── + const handleSaveTitle = useCallback(async () => { + if (!titleDraft.trim()) return + await window.electronAPI.meetingMode.updateTitle({ + sessionId: detail.id, + title: titleDraft.trim(), + }) + setDetail((prev) => ({ ...prev, title: titleDraft.trim() })) + setEditingTitle(false) + }, [detail.id, titleDraft]) + + // ── 세그먼트 편집 ── + const handleEditSegment = useCallback( + async (segmentId: string, newText: string) => { + await window.electronAPI.meetingMode.editSegment({ + sessionId: detail.id, + segmentId, + text: newText, + }) + // 로컬 editedTranscript 갱신 (단순 반영) + setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript })) + }, + [detail.id], + ) + + // ── 전사 저장 ── + const handleSaveTranscript = useCallback( + async (editedTranscript: string) => { + await window.electronAPI.meetingMode.updateTranscript({ + sessionId: detail.id, + editedTranscript, + }) + setDetail((prev) => ({ ...prev, editedTranscript })) + }, + [detail.id], + ) + + // ── 문서 내용 자동저장 ── + const handleDocContentChange = useCallback( + (docId: string, content: string) => { + if (autoSaveTimers.current[docId]) clearTimeout(autoSaveTimers.current[docId]) + autoSaveTimers.current[docId] = setTimeout(async () => { + await window.electronAPI.meetingMode.updateDocument({ + documentId: docId, + content, + }) + setDocuments((prev) => + prev.map((d) => (d.id === docId ? { ...d, content } : d)), + ) + }, AUTOSAVE_DELAY) + }, + [], + ) + + // ── 문서 내보내기 ── + const handleExportDocument = useCallback( + async (docId: string, format: MeetingExportFormat) => { + await window.electronAPI.meetingMode.exportDocument({ + documentId: docId, + format, + }) + }, + [], + ) + + // ── 문서 삭제 ── + const handleDeleteDocument = useCallback( + async (docId: string) => { + await window.electronAPI.meetingMode.deleteDocument({ documentId: docId }) + setDocuments((prev) => { + const docTabIndex = prev.findIndex((d) => d.id === docId) + 1 // +1: 전사 탭 + setTabIndex((currentTab) => { + if (currentTab === docTabIndex) return 0 + if (currentTab > docTabIndex) return currentTab - 1 + return currentTab + }) + return prev.filter((d) => d.id !== docId) + }) + }, + [], + ) + + // ── 문서 생성 ── + const handleGenerate = useCallback( + async (templateId: string, customPrompt?: string, customTitle?: string) => { + setGenerating(true) + const resp = await window.electronAPI.meetingMode.generateDocument({ + sessionId: detail.id, + templateId, + customPrompt, + customTitle, + }) + setGenerating(false) + if (resp.success) { + const newDoc = resp.data.document + setDocuments((prev) => { + // 새 문서 탭으로 이동: 전사(0) + 기존문서수 + 1 + setTabIndex(prev.length + 1) + return [...prev, newDoc] + }) + setDialogOpen(false) + } + }, + [detail.id], + ) + + // 탭 수: 전사(1) + 문서들 + [+] 버튼(1) + const totalTabCount = 1 + documents.length + 1 + + return ( + + {/* 헤더 */} + + + + + + {editingTitle ? ( + + setTitleDraft(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()} + sx={{ + flex: 1, + '& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size }, + }} + autoFocus + /> + + + + + ) : ( + <> + + {detail.title ?? t('meeting.untitled')} + + + { setEditingTitle(true); setTitleDraft(detail.title ?? '') }} + > + + + + + )} + + + {/* 탭 바 */} + + { + // 마지막 탭([+])은 다이얼로그 열기 + if (v === totalTabCount - 1) { + setDialogOpen(true) + return + } + setTabIndex(v) + }} + sx={{ + minHeight: 40, + '& .MuiTab-root': { + fontFamily: d3roFontMono, + fontSize: d3roTypo.engrave.size, + minHeight: 40, + py: 0, + color: d3roPalette.text.inactive, + textTransform: 'uppercase', + letterSpacing: '1px', + '&.Mui-selected': { color: d3roPalette.accent.amber }, + }, + '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.amber, height: 2 }, + }} + > + + {documents.map((doc) => ( + + ))} + } + label={t('meeting.addDocument')} + iconPosition="start" + sx={{ minWidth: 120 }} + /> + + + + {/* 탭 콘텐츠 */} + + {tabIndex === 0 && ( + + )} + {documents.map((doc, idx) => + tabIndex === idx + 1 ? ( + handleDocContentChange(doc.id, content)} + onExport={(format) => handleExportDocument(doc.id, format)} + onDelete={() => handleDeleteDocument(doc.id)} + /> + ) : null, + )} + + + {/* 문서 생성 다이얼로그 */} + setDialogOpen(false)} + onGenerate={handleGenerate} + templates={templates} + generating={generating} + /> + + ) +} diff --git a/src/renderer/components/meeting/TranscriptTab.tsx b/src/renderer/components/meeting/TranscriptTab.tsx new file mode 100644 index 0000000..3a9541a --- /dev/null +++ b/src/renderer/components/meeting/TranscriptTab.tsx @@ -0,0 +1,173 @@ +// src/renderer/components/meeting/TranscriptTab.tsx +// Phase 14.5: 전사 편집 탭 (상세 페이지용) + +import { useState, useCallback } from 'react' +import { Box, Switch, FormControlLabel } from '@mui/material' +import { EditableSegment } from './EditableSegment' +import { PhysicalButton } from '../ds/PhysicalButton' +import { PhosphorText } from '../ds/PhosphorText' +import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme' +import { useI18n } from '../../i18n' +import FileDownloadIcon from '@mui/icons-material/FileDownload' + +interface TranscriptTabProps { + sessionId: string + segments: Array<{ id: string; timestamp: number; text: string; edited: boolean }> + rawTranscript: string | null + editedTranscript: string | null + memos: Array<{ id: string; timestampMs: number; content: string }> + onEditSegment: (segmentId: string, newText: string) => void + onSaveTranscript: (editedTranscript: string) => void +} + +type TimelineItem = + | { kind: 'segment'; id: string; timestamp: number; text: string; edited: boolean } + | { kind: 'memo'; id: string; timestamp: number; content: string } + +export function TranscriptTab({ + sessionId: _sessionId, + segments, + rawTranscript, + editedTranscript, + memos, + onEditSegment, + onSaveTranscript, +}: TranscriptTabProps): React.ReactElement { + const { t } = useI18n() + const [showEdited, setShowEdited] = useState(true) + + const handleDownloadTxt = useCallback(() => { + const content = showEdited && editedTranscript ? editedTranscript : (rawTranscript ?? '') + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'transcript.txt' + a.click() + URL.revokeObjectURL(url) + }, [showEdited, editedTranscript, rawTranscript]) + + // 세그먼트 + 메모를 타임스탬프 순으로 인터리브 + const timeline: TimelineItem[] = [ + ...segments.map((s) => ({ + kind: 'segment' as const, + id: s.id, + timestamp: s.timestamp, + text: s.text, + edited: s.edited, + })), + ...memos.map((m) => ({ + kind: 'memo' as const, + id: m.id, + timestamp: m.timestampMs, + content: m.content, + })), + ].sort((a, b) => a.timestamp - b.timestamp) + + return ( + + {/* 상단 컨트롤 */} + + { + setShowEdited(e.target.checked) + if (!e.target.checked && rawTranscript) { + onSaveTranscript(rawTranscript) + } + }} + sx={{ + '& .MuiSwitch-thumb': { bgcolor: d3roPalette.accent.amber }, + '& .Mui-checked + .MuiSwitch-track': { bgcolor: d3roPalette.accent.amberDim }, + }} + /> + } + label={ + + {showEdited ? t('meeting.editedText') : t('meeting.originalText')} + + } + /> + + + {t('meeting.downloadTranscript')} + + + + {/* 타임라인 스크롤 영역 */} + + {timeline.length === 0 ? ( + + {t('meeting.noSessions')} + + ) : ( + timeline.map((item) => { + if (item.kind === 'segment') { + return ( + + ) + } + // memo + return ( + + + {`[M ${String(Math.floor(item.timestamp / 60000)).padStart(2, '0')}:${String(Math.floor((item.timestamp % 60000) / 1000)).padStart(2, '0')}]`} + + + {item.content} + + + ) + }) + )} + + + ) +} diff --git a/src/renderer/i18n/de.json b/src/renderer/i18n/de.json index 76e338e..f47c1c7 100644 --- a/src/renderer/i18n/de.json +++ b/src/renderer/i18n/de.json @@ -229,5 +229,29 @@ "meeting.processingStep.generating": "Protokoll mit LLM erstellen...", "meeting.processingStep.parsing": "Protokoll analysieren...", "meeting.processingStep.saving": "Speichern...", - "meeting.processingStep.notifying": "Benachrichtigung senden..." + "meeting.processingStep.notifying": "Benachrichtigung senden...", + "meeting.transcriptTab": "Transkript", + "meeting.addDocument": "Dokument hinzufügen", + "meeting.selectTemplate": "Vorlage auswählen", + "meeting.generate": "Generieren", + "meeting.generating": "Dokument wird generiert...", + "meeting.editMode": "Bearbeiten", + "meeting.previewMode": "Vorschau", + "meeting.modified": "Geändert", + "meeting.originalText": "Original", + "meeting.editedText": "Bearbeitet", + "meeting.exportDocx": "DOCX exportieren", + "meeting.exportTxt": "TXT exportieren", + "meeting.exportFormat": "Exportieren", + "meeting.customPrompt": "Benutzerdefinierter Prompt", + "meeting.templateName": "Vorlagenname", + "meeting.templatePrompt": "KI-Prompt", + "meeting.templateSave": "Vorlage speichern", + "meeting.documentDeleted": "Dokument gelöscht", + "meeting.autoSaved": "Automatisch gespeichert", + "meeting.minutesTemplate": "Besprechungsprotokoll", + "meeting.reportTemplate": "Bericht", + "meeting.ideaNoteTemplate": "Ideennotiz", + "meeting.customTemplate": "Benutzerdefiniert", + "meeting.downloadTranscript": "Transkript herunterladen" } diff --git a/src/renderer/i18n/en.json b/src/renderer/i18n/en.json index 4e4c894..afaa0bc 100644 --- a/src/renderer/i18n/en.json +++ b/src/renderer/i18n/en.json @@ -429,5 +429,29 @@ "meeting.processingStep.generating": "Generating minutes with LLM...", "meeting.processingStep.parsing": "Parsing minutes...", "meeting.processingStep.saving": "Saving...", - "meeting.processingStep.notifying": "Sending notification..." + "meeting.processingStep.notifying": "Sending notification...", + "meeting.transcriptTab": "Transcript", + "meeting.addDocument": "Add Document", + "meeting.selectTemplate": "Select Template", + "meeting.generate": "Generate", + "meeting.generating": "Generating document...", + "meeting.editMode": "Edit", + "meeting.previewMode": "Preview", + "meeting.modified": "Modified", + "meeting.originalText": "Original", + "meeting.editedText": "Edited", + "meeting.exportDocx": "Export DOCX", + "meeting.exportTxt": "Export TXT", + "meeting.exportFormat": "Export", + "meeting.customPrompt": "Custom Prompt", + "meeting.templateName": "Template Name", + "meeting.templatePrompt": "AI Prompt", + "meeting.templateSave": "Save Template", + "meeting.documentDeleted": "Document deleted", + "meeting.autoSaved": "Auto-saved", + "meeting.minutesTemplate": "Meeting Minutes", + "meeting.reportTemplate": "Report", + "meeting.ideaNoteTemplate": "Idea Note", + "meeting.customTemplate": "Custom", + "meeting.downloadTranscript": "Download Transcript" } diff --git a/src/renderer/i18n/es.json b/src/renderer/i18n/es.json index 15d1cca..feef480 100644 --- a/src/renderer/i18n/es.json +++ b/src/renderer/i18n/es.json @@ -229,5 +229,29 @@ "meeting.processingStep.generating": "Generando actas con LLM...", "meeting.processingStep.parsing": "Analizando actas...", "meeting.processingStep.saving": "Guardando...", - "meeting.processingStep.notifying": "Enviando notificación..." + "meeting.processingStep.notifying": "Enviando notificación...", + "meeting.transcriptTab": "Transcripción", + "meeting.addDocument": "Agregar documento", + "meeting.selectTemplate": "Seleccionar plantilla", + "meeting.generate": "Generar", + "meeting.generating": "Generando documento...", + "meeting.editMode": "Editar", + "meeting.previewMode": "Vista previa", + "meeting.modified": "Modificado", + "meeting.originalText": "Original", + "meeting.editedText": "Editado", + "meeting.exportDocx": "Exportar DOCX", + "meeting.exportTxt": "Exportar TXT", + "meeting.exportFormat": "Exportar", + "meeting.customPrompt": "Prompt personalizado", + "meeting.templateName": "Nombre de plantilla", + "meeting.templatePrompt": "Prompt de IA", + "meeting.templateSave": "Guardar plantilla", + "meeting.documentDeleted": "Documento eliminado", + "meeting.autoSaved": "Guardado automáticamente", + "meeting.minutesTemplate": "Actas de reunión", + "meeting.reportTemplate": "Informe", + "meeting.ideaNoteTemplate": "Nota de ideas", + "meeting.customTemplate": "Personalizado", + "meeting.downloadTranscript": "Descargar transcripción" } diff --git a/src/renderer/i18n/fr.json b/src/renderer/i18n/fr.json index f6ea433..6abd057 100644 --- a/src/renderer/i18n/fr.json +++ b/src/renderer/i18n/fr.json @@ -229,5 +229,29 @@ "meeting.processingStep.generating": "Génération du compte-rendu avec LLM...", "meeting.processingStep.parsing": "Analyse du compte-rendu...", "meeting.processingStep.saving": "Enregistrement...", - "meeting.processingStep.notifying": "Envoi de la notification..." + "meeting.processingStep.notifying": "Envoi de la notification...", + "meeting.transcriptTab": "Transcription", + "meeting.addDocument": "Ajouter un document", + "meeting.selectTemplate": "Choisir un modèle", + "meeting.generate": "Générer", + "meeting.generating": "Génération du document...", + "meeting.editMode": "Éditer", + "meeting.previewMode": "Aperçu", + "meeting.modified": "Modifié", + "meeting.originalText": "Original", + "meeting.editedText": "Édité", + "meeting.exportDocx": "Exporter DOCX", + "meeting.exportTxt": "Exporter TXT", + "meeting.exportFormat": "Exporter", + "meeting.customPrompt": "Invite personnalisée", + "meeting.templateName": "Nom du modèle", + "meeting.templatePrompt": "Invite IA", + "meeting.templateSave": "Sauvegarder le modèle", + "meeting.documentDeleted": "Document supprimé", + "meeting.autoSaved": "Sauvegarde automatique", + "meeting.minutesTemplate": "Compte-rendu", + "meeting.reportTemplate": "Rapport", + "meeting.ideaNoteTemplate": "Note d'idées", + "meeting.customTemplate": "Personnalisé", + "meeting.downloadTranscript": "Télécharger la transcription" } diff --git a/src/renderer/i18n/ja.json b/src/renderer/i18n/ja.json index 545ec80..455adca 100644 --- a/src/renderer/i18n/ja.json +++ b/src/renderer/i18n/ja.json @@ -229,5 +229,29 @@ "meeting.processingStep.generating": "LLMで議事録を生成中...", "meeting.processingStep.parsing": "議事録を解析中...", "meeting.processingStep.saving": "保存中...", - "meeting.processingStep.notifying": "通知を送信中..." + "meeting.processingStep.notifying": "通知を送信中...", + "meeting.transcriptTab": "文字起こし", + "meeting.addDocument": "ドキュメント追加", + "meeting.selectTemplate": "テンプレートを選択", + "meeting.generate": "生成", + "meeting.generating": "ドキュメントを生成中...", + "meeting.editMode": "編集", + "meeting.previewMode": "プレビュー", + "meeting.modified": "編集済み", + "meeting.originalText": "原文", + "meeting.editedText": "編集版", + "meeting.exportDocx": "DOCXエクスポート", + "meeting.exportTxt": "TXTエクスポート", + "meeting.exportFormat": "エクスポート", + "meeting.customPrompt": "カスタムプロンプト", + "meeting.templateName": "テンプレート名", + "meeting.templatePrompt": "AIプロンプト", + "meeting.templateSave": "テンプレートを保存", + "meeting.documentDeleted": "ドキュメントが削除されました", + "meeting.autoSaved": "自動保存済み", + "meeting.minutesTemplate": "議事録", + "meeting.reportTemplate": "レポート", + "meeting.ideaNoteTemplate": "アイデアノート", + "meeting.customTemplate": "カスタム", + "meeting.downloadTranscript": "文字起こしをダウンロード" } diff --git a/src/renderer/i18n/ko.json b/src/renderer/i18n/ko.json index ec34515..fddadff 100644 --- a/src/renderer/i18n/ko.json +++ b/src/renderer/i18n/ko.json @@ -429,5 +429,29 @@ "meeting.processingStep.generating": "LLM 회의록 생성 중...", "meeting.processingStep.parsing": "회의록 파싱 중...", "meeting.processingStep.saving": "저장 중...", - "meeting.processingStep.notifying": "완료 알림 중..." + "meeting.processingStep.notifying": "완료 알림 중...", + "meeting.transcriptTab": "전사", + "meeting.addDocument": "문서 추가", + "meeting.selectTemplate": "템플릿 선택", + "meeting.generate": "생성", + "meeting.generating": "문서 생성 중...", + "meeting.editMode": "편집", + "meeting.previewMode": "미리보기", + "meeting.modified": "수정됨", + "meeting.originalText": "원본 보기", + "meeting.editedText": "수정본 보기", + "meeting.exportDocx": "DOCX 내보내기", + "meeting.exportTxt": "TXT 내보내기", + "meeting.exportFormat": "내보내기", + "meeting.customPrompt": "커스텀 프롬프트", + "meeting.templateName": "템플릿 이름", + "meeting.templatePrompt": "AI 프롬프트", + "meeting.templateSave": "템플릿 저장", + "meeting.documentDeleted": "문서가 삭제되었습니다", + "meeting.autoSaved": "자동 저장됨", + "meeting.minutesTemplate": "회의록", + "meeting.reportTemplate": "보고서", + "meeting.ideaNoteTemplate": "아이디어 노트", + "meeting.customTemplate": "커스텀", + "meeting.downloadTranscript": "전사 다운로드" } diff --git a/src/renderer/i18n/pt.json b/src/renderer/i18n/pt.json index bee0cf4..c0720dc 100644 --- a/src/renderer/i18n/pt.json +++ b/src/renderer/i18n/pt.json @@ -229,5 +229,29 @@ "meeting.processingStep.generating": "Gerando ata com LLM...", "meeting.processingStep.parsing": "Analisando ata...", "meeting.processingStep.saving": "Salvando...", - "meeting.processingStep.notifying": "Enviando notificação..." + "meeting.processingStep.notifying": "Enviando notificação...", + "meeting.transcriptTab": "Transcrição", + "meeting.addDocument": "Adicionar documento", + "meeting.selectTemplate": "Selecionar modelo", + "meeting.generate": "Gerar", + "meeting.generating": "Gerando documento...", + "meeting.editMode": "Editar", + "meeting.previewMode": "Visualizar", + "meeting.modified": "Modificado", + "meeting.originalText": "Original", + "meeting.editedText": "Editado", + "meeting.exportDocx": "Exportar DOCX", + "meeting.exportTxt": "Exportar TXT", + "meeting.exportFormat": "Exportar", + "meeting.customPrompt": "Prompt personalizado", + "meeting.templateName": "Nome do modelo", + "meeting.templatePrompt": "Prompt de IA", + "meeting.templateSave": "Salvar modelo", + "meeting.documentDeleted": "Documento excluído", + "meeting.autoSaved": "Salvo automaticamente", + "meeting.minutesTemplate": "Ata de reunião", + "meeting.reportTemplate": "Relatório", + "meeting.ideaNoteTemplate": "Nota de ideias", + "meeting.customTemplate": "Personalizado", + "meeting.downloadTranscript": "Baixar transcrição" } diff --git a/src/renderer/i18n/ru.json b/src/renderer/i18n/ru.json index ba4495f..c97fef5 100644 --- a/src/renderer/i18n/ru.json +++ b/src/renderer/i18n/ru.json @@ -229,5 +229,29 @@ "meeting.processingStep.generating": "Создание протокола с помощью LLM...", "meeting.processingStep.parsing": "Разбор протокола...", "meeting.processingStep.saving": "Сохранение...", - "meeting.processingStep.notifying": "Отправка уведомления..." + "meeting.processingStep.notifying": "Отправка уведомления...", + "meeting.transcriptTab": "Транскрипция", + "meeting.addDocument": "Добавить документ", + "meeting.selectTemplate": "Выбрать шаблон", + "meeting.generate": "Создать", + "meeting.generating": "Создание документа...", + "meeting.editMode": "Редактировать", + "meeting.previewMode": "Предпросмотр", + "meeting.modified": "Изменено", + "meeting.originalText": "Оригинал", + "meeting.editedText": "Отредактировано", + "meeting.exportDocx": "Экспорт DOCX", + "meeting.exportTxt": "Экспорт TXT", + "meeting.exportFormat": "Экспорт", + "meeting.customPrompt": "Пользовательский запрос", + "meeting.templateName": "Название шаблона", + "meeting.templatePrompt": "ИИ-запрос", + "meeting.templateSave": "Сохранить шаблон", + "meeting.documentDeleted": "Документ удалён", + "meeting.autoSaved": "Автосохранение", + "meeting.minutesTemplate": "Протокол встречи", + "meeting.reportTemplate": "Отчёт", + "meeting.ideaNoteTemplate": "Заметка с идеями", + "meeting.customTemplate": "Пользовательский", + "meeting.downloadTranscript": "Скачать транскрипцию" } diff --git a/src/renderer/i18n/th.json b/src/renderer/i18n/th.json index cc71563..5fb8a91 100644 --- a/src/renderer/i18n/th.json +++ b/src/renderer/i18n/th.json @@ -229,5 +229,29 @@ "meeting.processingStep.generating": "กำลังสร้างรายงานด้วย LLM...", "meeting.processingStep.parsing": "กำลังวิเคราะห์รายงาน...", "meeting.processingStep.saving": "กำลังบันทึก...", - "meeting.processingStep.notifying": "กำลังส่งการแจ้งเตือน..." + "meeting.processingStep.notifying": "กำลังส่งการแจ้งเตือน...", + "meeting.transcriptTab": "การถอดความ", + "meeting.addDocument": "เพิ่มเอกสาร", + "meeting.selectTemplate": "เลือกแม่แบบ", + "meeting.generate": "สร้าง", + "meeting.generating": "กำลังสร้างเอกสาร...", + "meeting.editMode": "แก้ไข", + "meeting.previewMode": "ดูตัวอย่าง", + "meeting.modified": "แก้ไขแล้ว", + "meeting.originalText": "ต้นฉบับ", + "meeting.editedText": "แก้ไขแล้ว", + "meeting.exportDocx": "ส่งออก DOCX", + "meeting.exportTxt": "ส่งออก TXT", + "meeting.exportFormat": "ส่งออก", + "meeting.customPrompt": "พรอมต์กำหนดเอง", + "meeting.templateName": "ชื่อแม่แบบ", + "meeting.templatePrompt": "พรอมต์ AI", + "meeting.templateSave": "บันทึกแม่แบบ", + "meeting.documentDeleted": "ลบเอกสารแล้ว", + "meeting.autoSaved": "บันทึกอัตโนมัติ", + "meeting.minutesTemplate": "รายงานการประชุม", + "meeting.reportTemplate": "รายงาน", + "meeting.ideaNoteTemplate": "บันทึกไอเดีย", + "meeting.customTemplate": "กำหนดเอง", + "meeting.downloadTranscript": "ดาวน์โหลดการถอดความ" } diff --git a/src/renderer/i18n/vi.json b/src/renderer/i18n/vi.json index 1a5c80f..7f2f84b 100644 --- a/src/renderer/i18n/vi.json +++ b/src/renderer/i18n/vi.json @@ -229,5 +229,29 @@ "meeting.processingStep.generating": "Đang tạo biên bản với LLM...", "meeting.processingStep.parsing": "Đang phân tích biên bản...", "meeting.processingStep.saving": "Đang lưu...", - "meeting.processingStep.notifying": "Đang gửi thông báo..." + "meeting.processingStep.notifying": "Đang gửi thông báo...", + "meeting.transcriptTab": "Bản ghi", + "meeting.addDocument": "Thêm tài liệu", + "meeting.selectTemplate": "Chọn mẫu", + "meeting.generate": "Tạo", + "meeting.generating": "Đang tạo tài liệu...", + "meeting.editMode": "Chỉnh sửa", + "meeting.previewMode": "Xem trước", + "meeting.modified": "Đã chỉnh sửa", + "meeting.originalText": "Bản gốc", + "meeting.editedText": "Đã chỉnh sửa", + "meeting.exportDocx": "Xuất DOCX", + "meeting.exportTxt": "Xuất TXT", + "meeting.exportFormat": "Xuất", + "meeting.customPrompt": "Lệnh tùy chỉnh", + "meeting.templateName": "Tên mẫu", + "meeting.templatePrompt": "Lệnh AI", + "meeting.templateSave": "Lưu mẫu", + "meeting.documentDeleted": "Đã xóa tài liệu", + "meeting.autoSaved": "Đã lưu tự động", + "meeting.minutesTemplate": "Biên bản họp", + "meeting.reportTemplate": "Báo cáo", + "meeting.ideaNoteTemplate": "Ghi chú ý tưởng", + "meeting.customTemplate": "Tùy chỉnh", + "meeting.downloadTranscript": "Tải bản ghi" } diff --git a/src/renderer/i18n/zh-TW.json b/src/renderer/i18n/zh-TW.json index d9786e1..b61048c 100644 --- a/src/renderer/i18n/zh-TW.json +++ b/src/renderer/i18n/zh-TW.json @@ -229,5 +229,29 @@ "meeting.processingStep.generating": "正在以 LLM 產生會議記錄...", "meeting.processingStep.parsing": "正在解析會議記錄...", "meeting.processingStep.saving": "正在儲存...", - "meeting.processingStep.notifying": "正在發送通知..." + "meeting.processingStep.notifying": "正在發送通知...", + "meeting.transcriptTab": "轉錄", + "meeting.addDocument": "新增文件", + "meeting.selectTemplate": "選擇範本", + "meeting.generate": "生成", + "meeting.generating": "正在生成文件...", + "meeting.editMode": "編輯", + "meeting.previewMode": "預覽", + "meeting.modified": "已修改", + "meeting.originalText": "原文", + "meeting.editedText": "編輯版", + "meeting.exportDocx": "匯出 DOCX", + "meeting.exportTxt": "匯出 TXT", + "meeting.exportFormat": "匯出", + "meeting.customPrompt": "自訂提示", + "meeting.templateName": "範本名稱", + "meeting.templatePrompt": "AI 提示", + "meeting.templateSave": "儲存範本", + "meeting.documentDeleted": "文件已刪除", + "meeting.autoSaved": "已自動儲存", + "meeting.minutesTemplate": "會議記錄", + "meeting.reportTemplate": "報告", + "meeting.ideaNoteTemplate": "創意筆記", + "meeting.customTemplate": "自訂", + "meeting.downloadTranscript": "下載轉錄" } diff --git a/src/renderer/i18n/zh.json b/src/renderer/i18n/zh.json index 0ae6cbd..5f15571 100644 --- a/src/renderer/i18n/zh.json +++ b/src/renderer/i18n/zh.json @@ -229,5 +229,29 @@ "meeting.processingStep.generating": "正在用 LLM 生成会议纪要...", "meeting.processingStep.parsing": "正在解析会议纪要...", "meeting.processingStep.saving": "正在保存...", - "meeting.processingStep.notifying": "正在发送通知..." + "meeting.processingStep.notifying": "正在发送通知...", + "meeting.transcriptTab": "转录", + "meeting.addDocument": "添加文档", + "meeting.selectTemplate": "选择模板", + "meeting.generate": "生成", + "meeting.generating": "正在生成文档...", + "meeting.editMode": "编辑", + "meeting.previewMode": "预览", + "meeting.modified": "已修改", + "meeting.originalText": "原文", + "meeting.editedText": "编辑版", + "meeting.exportDocx": "导出 DOCX", + "meeting.exportTxt": "导出 TXT", + "meeting.exportFormat": "导出", + "meeting.customPrompt": "自定义提示", + "meeting.templateName": "模板名称", + "meeting.templatePrompt": "AI 提示", + "meeting.templateSave": "保存模板", + "meeting.documentDeleted": "文档已删除", + "meeting.autoSaved": "已自动保存", + "meeting.minutesTemplate": "会议记录", + "meeting.reportTemplate": "报告", + "meeting.ideaNoteTemplate": "创意笔记", + "meeting.customTemplate": "自定义", + "meeting.downloadTranscript": "下载转录" } diff --git a/src/renderer/pages/MeetingModePage.tsx b/src/renderer/pages/MeetingModePage.tsx index 9cb1873..1b03ea4 100644 --- a/src/renderer/pages/MeetingModePage.tsx +++ b/src/renderer/pages/MeetingModePage.tsx @@ -4,22 +4,15 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { Box, - TextField, - IconButton, - Tooltip, LinearProgress, - Typography, + TextField, } from '@mui/material' import AddIcon from '@mui/icons-material/Add' import StopIcon from '@mui/icons-material/Stop' -import ArrowBackIcon from '@mui/icons-material/ArrowBack' -import DeleteIcon from '@mui/icons-material/Delete' -import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf' -import DescriptionIcon from '@mui/icons-material/Description' -import EditIcon from '@mui/icons-material/Edit' -import CheckIcon from '@mui/icons-material/Check' import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord' import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '../components/ds' +import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs' +import { EditableSegment } from '../components/meeting/EditableSegment' import { PageHeader, EmptyStateCard } from '../components/shared' import { d3roPalette, d3roFontMono, d3roTypo } from '../theme' import { useI18n } from '../i18n' @@ -57,8 +50,6 @@ export function MeetingModePage(): React.ReactElement { // 상세 뷰 상태 const [detail, setDetail] = useState(null) - const [editingTitle, setEditingTitle] = useState(false) - const [titleDraft, setTitleDraft] = useState('') // ── 세션 목록 로드 ── const loadSessions = useCallback(async () => { @@ -183,32 +174,6 @@ export function MeetingModePage(): React.ReactElement { } }, []) - const handleDelete = useCallback(async () => { - if (!detail) return - await window.electronAPI.meetingMode.deleteSession({ sessionId: detail.id }) - setDetail(null) - setView('list') - }, [detail]) - - const handleExportPdf = useCallback(async () => { - if (!detail) return - await window.electronAPI.meetingMode.exportPdf({ sessionId: detail.id }) - }, [detail]) - - const handleExportMarkdown = useCallback(async () => { - if (!detail) return - await window.electronAPI.meetingMode.exportMarkdown({ sessionId: detail.id }) - }, [detail]) - - const handleSaveTitle = useCallback(async () => { - if (!detail || !titleDraft.trim()) return - await window.electronAPI.meetingMode.updateTitle({ - sessionId: detail.id, - title: titleDraft.trim(), - }) - setDetail({ ...detail, title: titleDraft.trim() }) - setEditingTitle(false) - }, [detail, titleDraft]) // ── 유틸 ── const formatTime = (ms: number): string => { @@ -355,17 +320,14 @@ export function MeetingModePage(): React.ReactElement { }} > {segments.map((seg) => ( - - - [{formatTime(seg.timestamp - (recordingStartRef.current || seg.timestamp))}] - - - {seg.text} - - + { /* 녹음 중 편집 비활성 */ }} + /> ))} @@ -445,156 +407,11 @@ export function MeetingModePage(): React.ReactElement { // ── 렌더: 상세 뷰 ── if (view === 'detail' && detail) { - const durationMin = detail.durationMs ? Math.round(detail.durationMs / 60000) : 0 - return ( - - {/* 헤더 */} - - { setDetail(null); setView('list') }}> - - - {editingTitle ? ( - - setTitleDraft(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()} - sx={{ flex: 1 }} - autoFocus - /> - - - - - ) : ( - <> - - {detail.title ?? t('meeting.untitled')} - - - { setEditingTitle(true); setTitleDraft(detail.title ?? '') }} - > - - - - - )} - - - {/* 메타 */} - - {formatDate(detail.startedAt)} - {detail.endedAt ? ` ~ ${new Date(detail.endedAt).getHours()}:${String(new Date(detail.endedAt).getMinutes()).padStart(2, '0')}` : ''} - {' · '} - {t('meeting.duration', { minutes: durationMin })} - - - {/* 회의록 본문 */} - {detail.minutes && ( - <> - {/* 요약 */} - - {t('meeting.summary')} - - {detail.minutes.summary} - - - - {/* 결정사항 */} - {detail.minutes.decisions.length > 0 && ( - - {t('meeting.decisions')} - {detail.minutes.decisions.map((d, i) => ( - - {'• '}{d} - - ))} - - )} - - {/* 할 일 */} - {detail.minutes.actionItems.length > 0 && ( - - {t('meeting.actionItems')} - {detail.minutes.actionItems.map((item, i) => ( - - {'☐ '}{item.assignee ? `${item.assignee}: ` : ''}{item.task} - {item.deadline ? ` (${item.deadline})` : ''} - - ))} - - )} - - {/* 타임라인 */} - {detail.minutes.timeline.length > 0 && ( - - {t('meeting.timeline')} - {detail.minutes.timeline.map((tl, i) => ( - - - {tl.time} - - - {tl.type === 'memo' ? '📝 ' : ''}{tl.content} - - - ))} - - )} - - )} - - {/* 에러 메시지 */} - {detail.status === 'error' && detail.errorMessage && ( - - - {t('meeting.error')} - - {detail.errorMessage} - - )} - - {/* 메모 */} - {detail.memos.length > 0 && ( - - {t('meeting.memos')} - {detail.memos.map((m) => ( - - - {formatTime(m.timestampMs)} - - {m.content} - - ))} - - )} - - {/* 액션 버튼 */} - - - - - {t('meeting.exportPdf')} - - - - - - {t('meeting.exportMarkdown')} - - - - - - {t('meeting.delete')} - - - - + { setDetail(null); setView('list') }} + /> ) } diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 275031c..78b983f 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -158,6 +158,13 @@ export enum ErrorCode { MeetingSessionNotFound = 883, MeetingExportFailed = 884, MeetingPdfFailed = 885, + MeetingDocumentNotFound = 886, + MeetingDocGenerationFailed = 887, + MeetingDocExportFailed = 888, + MeetingTranscriptUpdateFailed = 889, + MeetingDocTemplateNotFound = 890, + MeetingDocTemplateBuiltinDelete = 891, + MeetingDocxExportFailed = 892, // === Config (800-849) === ConfigReadFailed = 800, diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 37606c1..0730686 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -317,14 +317,32 @@ export const IPC_CHANNELS = { UPDATE_TITLE: 'meetingMode:updateTitle', EXPORT_PDF: 'meetingMode:exportPdf', EXPORT_MARKDOWN: 'meetingMode:exportMarkdown', + // Phase 14.5: 문서 생성/편집/내보내기 + UPDATE_TRANSCRIPT: 'meetingMode:updateTranscript', + GENERATE_DOCUMENT: 'meetingMode:generateDocument', + GET_DOCUMENTS: 'meetingMode:getDocuments', + UPDATE_DOCUMENT: 'meetingMode:updateDocument', + DELETE_DOCUMENT: 'meetingMode:deleteDocument', + EXPORT_DOCUMENT: 'meetingMode:exportDocument', + EXPORT_TRANSCRIPT: 'meetingMode:exportTranscript', + EDIT_SEGMENT: 'meetingMode:editSegment', // Main → Renderer events STATE_CHANGED: 'meetingMode:stateChanged', SEGMENT: 'meetingMode:segment', PROCESSING_PROGRESS: 'meetingMode:processingProgress', SESSION_COMPLETED: 'meetingMode:sessionCompleted', + DOC_GENERATING_PROGRESS: 'meetingMode:docGeneratingProgress', ERROR: 'meetingMode:error', }, + // ── Phase 14.5: Meeting Document Templates ── + MEETING_DOC_TEMPLATE: { + GET_ALL: 'meetingDocTemplate:getAll', + CREATE: 'meetingDocTemplate:create', + UPDATE: 'meetingDocTemplate:update', + DELETE: 'meetingDocTemplate:delete', + }, + // ── Phase 11: License & Monetization ── LICENSE: { GET_INFO: 'license:getInfo', diff --git a/src/shared/types.ts b/src/shared/types.ts index 403eb1c..497c2c8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1290,9 +1290,11 @@ export interface MeetingSessionSummary { export interface MeetingSessionDetail extends MeetingSessionSummary { rawTranscript: string | null + editedTranscript: string | null minutesMarkdown: string | null minutes: MeetingMinutes | null memos: MeetingMemo[] + documents: MeetingDocument[] sttModel: string | null llmModel: string | null errorMessage: string | null @@ -1358,3 +1360,96 @@ export interface MeetingProcessingProgress { step: MeetingProcessingStep percent: number } + +// ============================================================ +// Phase 14.5: Meeting Document Types +// ============================================================ + +export type MeetingDocTemplateType = 'minutes' | 'report' | 'idea-note' | 'custom' + +export interface MeetingDocTemplate { + id: string + name: string + description: string + templateType: MeetingDocTemplateType + systemPrompt: string + isBuiltin: boolean + createdAt: number + updatedAt: number +} + +export interface MeetingDocument { + id: string + sessionId: string + templateType: MeetingDocTemplateType + title: string + content: string + promptUsed: string | null + llmModel: string | null + llmLatencyMs: number | null + createdAt: number + updatedAt: number +} + +export interface MeetingGenerateDocParams { + sessionId: string + templateId: string + customPrompt?: string + customTitle?: string +} + +export interface MeetingGenerateDocResult { + document: MeetingDocument +} + +export interface MeetingUpdateDocParams { + documentId: string + content: string +} + +export interface MeetingDeleteDocParams { + documentId: string +} + +export interface MeetingGetDocsParams { + sessionId: string +} + +export interface MeetingUpdateTranscriptParams { + sessionId: string + editedTranscript: string +} + +export type MeetingExportFormat = 'md' | 'pdf' | 'txt' | 'docx' + +export interface MeetingExportDocParams { + documentId: string + format: MeetingExportFormat +} + +export interface MeetingExportTranscriptParams { + sessionId: string +} + +export interface MeetingDocGeneratingProgress { + sessionId: string + templateId: string + percent: number +} + +export interface CreateMeetingDocTemplateParams { + name: string + description: string + systemPrompt: string +} + +export interface UpdateMeetingDocTemplateParams { + id: string + name?: string + description?: string + systemPrompt?: string +} + +export interface DeleteMeetingDocTemplateParams { + id: string +}