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,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')
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue