fix(dictionary): let an edit clear the pronunciation and report duplicate renames
This commit is contained in:
parent
9d5d043e8b
commit
de1e8a82a4
6 changed files with 350 additions and 24 deletions
|
|
@ -12,6 +12,12 @@ import { getLogger } from './LoggerService'
|
|||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import {
|
||||
buildDictionaryUpdatePatch,
|
||||
isUniqueConstraintViolation,
|
||||
normalizeDictionaryPronunciation,
|
||||
normalizeDictionaryWord
|
||||
} from '@d3ro/core/dictionary-policy'
|
||||
import type {
|
||||
DictionaryEntry,
|
||||
DictionaryQueryParams,
|
||||
|
|
@ -114,7 +120,7 @@ function normalizeCategory(value: string | null): DictionaryEntry['category'] {
|
|||
|
||||
class DictionaryService {
|
||||
add(params: DictionaryAddParams): DictionaryEntry {
|
||||
const word = params.word.trim()
|
||||
const word = normalizeDictionaryWord(params.word)
|
||||
if (!word) {
|
||||
throw new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty')
|
||||
}
|
||||
|
|
@ -126,7 +132,7 @@ class DictionaryService {
|
|||
const entry: NewDictionary = {
|
||||
id,
|
||||
word,
|
||||
pronunciation: params.pronunciation ?? null,
|
||||
pronunciation: normalizeDictionaryPronunciation(params.pronunciation) ?? null,
|
||||
category: params.category ?? 'user',
|
||||
usageCount: 0,
|
||||
lastUsedAt: null,
|
||||
|
|
@ -137,8 +143,7 @@ class DictionaryService {
|
|||
try {
|
||||
db.insert(dictionary).values(entry).run()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes('UNIQUE') || message.includes('unique')) {
|
||||
if (isUniqueConstraintViolation(err)) {
|
||||
throw new D3ROError(ErrorCode.DictionaryDuplicate, `Duplicate word: ${word}`)
|
||||
}
|
||||
throw err
|
||||
|
|
@ -150,16 +155,27 @@ class DictionaryService {
|
|||
}
|
||||
|
||||
update(params: DictionaryUpdateParams): DictionaryEntry | null {
|
||||
const result = buildDictionaryUpdatePatch(params)
|
||||
if (!result.ok) {
|
||||
throw new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty')
|
||||
}
|
||||
|
||||
const db = getDatabase()
|
||||
const existing = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
|
||||
if (!existing) return null
|
||||
|
||||
const updates: Partial<NewDictionary> = { updatedAt: Date.now() }
|
||||
if (params.word !== undefined) updates.word = params.word
|
||||
if (params.pronunciation !== undefined) updates.pronunciation = params.pronunciation
|
||||
if (params.category !== undefined) updates.category = params.category
|
||||
|
||||
db.update(dictionary).set(updates).where(eq(dictionary.id, params.id)).run()
|
||||
const updates: Partial<NewDictionary> = { ...result.patch, updatedAt: Date.now() }
|
||||
try {
|
||||
db.update(dictionary).set(updates).where(eq(dictionary.id, params.id)).run()
|
||||
} catch (err) {
|
||||
if (isUniqueConstraintViolation(err)) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.DictionaryDuplicate,
|
||||
`Duplicate word: ${result.patch.word ?? existing.word}`
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const updated = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
|
||||
// Phase 3.3: 자동 push (fire-and-forget)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared'
|
|||
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@d3ro/core/types'
|
||||
import {
|
||||
dictionarySaveErrorKey,
|
||||
toDictionaryAddParams,
|
||||
toDictionaryUpdateParams,
|
||||
} from '@d3ro/core/dictionary-policy'
|
||||
|
||||
export function DictionaryPage(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
|
|
@ -20,6 +25,7 @@ export function DictionaryPage(): React.ReactElement {
|
|||
const [formWord, setFormWord] = useState('')
|
||||
const [formPronunciation, setFormPronunciation] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const isSavingRef = useRef(false)
|
||||
const [ioBusy, setIoBusy] = useState(false)
|
||||
const [ioMessage, setIoMessage] = useState<string | null>(null)
|
||||
|
|
@ -47,6 +53,7 @@ export function DictionaryPage(): React.ReactElement {
|
|||
setEditId(null)
|
||||
setFormWord('')
|
||||
setFormPronunciation('')
|
||||
setFormError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +62,7 @@ export function DictionaryPage(): React.ReactElement {
|
|||
setEditId(entry.id)
|
||||
setFormWord(entry.word)
|
||||
setFormPronunciation(entry.pronunciation ?? '')
|
||||
setFormError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -62,18 +70,16 @@ export function DictionaryPage(): React.ReactElement {
|
|||
if (!formWord.trim() || saving || isSavingRef.current) return
|
||||
isSavingRef.current = true
|
||||
setSaving(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
if (editId) {
|
||||
await window.electronAPI.dictionary.update({
|
||||
id: editId,
|
||||
word: formWord.trim(),
|
||||
pronunciation: formPronunciation.trim() || undefined,
|
||||
})
|
||||
} else {
|
||||
await window.electronAPI.dictionary.add({
|
||||
word: formWord.trim(),
|
||||
pronunciation: formPronunciation.trim() || undefined,
|
||||
})
|
||||
const form = { word: formWord, pronunciation: formPronunciation }
|
||||
const result = editId
|
||||
? await window.electronAPI.dictionary.update(toDictionaryUpdateParams(editId, form))
|
||||
: await window.electronAPI.dictionary.add(toDictionaryAddParams(form))
|
||||
if (!result.success) {
|
||||
// 다이얼로그를 유지해 사용자가 입력을 고칠 수 있게 한다 (예: 같은 단어로 이름 변경).
|
||||
setFormError(t(dictionarySaveErrorKey(result.error.code)))
|
||||
return
|
||||
}
|
||||
setDialogOpen(false)
|
||||
loadData()
|
||||
|
|
@ -308,7 +314,11 @@ export function DictionaryPage(): React.ReactElement {
|
|||
<TextField
|
||||
label={t('dictionary.word')}
|
||||
value={formWord}
|
||||
onChange={(e) => setFormWord(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFormWord(e.target.value)
|
||||
setFormError(null)
|
||||
}}
|
||||
error={formError !== null}
|
||||
fullWidth
|
||||
autoFocus
|
||||
sx={{ mt: 1.5 }}
|
||||
|
|
@ -321,13 +331,18 @@ export function DictionaryPage(): React.ReactElement {
|
|||
sx={{ mt: 2.5 }}
|
||||
helperText="e.g. 디쓰리오"
|
||||
/>
|
||||
{formError && (
|
||||
<PhosphorText variant="meta" role="alert" sx={{ display: 'block', mt: 1.5, color: d3roPalette.tag.red }}>
|
||||
{formError}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 2.5, pb: 2, pt: 1, gap: 1 }}>
|
||||
<PhysicalButton tone="glass" onClick={() => setDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</PhysicalButton>
|
||||
<PhysicalButton tone="accent" onClick={handleSave} disabled={!formWord.trim() || saving}>
|
||||
{saving ? t('common.saving') || '저장 중...' : t('common.save')}
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</PhysicalButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue