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,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')
}