feat(web): manage dictionaries and teams from the console
The console could not create dictionary entries, and team pages showed a static member list with no record of who changed what. Dictionary management, a knowledge upload form, and a team activity feed are now available, alongside a download center that links the published desktop installer feed rather than repository-local paths that no deploy ships. Red-team e2e coverage was added for the account and team flows touched here.
This commit is contained in:
parent
f6a29db95a
commit
cfc58458a8
21 changed files with 985 additions and 125 deletions
24
apps/web/src/lib/desktop-release.ts
Normal file
24
apps/web/src/lib/desktop-release.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// apps/web/src/lib/desktop-release.ts
|
||||
// 데스크톱 공식 릴리스 계약 SSOT.
|
||||
//
|
||||
// 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는
|
||||
// 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다.
|
||||
|
||||
export const DESKTOP_VERSION = '1.1.0'
|
||||
|
||||
const FORGEJO_ORIGIN = 'https://git.chanpaca.net'
|
||||
const FORGEJO_OWNER = 'yunchan'
|
||||
const FORGEJO_REPO = 'd3ro-voice'
|
||||
|
||||
/** Registry 안에서 항상 최신 설치 자산을 가리키는 feed 루트 (updater와 동일). */
|
||||
export const DESKTOP_FEED_URL = `${FORGEJO_ORIGIN}/api/packages/${FORGEJO_OWNER}/generic/${FORGEJO_REPO}/latest`
|
||||
|
||||
export const DESKTOP_WINDOWS_INSTALLER_FILENAME = `D3RO-Voice-Setup-${DESKTOP_VERSION}-x64.exe`
|
||||
|
||||
export const DESKTOP_WINDOWS_INSTALLER_URL = `${DESKTOP_FEED_URL}/${DESKTOP_WINDOWS_INSTALLER_FILENAME}`
|
||||
|
||||
/** Forgejo Release 허브 (릴리스 노트 + 자산 첨부). */
|
||||
export const DESKTOP_RELEASE_HUB_URL = `${FORGEJO_ORIGIN}/${FORGEJO_OWNER}/${FORGEJO_REPO}/releases/tag/v${DESKTOP_VERSION}`
|
||||
|
||||
/** Release 자산 목록 (버전 아카이브). */
|
||||
export const DESKTOP_RELEASES_URL = `${FORGEJO_ORIGIN}/${FORGEJO_OWNER}/${FORGEJO_REPO}/releases`
|
||||
|
|
@ -265,3 +265,178 @@ export async function deleteDictionaryEntry(
|
|||
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<string, unknown>): 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<string, unknown>)
|
||||
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<string, unknown> = {}
|
||||
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<DictionaryImportResult> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue