feat(desktop): move dictionary entries in and out as files

Users could only rebuild their spoken-word dictionary entry by entry. Import
and export now round-trip the whole list, reporting duplicate and invalid
entries per row instead of failing the batch, so a dictionary survives a
reinstall or a move to another machine.
This commit is contained in:
Yun Chan 2026-09-16 23:23:30 +09:00
parent 7953706142
commit 911c9f0229
5 changed files with 625 additions and 15 deletions

View file

@ -1,6 +1,6 @@
// src/main/ipc/dictionary-handlers.ts // src/main/ipc/dictionary-handlers.ts
import { ipcMain } from 'electron' import { ipcMain, dialog } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors' import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors'
import { getDictionaryService } from '../services/DictionaryService' import { getDictionaryService } from '../services/DictionaryService'
@ -9,7 +9,9 @@ import type {
DictionaryAddParams, DictionaryAddParams,
DictionaryUpdateParams, DictionaryUpdateParams,
DictionaryDeleteParams, DictionaryDeleteParams,
DictionarySearchParams DictionarySearchParams,
DictionaryImportParams,
DictionaryExportParams
} from '@d3ro/core/types' } from '@d3ro/core/types'
export function registerDictionaryHandlers(): void { export function registerDictionaryHandlers(): void {
@ -74,4 +76,43 @@ export function registerDictionaryHandlers(): void {
return ipcError(ErrorCode.DBQueryFailed, `Failed to search dictionary: ${message}`) return ipcError(ErrorCode.DBQueryFailed, `Failed to search dictionary: ${message}`)
} }
}) })
ipcMain.handle(IPC_CHANNELS.DICTIONARY.IMPORT, async (_event, params: DictionaryImportParams) => {
try {
let filePath = params.filePath
if (!filePath) {
const picked = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
params.format === 'csv'
? { name: 'CSV', extensions: ['csv'] }
: { name: 'JSON', extensions: ['json'] }
]
})
if (picked.canceled || picked.filePaths.length === 0) {
return ipcSuccess({ imported: 0, skipped: 0, errors: 0 })
}
filePath = picked.filePaths[0]
}
return ipcSuccess(getDictionaryService().importDictionary({ ...params, filePath }))
} catch (error) {
if (error instanceof D3ROError) {
return ipcError(error.code, error.message)
}
const message = error instanceof Error ? error.message : String(error)
return ipcError(ErrorCode.DictionaryImportFailed, `Failed to import dictionary: ${message}`)
}
})
ipcMain.handle(IPC_CHANNELS.DICTIONARY.EXPORT, async (_event, params: DictionaryExportParams) => {
try {
return ipcSuccess(await getDictionaryService().exportDictionary(params))
} catch (error) {
if (error instanceof D3ROError) {
return ipcError(error.code, error.message)
}
const message = error instanceof Error ? error.message : String(error)
return ipcError(ErrorCode.DictionaryExportFailed, `Failed to export dictionary: ${message}`)
}
})
} }

View file

