feat(V2-1b): packages/core 추출 — 공유 타입/에러/채널/유틸 분리
packages/core (@d3ro/core) 신규 생성:
- types.ts, errors.ts, ipc-channels.ts, constants.ts (shared에서 이동)
- utils/meeting-markdown.ts, utils/markdown-to-docx.ts (main/utils에서 이동)
- subpath exports 정의 (./types, ./errors, ./ipc-channels, ./constants,
./utils/meeting-markdown, ./utils/markdown-to-docx)
- src/index.ts barrel export 추가
- docx를 core 자체 dependency로 선언
apps/desktop 연결:
- package.json에 @d3ro/core: '*' dep 추가
- tsconfig.node/web.json paths에 @d3ro/core/* 추가
- electron.vite.config.ts 3개 섹션 alias 추가 (main/preload/renderer)
- externalizeDepsPlugin exclude에 @d3ro/core (workspace 소스 번들 대상)
- vitest.config.ts alias 추가
일괄 치환 (79 파일, 167건):
- @shared/{types,errors,ipc-channels,constants} → @d3ro/core/*
- static/dynamic import + type expression import 모두 포함
- MeetingModeService.ts의 ../utils/* 상대 경로 → @d3ro/core/utils/*
- @shared/theme-vars는 V2-1c 범위로 남김 (WindowManager만 사용)
M1 수정 포함:
- electron.vite.config.ts의 resolve('src/shared') → resolve(__dirname, ...)
CWD 독립적으로 동작하도록 견고화
검증:
- typecheck 통과
- build 통과 (main+preload+renderer)
- dev 런타임 → DB/핫키/Ollama 모두 정상, 기존 데이터 연속성 유지
This commit is contained in:
parent
a4cb4c0805
commit
3b0eb3393b
95 changed files with 315 additions and 212 deletions
31
packages/core/src/utils/markdown-to-docx.ts
Normal file
31
packages/core/src/utils/markdown-to-docx.ts
Normal file
|
|
@ -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<Buffer> {
|
||||
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))
|
||||
}
|
||||
116
packages/core/src/utils/meeting-markdown.ts
Normal file
116
packages/core/src/utils/meeting-markdown.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// packages/core/src/utils/meeting-markdown.ts
|
||||
// Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티
|
||||
|
||||
import type { MeetingMinutes, MeetingSessionDetail } from '../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, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/^\- \[.\] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^\- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^\* (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/\n{2,}/g, '</p><p>')
|
||||
.replace(/^(?!<[h|l|t|p])/gm, '')
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue