import type { D3roSupabaseClient, DictionaryEntry } from '@d3ro/api-client' export type DictionaryCategory = DictionaryEntry['category'] export type DictionaryFilter = DictionaryCategory | 'all' export interface DictionaryCursor { updatedAt: string id: string } export interface DictionaryDraft { word: string pronunciation: string | null category: DictionaryCategory } export interface DictionaryPageResult { entries: DictionaryEntry[] nextCursor: DictionaryCursor | null total: number } export type DictionaryClientErrorCode = | 'auth' | 'conflict' | 'duplicate' | 'network' | 'validation' | 'server' export class DictionaryClientError extends Error { constructor( public readonly code: DictionaryClientErrorCode, message: string ) { super(message) this.name = 'DictionaryClientError' } } const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const CATEGORIES: readonly DictionaryCategory[] = ['user', 'technical', 'auto'] const MAX_WORD_LENGTH = 120 const MAX_PRONUNCIATION_LENGTH = 200 const MAX_PAGE_SIZE = 50 function requireUuid(value: string, code: 'auth' | 'validation', message: string): void { if (!UUID_PATTERN.test(value)) throw new DictionaryClientError(code, message) } function normalizeText(value: string): string { return value.trim().replace(/\s+/g, ' ') } export function sanitizeDictionarySearch(value: string): string { return value .trim() .slice(0, 100) .replace(/[%,()._'"\\]/g, ' ') .replace(/\s+/g, ' ') .trim() } export function normalizeDictionaryDraft(draft: DictionaryDraft): DictionaryDraft { const word = normalizeText(draft.word) const pronunciation = normalizeText(draft.pronunciation ?? '') || null if (word.length === 0 || word.length > MAX_WORD_LENGTH) { throw new DictionaryClientError('validation', `단어는 1-${MAX_WORD_LENGTH}자로 입력해 주세요.`) } if (pronunciation !== null && pronunciation.length > MAX_PRONUNCIATION_LENGTH) { throw new DictionaryClientError('validation', `발음은 ${MAX_PRONUNCIATION_LENGTH}자 이하여야 합니다.`) } if (!CATEGORIES.includes(draft.category)) { throw new DictionaryClientError('validation', '사전 분류가 올바르지 않습니다.') } return { word, pronunciation, category: draft.category } } function mapDictionaryError(error: unknown): DictionaryClientError { if (error instanceof DictionaryClientError) return error const candidate = error as { code?: unknown; message?: unknown } const databaseCode = typeof candidate?.code === 'string' ? candidate.code : '' const message = typeof candidate?.message === 'string' ? candidate.message : 'Dictionary request failed' const normalized = message.toLowerCase() if (databaseCode === '23505') return new DictionaryClientError('duplicate', message) if ( error instanceof TypeError || normalized.includes('failed to fetch') || normalized.includes('network request failed') || normalized.includes('networkerror') ) { return new DictionaryClientError('network', message) } if (databaseCode === 'PGRST301' || databaseCode === '42501' || normalized.includes('jwt')) { return new DictionaryClientError('auth', message) } if (databaseCode.startsWith('22')) return new DictionaryClientError('validation', message) return new DictionaryClientError('server', message) } function assertOwned(userId: string, entry: DictionaryEntry): void { requireUuid(userId, 'auth', 'Authenticated user is invalid') requireUuid(entry.id, 'validation', 'Dictionary entry id is invalid') if (entry.user_id !== userId) { throw new DictionaryClientError('auth', 'Dictionary ownership could not be verified') } } function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, (character) => `\\${character}`) } async function findDuplicate( client: D3roSupabaseClient, userId: string, draft: DictionaryDraft, excludingId?: string ): Promise { let query = client .from('dictionary') .select('id') .eq('user_id', userId) .eq('category', draft.category) .ilike('word', escapeLikePattern(draft.word)) .limit(1) if (excludingId) query = query.neq('id', excludingId) const { data, error } = await query.maybeSingle() if (error) throw error return data !== null } export async function listDictionaryPage( client: D3roSupabaseClient, options: { userId: string search: string category: DictionaryFilter pageSize: number cursor: DictionaryCursor | null } ): Promise { requireUuid(options.userId, 'auth', 'Authenticated user is invalid') if (options.category !== 'all' && !CATEGORIES.includes(options.category)) { throw new DictionaryClientError('validation', 'Dictionary category is invalid') } if (options.cursor) { requireUuid(options.cursor.id, 'validation', 'Dictionary cursor id is invalid') if (!Number.isFinite(Date.parse(options.cursor.updatedAt))) { throw new DictionaryClientError('validation', 'Dictionary cursor date is invalid') } } const pageSize = Math.max(1, Math.min(options.pageSize, MAX_PAGE_SIZE)) try { let query = client .from('dictionary') .select('*', { count: 'exact' }) .eq('user_id', options.userId) .order('updated_at', { ascending: false }) .order('id', { ascending: false }) .limit(pageSize + 1) if (options.category !== 'all') query = query.eq('category', options.category) const search = sanitizeDictionarySearch(options.search) if (search) { const pattern = `%${search}%` query = query.or(`word.ilike.${pattern},pronunciation.ilike.${pattern}`) } if (options.cursor) { query = query.or( `updated_at.lt.${options.cursor.updatedAt},and(updated_at.eq.${options.cursor.updatedAt},id.lt.${options.cursor.id})` ) } const { data, error, count } = await query if (error) throw error const rows = data ?? [] for (const row of rows) assertOwned(options.userId, row) const hasMore = rows.length > pageSize const entries = hasMore ? rows.slice(0, pageSize) : rows const last = entries.at(-1) return { entries, nextCursor: hasMore && last ? { updatedAt: last.updated_at, id: last.id } : null, total: count ?? entries.length } } catch (error) { throw mapDictionaryError(error) } } export async function createDictionaryEntry( client: D3roSupabaseClient, userId: string, draft: DictionaryDraft ): Promise { requireUuid(userId, 'auth', 'Authenticated user is invalid') const normalized = normalizeDictionaryDraft(draft) try { if (await findDuplicate(client, userId, normalized)) { throw new DictionaryClientError('duplicate', 'Duplicate dictionary entry') } const { data, error } = await client .from('dictionary') .insert({ user_id: userId, ...normalized }) .select('*') .single() if (error) throw error assertOwned(userId, data) return data } catch (error) { throw mapDictionaryError(error) } } export async function updateDictionaryEntry( client: D3roSupabaseClient, userId: string, current: DictionaryEntry, draft: DictionaryDraft ): Promise { assertOwned(userId, current) const normalized = normalizeDictionaryDraft(draft) try { if (await findDuplicate(client, userId, normalized, current.id)) { throw new DictionaryClientError('duplicate', 'Duplicate dictionary entry') } const { data, error } = await client .from('dictionary') .update({ word: normalized.word, pronunciation: normalized.pronunciation, category: normalized.category }) .eq('user_id', userId) .eq('id', current.id) .eq('updated_at', current.updated_at) .select('*') .maybeSingle() if (error) throw error if (!data) throw new DictionaryClientError('conflict', 'Dictionary entry changed') assertOwned(userId, data) return data } catch (error) { throw mapDictionaryError(error) } } export async function deleteDictionaryEntry( client: D3roSupabaseClient, userId: string, current: DictionaryEntry ): Promise { assertOwned(userId, current) try { const { data, error } = await client .from('dictionary') .delete() .eq('user_id', userId) .eq('id', current.id) .eq('updated_at', current.updated_at) .select('id') .maybeSingle() if (error) throw error if (!data) throw new DictionaryClientError('conflict', 'Dictionary entry changed') } catch (error) { throw mapDictionaryError(error) } } export interface DictionaryImportResult { imported: number skipped: number } const CSV_HEADER = ['word', 'pronunciation', 'category', 'usageCount', 'createdAt', 'updatedAt'] 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') { 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 coerceDraft(record: Record): DictionaryDraft | null { const word = typeof record.word === 'string' ? record.word : '' const pronunciation = typeof record.pronunciation === 'string' && record.pronunciation.trim() !== '' ? record.pronunciation : null const category = typeof record.category === 'string' ? record.category : 'user' try { return normalizeDictionaryDraft({ word, pronunciation, category: category as DictionaryCategory }) } catch { return null } } export function serializeDictionary(entries: DictionaryEntry[], format: 'json' | 'csv'): string { if (format === 'json') { return JSON.stringify( { entries: entries.map((entry) => ({ word: entry.word, pronunciation: entry.pronunciation, category: entry.category, usageCount: entry.usage_count, lastUsedAt: entry.last_used_at ? Date.parse(entry.last_used_at) : null, createdAt: Date.parse(entry.created_at), updatedAt: Date.parse(entry.updated_at) })) }, null, 2 ) } const lines = [CSV_HEADER.join(',')] for (const entry of entries) { lines.push( [ csvCell(entry.word), csvCell(entry.pronunciation ?? ''), csvCell(entry.category), csvCell(entry.usage_count), csvCell(Date.parse(entry.created_at)), csvCell(Date.parse(entry.updated_at)) ].join(',') ) } return `\uFEFF${lines.join('\r\n')}\r\n` } export function parseDictionaryFile(raw: string, format: 'json' | 'csv'): DictionaryDraft[] { const drafts: DictionaryDraft[] = [] if (format === 'json') { let data: unknown try { data = JSON.parse(raw) } catch { throw new DictionaryClientError('validation', '사전 JSON 형식이 올바르지 않습니다.') } const list = Array.isArray(data) ? data : data && typeof data === 'object' && Array.isArray((data as { entries?: unknown }).entries) ? (data as { entries: unknown[] }).entries : null if (!list) throw new DictionaryClientError('validation', '사전 JSON에 entries 배열이 없습니다.') for (const item of list) { if (!item || typeof item !== 'object') continue const draft = coerceDraft(item as Record) if (draft) drafts.push(draft) } } else { const rows = parseCsvRows(raw.replace(/^\uFEFF/, '')) if (rows.length === 0) throw new DictionaryClientError('validation', '빈 CSV 파일입니다.') const header = rows[0].map((cell) => cell.trim()) if (!header.includes('word')) { throw new DictionaryClientError('validation', 'CSV에 word 열이 없습니다.') } for (const cells of rows.slice(1)) { const record: Record = {} header.forEach((key, index) => { record[key] = cells[index] ?? '' }) const draft = coerceDraft(record) if (draft) drafts.push(draft) } } if (drafts.length === 0) { throw new DictionaryClientError('validation', '가져올 유효한 단어가 없습니다.') } return drafts } export async function importDictionaryFile( client: D3roSupabaseClient, userId: string, raw: string, format: 'json' | 'csv' ): Promise { requireUuid(userId, 'auth', 'Authenticated user is invalid') const drafts = parseDictionaryFile(raw, format) const result: DictionaryImportResult = { imported: 0, skipped: 0 } for (const draft of drafts) { try { await createDictionaryEntry(client, userId, draft) result.imported += 1 } catch (error) { if ( error instanceof DictionaryClientError && (error.code === 'duplicate' || error.code === 'conflict') ) { result.skipped += 1 continue } throw error } } return result }