@ -1,12 +1,16 @@
// src/main/services/DictionaryService.ts // src/main/services/DictionaryService.ts
// 사용자 커스텀 단어 사전. 설계서 01/03 IDictionaryService 구현. // 사용자 커스텀 단어 사전. 설계서 01/03 IDictionaryService 구현.
import { eq, like, desc, count, sql } from 'drizzle-orm' import { eq, and, like, desc, count, sql } from 'drizzle-orm'
import path from 'path'
import fs from 'fs'
import { app, dialog } from 'electron'
import { getDatabase } from '../db' import { getDatabase } from '../db'
import { dictionary } from '../db/schema' import { dictionary } from '../db/schema'
import type { Dictionary, NewDictionary } from '../db/schema' import type { Dictionary, NewDictionary } from '../db/schema'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService' import { getCloudSyncService } from './CloudSyncService'
import { getMainWindow } from '../windows/WindowManager'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { import type {
DictionaryEntry, DictionaryEntry,
@ -14,11 +18,100 @@ import type {
DictionaryPage, DictionaryPage,
DictionaryAddParams, DictionaryAddParams,
DictionaryUpdateParams, DictionaryUpdateParams,
DictionarySearchParams DictionarySearchParams,
DictionaryImportParams,
DictionaryImportResult,
DictionaryExportParams
} from '@d3ro/core/types' } from '@d3ro/core/types'
const logger = getLogger('DictionaryService') const logger = getLogger('DictionaryService')
let isShowingDictionarySaveDialog = false
const DICTIONARY_CSV_HEADER = [
'word',
'pronunciation',
'category',
'usageCount',
'createdAt',
'updatedAt'
] as const
function csvCell(value: unknown): string {
const text = value === null || value === undefined ? '' : String(value)
const escaped = text.replace(/"/g, '""')
const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped)
return needsQuotes ? `"${escaped}"` : escaped
}
function parseCsvRows(input: string): string[][] {
const rows: string[][] = []
let row: string[] = []
let field = ''
let inQuotes = false
for (let i = 0; i < input.length; i += 1) {
const char = input[i]
if (inQuotes) {
if (char === '"') {
if (input[i + 1] === '"') {
field += '"'
i += 1
} else {
inQuotes = false
}
} else {
field += char
}
continue
}
if (char === '"') {
inQuotes = true
} else if (char === ',') {
row.push(field)
field = ''
} else if (char === '\n') {
row.push(field)
rows.push(row)
row = []
field = ''
} else if (char === '\r') {
// skip CR; LF terminates the row
} else {
field += char
}
}
if (field.length > 0 || row.length > 0) {
row.push(field)
rows.push(row)
}
return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0))
}
function pickString(source: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = source[key]
if (typeof value === 'string' && value.trim().length > 0) return value.trim()
}
return null
}
function pickNumber(source: Record<string, unknown>, keys: string[]): number | null {
for (const key of keys) {
const value = source[key]
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
}
return null
}
function normalizeCategory(value: string | null): DictionaryEntry['category'] {
if (value === 'auto' || value === 'technical' || value === 'user') return value
return 'user'
}
class DictionaryService { class DictionaryService {
add(params: DictionaryAddParams): DictionaryEntry { add(params: DictionaryAddParams): DictionaryEntry {
const word = params.word.trim() const word = params.word.trim()
@ -160,6 +253,201 @@ class DictionaryService {
return words.map((w) => w.word).join(', ') return words.map((w) => w.word).join(', ')
} }
async exportDictionary(params: DictionaryExportParams): Promise<string> {
const db = getDatabase()
const entries = db
.select()
.from(dictionary)
.orderBy(desc(dictionary.createdAt))
.all()
.map((row) => this._toEntry(row))
const content =
params.format === 'csv' ? this._serializeCsv(entries) : this._serializeJson(entries)
const defaultName = `d3ro-dictionary-${new Date().toISOString().slice(0, 10)}.${params.format}`
if (isShowingDictionarySaveDialog) {
throw new D3ROError(ErrorCode.DictionaryExportFailed, 'Export dialog already active')
}
isShowingDictionarySaveDialog = true
let result: Electron.SaveDialogReturnValue
try {
const mainWindow = getMainWindow()
const dialogOptions = {
defaultPath: path.join(app.getPath('documents'), defaultName),
filters: [
params.format === 'csv'
? { name: 'CSV', extensions: ['csv'] }
: { name: 'JSON', extensions: ['json'] }
]
}
result = mainWindow
? await dialog.showSaveDialog(mainWindow, dialogOptions)
: await dialog.showSaveDialog(dialogOptions)
} finally {
isShowingDictionarySaveDialog = false
}
if (result.canceled || !result.filePath) {
throw new D3ROError(ErrorCode.DictionaryExportFailed, 'Export cancelled')
}
try {
fs.writeFileSync(result.filePath, content, 'utf-8')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new D3ROError(
ErrorCode.DictionaryExportFailed,
`Failed to write dictionary export: ${message}`
)
}
logger.info(`Dictionary exported (${entries.length} entries, ${params.format})`)
return result.filePath
}
importDictionary(params: DictionaryImportParams): DictionaryImportResult {
if (params.format !== 'json' && params.format !== 'csv') {
throw new D3ROError(
ErrorCode.DictionaryImportInvalidFormat,
`Unsupported dictionary format: ${String(params.format)}`
)
}
let raw: string
try {
raw = fs.readFileSync(params.filePath, 'utf-8')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new D3ROError(
ErrorCode.DictionaryImportFailed,
`Failed to read dictionary file: ${message}`
)
}
let parsed: Array<Record<string, unknown>>
try {
parsed = params.format === 'json' ? this._parseJson(raw) : this._parseCsv(raw)
} catch (err) {
if (err instanceof D3ROError) throw err
const message = err instanceof Error ? err.message : String(err)
throw new D3ROError(
ErrorCode.DictionaryImportInvalidFormat,
`Invalid dictionary file: ${message}`
)
}
const db = getDatabase()
const now = Date.now()
const outcome: DictionaryImportResult = { imported: 0, skipped: 0, errors: 0 }
db.transaction((tx) => {
for (const row of parsed) {
const word = pickString(row, ['word'])
if (!word) {
outcome.errors += 1
continue
}
const category = normalizeCategory(pickString(row, ['category']))
const existing = tx
.select({ id: dictionary.id })
.from(dictionary)
.where(and(eq(dictionary.word, word), eq(dictionary.category, category)))
.get()
if (existing) {
outcome.skipped += 1
continue
}
const createdAt = pickNumber(row, ['createdAt', 'created_at'])
const updatedAt = pickNumber(row, ['updatedAt', 'updated_at'])
const usageCount = pickNumber(row, ['usageCount', 'usage_count'])
const lastUsedAt = pickNumber(row, ['lastUsedAt', 'last_used_at'])
const id = crypto.randomUUID()
try {
tx.insert(dictionary)
.values({
id,
word,
pronunciation: pickString(row, ['pronunciation']),
category,
usageCount: usageCount !== null && usageCount >= 0 ? Math.floor(usageCount) : 0,
lastUsedAt,
createdAt: createdAt ?? now,
updatedAt: updatedAt ?? createdAt ?? now
})
.run()
outcome.imported += 1
void getCloudSyncService().pushOne('dictionary', id)
} catch {
outcome.skipped += 1
}
}
})
logger.info(
`Dictionary import: ${outcome.imported} imported, ${outcome.skipped} skipped, ${outcome.errors} errors`
)
return outcome
}
private _serializeJson(entries: DictionaryEntry[]): string {
return JSON.stringify({ entries }, null, 2)
}
private _serializeCsv(entries: DictionaryEntry[]): string {
const lines = [DICTIONARY_CSV_HEADER.join(',')]
for (const entry of entries) {
lines.push(
[
csvCell(entry.word),
csvCell(entry.pronunciation ?? ''),
csvCell(entry.category),
csvCell(entry.usageCount),
csvCell(entry.createdAt),
csvCell(entry.updatedAt)
].join(',')
)
}
return `\uFEFF${lines.join('\r\n')}\r\n`
}
private _parseJson(raw: string): Array<Record<string, unknown>> {
const data: unknown = JSON.parse(raw)
let list: unknown
if (Array.isArray(data)) {
list = data
} else if (
data &&
typeof data === 'object' &&
Array.isArray((data as { entries?: unknown }).entries)
) {
list = (data as { entries: unknown[] }).entries
} else {
throw new Error('expected an array or an object with an "entries" array')
}
return (list as unknown[]).filter(
(item): item is Record<string, unknown> => !!item && typeof item === 'object'
)
}
private _parseCsv(raw: string): Array<Record<string, unknown>> {
const rows = parseCsvRows(raw.replace(/^\uFEFF/, ''))
if (rows.length === 0) {
throw new Error('empty CSV')
}
const header = rows[0].map((cell) => cell.trim())
if (!header.includes('word')) {
throw new Error('missing "word" column')
}
return rows.slice(1).map((cells) => {
const record: Record<string, unknown> = {}
header.forEach((key, index) => {
record[key] = cells[index] ?? ''
})
return record
})
}
dispose(): void { dispose(): void {
logger.info('DictionaryService disposed') logger.info('DictionaryService disposed')
} }

View file

@ -69,6 +69,9 @@ import type {
DictionaryUpdateParams, DictionaryUpdateParams,
DictionaryDeleteParams, DictionaryDeleteParams,
DictionarySearchParams, DictionarySearchParams,
DictionaryImportParams,
DictionaryImportResult,
DictionaryExportParams,
StatsSummary, StatsSummary,
PermissionStatus, PermissionStatus,
// Phase 10 // Phase 10
@ -156,7 +159,6 @@ import type {
MeetingDeleteSessionParams, MeetingDeleteSessionParams,
MeetingExportParams, MeetingExportParams,
MeetingProcessingProgress, MeetingProcessingProgress,
CaptionSegment,
MeetingDocument, MeetingDocument,
MeetingDocTemplate, MeetingDocTemplate,
MeetingGenerateDocParams, MeetingGenerateDocParams,
@ -165,7 +167,7 @@ import type {
MeetingGetDocsParams, MeetingGetDocsParams,
MeetingUpdateTranscriptParams, MeetingUpdateTranscriptParams,
MeetingExportDocParams, MeetingExportDocParams,
MeetingExportFormat, MeetingExportTranscriptParams,
MeetingDocGeneratingProgress, MeetingDocGeneratingProgress,
CreateMeetingDocTemplateParams, CreateMeetingDocTemplateParams,
UpdateMeetingDocTemplateParams, UpdateMeetingDocTemplateParams,
@ -394,7 +396,11 @@ const electronAPI = {
delete: (params: DictionaryDeleteParams) => delete: (params: DictionaryDeleteParams) =>
invoke<void>(IPC_CHANNELS.DICTIONARY.DELETE, params), invoke<void>(IPC_CHANNELS.DICTIONARY.DELETE, params),
search: (params: DictionarySearchParams) => search: (params: DictionarySearchParams) =>
invoke<DictionaryPage>(IPC_CHANNELS.DICTIONARY.SEARCH, params) invoke<DictionaryPage>(IPC_CHANNELS.DICTIONARY.SEARCH, params),
import: (params: DictionaryImportParams) =>
invoke<DictionaryImportResult>(IPC_CHANNELS.DICTIONARY.IMPORT, params),
export: (params: DictionaryExportParams) =>
invoke<string>(IPC_CHANNELS.DICTIONARY.EXPORT, params)
}, },
// ── Stats ────────────────────────────────────────────── // ── Stats ──────────────────────────────────────────────
@ -714,7 +720,7 @@ const electronAPI = {
invoke<void>(IPC_CHANNELS.MEETING_MODE.DELETE_DOCUMENT, params), invoke<void>(IPC_CHANNELS.MEETING_MODE.DELETE_DOCUMENT, params),
exportDocument: (params: MeetingExportDocParams) => exportDocument: (params: MeetingExportDocParams) =>
invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_DOCUMENT, params), invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_DOCUMENT, params),
exportTranscript: (params: { sessionId: string; format: MeetingExportFormat }) => exportTranscript: (params: MeetingExportTranscriptParams) =>
invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_TRANSCRIPT, params), invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_TRANSCRIPT, params),
editSegment: (params: { sessionId: string; segmentId: string; text: string }) => editSegment: (params: { sessionId: string; segmentId: string; text: string }) =>
invoke<void>(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, params), invoke<void>(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, params),

View file

@ -3,7 +3,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react' import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Box, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Tooltip } from '@mui/material' import { Box, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Tooltip } from '@mui/material'
import { Plus, Trash2, Pencil, BookA } from 'lucide-react' import { Plus, Trash2, Pencil, BookA, Download, Upload } from 'lucide-react'
import { MetalCard, PhosphorText, PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' import { MetalCard, PhosphorText, PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds'
import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared' import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
@ -21,10 +21,15 @@ export function DictionaryPage(): React.ReactElement {
const [formPronunciation, setFormPronunciation] = useState('') const [formPronunciation, setFormPronunciation] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const isSavingRef = useRef(false) const isSavingRef = useRef(false)
const [ioBusy, setIoBusy] = useState(false)
const [ioMessage, setIoMessage] = useState<string | null>(null)
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
const result = await window.electronAPI.dictionary.getAll({ search: search || undefined }) const trimmed = search.trim()
const result = trimmed
? await window.electronAPI.dictionary.search({ query: trimmed, page: 0, pageSize: 50 })
: await window.electronAPI.dictionary.getAll({ page: 0, pageSize: 50 })
if (result.success) setData(result.data) if (result.success) setData(result.data)
setLoading(false) setLoading(false)
}, [search]) }, [search])
@ -74,18 +79,112 @@ export function DictionaryPage(): React.ReactElement {
} }
} }
const handleExport = async (format: 'json' | 'csv'): Promise<void> => {
if (ioBusy) return
setIoBusy(true)
setIoMessage(null)
try {
const result = await window.electronAPI.dictionary.export({ format })
setIoMessage(result.success ? t('dictionary.exported') : t('dictionary.exportFailed'))
} finally {
setIoBusy(false)
}
}
const handleImport = async (format: 'json' | 'csv'): Promise<void> => {
if (ioBusy) return
setIoBusy(true)
setIoMessage(null)
try {
const result = await window.electronAPI.dictionary.import({ filePath: '', format })
if (!result.success) {
setIoMessage(t('dictionary.importFailed'))
return
}
const { imported, skipped, errors } = result.data
setIoMessage(
t('dictionary.importedCount', {
imported: String(imported),
skipped: String(skipped),
errors: String(errors),
})
)
await loadData()
} finally {
setIoBusy(false)
}
}
const ioButtonSx = {
p: 0.7,
color: d3roPalette.text.inactive,
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
'&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.hairlineStrong },
} as const
return ( return (
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}> <Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
<PageHeader <PageHeader
title={t('dictionary.title')} title={t('dictionary.title')}
count={t('dictionary.words', { count: data?.total ?? 0 })} count={t('dictionary.words', { count: data?.total ?? 0 })}
action={ action={
<PhysicalButton tone="accent" onClick={openAdd} size="small" trailingIcon={<Plus size={14} />}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
{t('dictionary.add')} <Tooltip title={`${t('dictionary.export')} JSON`}>
</PhysicalButton> <IconButton
size="small"
disabled={ioBusy}
onClick={() => void handleExport('json')}
sx={ioButtonSx}
>
<Download size={15} />
</IconButton>
</Tooltip>
<Tooltip title={`${t('dictionary.export')} CSV`}>
<IconButton
size="small"
disabled={ioBusy}
onClick={() => void handleExport('csv')}
sx={ioButtonSx}
>
<Download size={15} />
</IconButton>
</Tooltip>
<Tooltip title={`${t('dictionary.import')} JSON`}>
<IconButton
size="small"
disabled={ioBusy}
onClick={() => void handleImport('json')}
sx={ioButtonSx}
>
<Upload size={15} />
</IconButton>
</Tooltip>
<Tooltip title={`${t('dictionary.import')} CSV`}>
<IconButton
size="small"
disabled={ioBusy}
onClick={() => void handleImport('csv')}
sx={ioButtonSx}
>
<Upload size={15} />
</IconButton>
</Tooltip>
<PhysicalButton tone="accent" onClick={openAdd} size="small" trailingIcon={<Plus size={14} />}>
{t('dictionary.add')}
</PhysicalButton>
</Box>
} }
/> />
{ioMessage && (
<Box sx={{ mb: 1.5 }}>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
{ioMessage}
</PhosphorText>
</Box>
)}
<SearchInput <SearchInput
placeholder={t('dictionary.search')} placeholder={t('dictionary.search')}
value={search} value={search}
@ -160,8 +259,8 @@ export function DictionaryPage(): React.ReactElement {
<Tooltip title={t('common.delete')}> <Tooltip title={t('common.delete')}>
<IconButton <IconButton
size="small" size="small"
onClick={() => { onClick={async () => {
window.electronAPI.dictionary.delete({ id: entry.id }) await window.electronAPI.dictionary.delete({ id: entry.id })
loadData() loadData()
}} }}
sx={{ sx={{

View file

@ -0,0 +1,176 @@
// tests/main/services/DictionaryIo.test.ts
// DictionaryService import/export — file + dialog mocking
import { describe, it, expect, beforeEach, vi } from 'vitest'
const writeFileSync = vi.fn()
const readFileSync = vi.fn()
const showSaveDialog = vi.fn()
vi.mock('fs', () => ({
default: { writeFileSync, readFileSync },
writeFileSync,
readFileSync
}))
vi.mock('electron', () => ({
app: { getPath: () => '/tmp/d3ro' },
dialog: { showSaveDialog }
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({
getMainWindow: () => null
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
}))
vi.mock('../../../src/main/services/CloudSyncService', () => ({
getCloudSyncService: () => ({ pushOne: vi.fn(async () => undefined) })
}))
interface TxStub {
select: ReturnType<typeof vi.fn>
insert: ReturnType<typeof vi.fn>
}
let existingRow: unknown = null
let txStub: TxStub
const mockDb = {
select: vi.fn(() => ({
from: vi.fn(() => ({
orderBy: vi.fn(() => ({
all: vi.fn(() => [
{
id: 'row-1',
word: 'AI',
pronunciation: '에이아이',
category: 'technical',
usageCount: 3,
lastUsedAt: 111,
createdAt: 100,
updatedAt: 200
}
])
}))
}))
})),
transaction: vi.fn((fn: (tx: TxStub) => void) => fn(txStub))
}
vi.mock('../../../src/main/db', () => ({ getDatabase: () => mockDb }))
type ServiceModule = typeof import('../../../src/main/services/DictionaryService')
let mod: ServiceModule
beforeEach(async () => {
vi.clearAllMocks()
existingRow = null
txStub = {
select: vi.fn(() => ({
from: vi.fn(() => ({ where: vi.fn(() => ({ get: vi.fn(() => existingRow) })) }))
})),
insert: vi.fn(() => ({ values: vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) })) }))
}
vi.resetModules()
mod = await import('../../../src/main/services/DictionaryService')
})
describe('DictionaryService.importDictionary', () => {
it('imports JSON entries with camelCase fields', () => {
readFileSync.mockReturnValue(
JSON.stringify({
entries: [
{ word: '쿠버네티스', pronunciation: '쿠버네티스', category: 'technical', usageCount: 2 }
]
})
)
const result = mod.getDictionaryService().importDictionary({
filePath: '/in/dict.json',
format: 'json'
})
expect(result).toEqual({ imported: 1, skipped: 0, errors: 0 })
expect(txStub.insert).toHaveBeenCalled()
})
it('skips entries that already exist', () => {
existingRow = { id: 'existing' }
readFileSync.mockReturnValue(JSON.stringify({ entries: [{ word: 'AI' }] }))
const result = mod.getDictionaryService().importDictionary({
filePath: '/in/dict.json',
format: 'json'
})
expect(result).toEqual({ imported: 0, skipped: 1, errors: 0 })
})
it('parses CSV exports', () => {
readFileSync.mockReturnValue(
'word,pronunciation,category,usageCount,createdAt,updatedAt\r\n"테스트",,"user",0,100,200\r\n'
)
const result = mod.getDictionaryService().importDictionary({
filePath: '/in/dict.csv',
format: 'csv'
})
expect(result).toEqual({ imported: 1, skipped: 0, errors: 0 })
})
it('rejects a file without a word column', () => {
readFileSync.mockReturnValue('term,category\r\nfoo,user\r\n')
expect(() =>
mod.getDictionaryService().importDictionary({ filePath: '/in/dict.csv', format: 'csv' })
).toThrowError(/Invalid dictionary file/)
})
it('counts invalid JSON entries as errors', () => {
readFileSync.mockReturnValue(JSON.stringify({ entries: [{ pronunciation: 'x' }, { word: 'ok' }] }))
const result = mod.getDictionaryService().importDictionary({
filePath: '/in/dict.json',
format: 'json'
})
expect(result).toEqual({ imported: 1, skipped: 0, errors: 1 })
})
})
describe('DictionaryService.exportDictionary', () => {
it('writes a JSON export and returns the chosen path', async () => {
showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/out/d3ro.json' })
const path = await mod.getDictionaryService().exportDictionary({ format: 'json' })
expect(path).toBe('/out/d3ro.json')
expect(writeFileSync).toHaveBeenCalledTimes(1)
const [writtenPath, content] = writeFileSync.mock.calls[0] as [string, string]
expect(writtenPath).toBe('/out/d3ro.json')
const parsed = JSON.parse(content) as { entries: Array<{ word: string }> }
expect(parsed.entries[0].word).toBe('AI')
})
it('writes CSV with a header row', async () => {
showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/out/d3ro.csv' })
await mod.getDictionaryService().exportDictionary({ format: 'csv' })
const [, content] = writeFileSync.mock.calls[0] as [string, string]
expect(content).toContain('word,pronunciation,category,usageCount,createdAt,updatedAt')
})
it('rejects a cancelled save dialog', async () => {
showSaveDialog.mockResolvedValue({ canceled: true, filePath: undefined })
await expect(
mod.getDictionaryService().exportDictionary({ format: 'json' })
).rejects.toThrowError(/Export cancelled/)
expect(writeFileSync).not.toHaveBeenCalled()
})
})