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:
parent
7953706142
commit
911c9f0229
5 changed files with 625 additions and 15 deletions
|
|
@ -1,6 +1,6 @@
|
|||
// src/main/ipc/dictionary-handlers.ts
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { ipcMain, dialog } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors'
|
||||
import { getDictionaryService } from '../services/DictionaryService'
|
||||
|
|
@ -9,7 +9,9 @@ import type {
|
|||
DictionaryAddParams,
|
||||
DictionaryUpdateParams,
|
||||
DictionaryDeleteParams,
|
||||
DictionarySearchParams
|
||||
DictionarySearchParams,
|
||||
DictionaryImportParams,
|
||||
DictionaryExportParams
|
||||
} from '@d3ro/core/types'
|
||||
|
||||
export function registerDictionaryHandlers(): void {
|
||||
|
|
@ -74,4 +76,43 @@ export function registerDictionaryHandlers(): void {
|
|||
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}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
// src/main/services/DictionaryService.ts
|
||||
// 사용자 커스텀 단어 사전. 설계서 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 { dictionary } from '../db/schema'
|
||||
import type { Dictionary, NewDictionary } from '../db/schema'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
DictionaryEntry,
|
||||
|
|
@ -14,11 +18,100 @@ import type {
|
|||
DictionaryPage,
|
||||
DictionaryAddParams,
|
||||
DictionaryUpdateParams,
|
||||
DictionarySearchParams
|
||||
DictionarySearchParams,
|
||||
DictionaryImportParams,
|
||||
DictionaryImportResult,
|
||||
DictionaryExportParams
|
||||
} from '@d3ro/core/types'
|
||||
|
||||
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 {
|
||||
add(params: DictionaryAddParams): DictionaryEntry {
|
||||
const word = params.word.trim()
|
||||
|
|
@ -160,6 +253,201 @@ class DictionaryService {
|
|||
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 {
|
||||
logger.info('DictionaryService disposed')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ import type {
|
|||
DictionaryUpdateParams,
|
||||
DictionaryDeleteParams,
|
||||
DictionarySearchParams,
|
||||
DictionaryImportParams,
|
||||
DictionaryImportResult,
|
||||
DictionaryExportParams,
|
||||
StatsSummary,
|
||||
PermissionStatus,
|
||||
// Phase 10
|
||||
|
|
@ -156,7 +159,6 @@ import type {
|
|||
MeetingDeleteSessionParams,
|
||||
MeetingExportParams,
|
||||
MeetingProcessingProgress,
|
||||
CaptionSegment,
|
||||
MeetingDocument,
|
||||
MeetingDocTemplate,
|
||||
MeetingGenerateDocParams,
|
||||
|
|
@ -165,7 +167,7 @@ import type {
|
|||
MeetingGetDocsParams,
|
||||
MeetingUpdateTranscriptParams,
|
||||
MeetingExportDocParams,
|
||||
MeetingExportFormat,
|
||||
MeetingExportTranscriptParams,
|
||||
MeetingDocGeneratingProgress,
|
||||
CreateMeetingDocTemplateParams,
|
||||
UpdateMeetingDocTemplateParams,
|
||||
|
|
@ -394,7 +396,11 @@ const electronAPI = {
|
|||
delete: (params: DictionaryDeleteParams) =>
|
||||
invoke<void>(IPC_CHANNELS.DICTIONARY.DELETE, params),
|
||||
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 ──────────────────────────────────────────────
|
||||
|
|
@ -714,7 +720,7 @@ const electronAPI = {
|
|||
invoke<void>(IPC_CHANNELS.MEETING_MODE.DELETE_DOCUMENT, params),
|
||||
exportDocument: (params: MeetingExportDocParams) =>
|
||||
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),
|
||||
editSegment: (params: { sessionId: string; segmentId: string; text: string }) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, params),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
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 { PageHeader, SearchInput, EmptyStateCard } from '../components/shared'
|
||||
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
|
|
@ -21,10 +21,15 @@ export function DictionaryPage(): React.ReactElement {
|
|||
const [formPronunciation, setFormPronunciation] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const isSavingRef = useRef(false)
|
||||
const [ioBusy, setIoBusy] = useState(false)
|
||||
const [ioMessage, setIoMessage] = useState<string | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
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)
|
||||
setLoading(false)
|
||||
}, [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 (
|
||||
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
|
||||
<PageHeader
|
||||
title={t('dictionary.title')}
|
||||
count={t('dictionary.words', { count: data?.total ?? 0 })}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Tooltip title={`${t('dictionary.export')} JSON`}>
|
||||
<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
|
||||
placeholder={t('dictionary.search')}
|
||||
value={search}
|
||||
|
|
@ -160,8 +259,8 @@ export function DictionaryPage(): React.ReactElement {
|
|||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
window.electronAPI.dictionary.delete({ id: entry.id })
|
||||
onClick={async () => {
|
||||
await window.electronAPI.dictionary.delete({ id: entry.id })
|
||||
loadData()
|
||||
}}
|
||||
sx={{
|
||||
|
|
|
|||
176
apps/desktop/tests/main/services/DictionaryIo.test.ts
Normal file
176
apps/desktop/tests/main/services/DictionaryIo.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue