From 42ae6404705146596ba8ee6d8ce0a68d7217fbf1 Mon Sep 17 00:00:00 2001 From: how2ice Date: Sun, 26 Apr 2026 17:36:46 +0900 Subject: [PATCH] feat: persist text memo notes in work server --- etc/servers/work-server/src/app.ts | 2 + .../work-server/src/routes/text-memo.ts | 78 ++++ .../src/services/text-memo-service.ts | 286 ++++++++++++ src/widgets/registry.ts | 2 +- .../text-memo-widget/TextMemoWidget.tsx | 192 +++++--- .../text-memo-widget/samples/Sample.tsx | 2 +- src/widgets/text-memo-widget/textMemoApi.ts | 420 ++++++++++++++++++ 7 files changed, 926 insertions(+), 56 deletions(-) create mode 100644 etc/servers/work-server/src/routes/text-memo.ts create mode 100644 etc/servers/work-server/src/services/text-memo-service.ts create mode 100644 src/widgets/text-memo-widget/textMemoApi.ts diff --git a/etc/servers/work-server/src/app.ts b/etc/servers/work-server/src/app.ts index ae04a40..2ba02f6 100755 --- a/etc/servers/work-server/src/app.ts +++ b/etc/servers/work-server/src/app.ts @@ -12,6 +12,7 @@ import { registerNotificationRoutes } from './routes/notification.js'; import { registerPlanRoutes } from './routes/plan.js'; import { registerServerCommandRoutes } from './routes/server-command.js'; import { registerSchemaRoutes } from './routes/schema.js'; +import { registerTextMemoRoutes } from './routes/text-memo.js'; import { registerVisitorHistoryRoutes } from './routes/visitor-history.js'; import { shouldPersistNotFoundErrorLog } from './not-found.js'; import { createErrorLog } from './services/error-log-service.js'; @@ -37,6 +38,7 @@ export function createApp() { app.register(registerNotificationRoutes); app.register(registerPlanRoutes); app.register(registerServerCommandRoutes); + app.register(registerTextMemoRoutes); app.register(registerVisitorHistoryRoutes); app.setNotFoundHandler(async (request, reply) => { diff --git a/etc/servers/work-server/src/routes/text-memo.ts b/etc/servers/work-server/src/routes/text-memo.ts new file mode 100644 index 0000000..2c338c9 --- /dev/null +++ b/etc/servers/work-server/src/routes/text-memo.ts @@ -0,0 +1,78 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { + createTextMemoNote, + deleteTextMemoNote, + importTextMemoNotes, + listTextMemoNotes, + textMemoNoteCreateSchema, + textMemoNoteImportSchema, + textMemoNoteUpdateSchema, + updateTextMemoNote, +} from '../services/text-memo-service.js'; + +function resolveClientId(headers: Record) { + return String(headers['x-client-id'] ?? '').trim(); +} + +export async function registerTextMemoRoutes(app: FastifyInstance) { + app.get('/api/text-memo/notes', async (request) => { + const items = await listTextMemoNotes(resolveClientId(request.headers)); + return { + ok: true, + items, + }; + }); + + app.post('/api/text-memo/notes', async (request) => { + const payload = textMemoNoteCreateSchema.parse(request.body ?? {}); + const item = await createTextMemoNote(resolveClientId(request.headers), payload); + + return { + ok: true, + item, + }; + }); + + app.post('/api/text-memo/notes/import', async (request) => { + const payload = textMemoNoteImportSchema.parse(request.body ?? {}); + const items = await importTextMemoNotes(resolveClientId(request.headers), payload); + + return { + ok: true, + items, + }; + }); + + app.put('/api/text-memo/notes/:noteId', async (request, reply) => { + const noteId = z.string().trim().min(1).parse((request.params as { noteId: string }).noteId); + const payload = textMemoNoteUpdateSchema.parse(request.body ?? {}); + const item = await updateTextMemoNote(resolveClientId(request.headers), noteId, payload); + + if (!item) { + return reply.code(404).send({ + message: '수정할 메모를 찾을 수 없습니다.', + }); + } + + return { + ok: true, + item, + }; + }); + + app.delete('/api/text-memo/notes/:noteId', async (request, reply) => { + const noteId = z.string().trim().min(1).parse((request.params as { noteId: string }).noteId); + const deleted = await deleteTextMemoNote(resolveClientId(request.headers), noteId); + + if (!deleted) { + return reply.code(404).send({ + message: '삭제할 메모를 찾을 수 없습니다.', + }); + } + + return { + ok: true, + }; + }); +} diff --git a/etc/servers/work-server/src/services/text-memo-service.ts b/etc/servers/work-server/src/services/text-memo-service.ts new file mode 100644 index 0000000..b923cac --- /dev/null +++ b/etc/servers/work-server/src/services/text-memo-service.ts @@ -0,0 +1,286 @@ +import { z } from 'zod'; +import { db } from '../db/client.js'; + +const TEXT_MEMO_TABLE = 'text_memo_notes'; +const MAX_NOTE_COUNT = 12; +const MAX_BODY_LENGTH = 1200; +const CLIENT_ID_MAX_LENGTH = 120; +const NOTE_ID_MAX_LENGTH = 120; + +const memoBodySchema = z.string().trim().min(1).max(MAX_BODY_LENGTH); +const memoTimestampSchema = z.string().datetime({ offset: true }).optional(); + +export const textMemoNoteCreateSchema = z.object({ + id: z.string().trim().min(1).max(NOTE_ID_MAX_LENGTH).optional(), + body: memoBodySchema, + createdAt: memoTimestampSchema, + updatedAt: memoTimestampSchema, +}); + +export const textMemoNoteImportSchema = z.object({ + notes: z.array( + z.object({ + id: z.string().trim().min(1).max(NOTE_ID_MAX_LENGTH), + body: memoBodySchema, + createdAt: z.string().datetime({ offset: true }), + }), + ).max(MAX_NOTE_COUNT), +}); + +export const textMemoNoteUpdateSchema = z.object({ + body: memoBodySchema, +}); + +export type TextMemoNoteItem = { + id: string; + body: string; + createdAt: string; + updatedAt: string; +}; + +function normalizeClientId(value: unknown) { + return String(value ?? '').trim().slice(0, CLIENT_ID_MAX_LENGTH); +} + +function normalizeIsoDate(value: string | undefined, fallback: string) { + if (!value) { + return fallback; + } + + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? fallback : new Date(parsed).toISOString(); +} + +function generateNoteId() { + return `memo-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function mapTextMemoRow(row: Record): TextMemoNoteItem { + return { + id: String(row.note_id ?? ''), + body: String(row.body ?? ''), + createdAt: new Date(String(row.created_at ?? '')).toISOString(), + updatedAt: new Date(String(row.updated_at ?? '')).toISOString(), + }; +} + +async function trimClientNotes(clientId: string) { + const rows = await db(TEXT_MEMO_TABLE) + .select('note_id') + .where({ client_id: clientId }) + .orderBy('updated_at', 'desc') + .orderBy('id', 'desc') + .offset(MAX_NOTE_COUNT); + + const deleteIds = rows + .map((row) => String(row.note_id ?? '').trim()) + .filter(Boolean); + + if (deleteIds.length === 0) { + return; + } + + await db(TEXT_MEMO_TABLE) + .where({ client_id: clientId }) + .whereIn('note_id', deleteIds) + .delete(); +} + +export async function ensureTextMemoTable() { + const hasTable = await db.schema.hasTable(TEXT_MEMO_TABLE); + + if (!hasTable) { + await db.schema.createTable(TEXT_MEMO_TABLE, (table) => { + table.increments('id').primary(); + table.string('client_id', CLIENT_ID_MAX_LENGTH).notNullable().index(); + table.string('note_id', NOTE_ID_MAX_LENGTH).notNullable(); + table.text('body').notNullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.unique(['client_id', 'note_id']); + }); + + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['client_id', (table) => table.string('client_id', CLIENT_ID_MAX_LENGTH).notNullable().index()], + ['note_id', (table) => table.string('note_id', NOTE_ID_MAX_LENGTH).notNullable()], + ['body', (table) => table.text('body').notNullable().defaultTo('')], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(TEXT_MEMO_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(TEXT_MEMO_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +export async function listTextMemoNotes(rawClientId: unknown) { + const clientId = normalizeClientId(rawClientId); + + if (!clientId) { + throw new Error('메모를 조회할 clientId가 비어 있습니다.'); + } + + await ensureTextMemoTable(); + + const rows = await db(TEXT_MEMO_TABLE) + .select('*') + .where({ client_id: clientId }) + .orderBy('updated_at', 'desc') + .orderBy('id', 'desc') + .limit(MAX_NOTE_COUNT); + + return rows.map((row) => mapTextMemoRow(row)); +} + +export async function createTextMemoNote(rawClientId: unknown, payload: z.infer) { + const clientId = normalizeClientId(rawClientId); + + if (!clientId) { + throw new Error('메모를 저장할 clientId가 비어 있습니다.'); + } + + await ensureTextMemoTable(); + + const now = new Date().toISOString(); + const createdAt = normalizeIsoDate(payload.createdAt, now); + const updatedAt = normalizeIsoDate(payload.updatedAt, createdAt); + const noteId = payload.id?.trim() || generateNoteId(); + + await db(TEXT_MEMO_TABLE) + .insert({ + client_id: clientId, + note_id: noteId, + body: payload.body, + created_at: createdAt, + updated_at: updatedAt, + }); + + await trimClientNotes(clientId); + + const row = await db(TEXT_MEMO_TABLE) + .select('*') + .where({ + client_id: clientId, + note_id: noteId, + }) + .first(); + + if (!row) { + throw new Error('저장된 메모를 다시 불러오지 못했습니다.'); + } + + return mapTextMemoRow(row); +} + +export async function updateTextMemoNote( + rawClientId: unknown, + rawNoteId: unknown, + payload: z.infer, +) { + const clientId = normalizeClientId(rawClientId); + const noteId = String(rawNoteId ?? '').trim().slice(0, NOTE_ID_MAX_LENGTH); + + if (!clientId || !noteId) { + throw new Error('수정할 메모 식별자가 올바르지 않습니다.'); + } + + await ensureTextMemoTable(); + + const affected = await db(TEXT_MEMO_TABLE) + .where({ + client_id: clientId, + note_id: noteId, + }) + .update({ + body: payload.body, + updated_at: new Date().toISOString(), + }); + + if (!affected) { + return null; + } + + const row = await db(TEXT_MEMO_TABLE) + .select('*') + .where({ + client_id: clientId, + note_id: noteId, + }) + .first(); + + return row ? mapTextMemoRow(row) : null; +} + +export async function deleteTextMemoNote(rawClientId: unknown, rawNoteId: unknown) { + const clientId = normalizeClientId(rawClientId); + const noteId = String(rawNoteId ?? '').trim().slice(0, NOTE_ID_MAX_LENGTH); + + if (!clientId || !noteId) { + throw new Error('삭제할 메모 식별자가 올바르지 않습니다.'); + } + + await ensureTextMemoTable(); + + const affected = await db(TEXT_MEMO_TABLE) + .where({ + client_id: clientId, + note_id: noteId, + }) + .delete(); + + return affected > 0; +} + +export async function importTextMemoNotes( + rawClientId: unknown, + payload: z.infer, +) { + const clientId = normalizeClientId(rawClientId); + + if (!clientId) { + throw new Error('메모를 가져올 clientId가 비어 있습니다.'); + } + + await ensureTextMemoTable(); + + const existingCountRow = await db(TEXT_MEMO_TABLE) + .where({ client_id: clientId }) + .count<{ count: number | string }[]>({ count: '*' }) + .first(); + + const existingCount = Number(existingCountRow?.count ?? 0); + if (existingCount > 0) { + return listTextMemoNotes(clientId); + } + + const sortedNotes = [...payload.notes] + .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)) + .slice(0, MAX_NOTE_COUNT); + + if (sortedNotes.length === 0) { + return []; + } + + await db.transaction(async (trx) => { + for (const note of sortedNotes) { + await trx(TEXT_MEMO_TABLE).insert({ + client_id: clientId, + note_id: note.id, + body: note.body, + created_at: note.createdAt, + updated_at: note.createdAt, + }); + } + }); + + return listTextMemoNotes(clientId); +} diff --git a/src/widgets/registry.ts b/src/widgets/registry.ts index 2d27bb8..187604d 100755 --- a/src/widgets/registry.ts +++ b/src/widgets/registry.ts @@ -26,7 +26,7 @@ export const registeredWidgets: WidgetRegistryItem[] = [ id: 'text-memo-widget', title: 'Text Memo Widget', description: - '텍스트 메모를 작성하고 최근 저장본을 브라우저 Local Storage에 유지한 뒤 다시 불러올 수 있는 위젯입니다.', + '텍스트 메모를 작성하고 최근 저장본을 work-server DB에 보관해 다시 불러올 수 있는 위젯입니다.', features: ['component-sample', 'feature-registry', 'imperative-handle'], }, ]; diff --git a/src/widgets/text-memo-widget/TextMemoWidget.tsx b/src/widgets/text-memo-widget/TextMemoWidget.tsx index 46df151..2294eeb 100755 --- a/src/widgets/text-memo-widget/TextMemoWidget.tsx +++ b/src/widgets/text-memo-widget/TextMemoWidget.tsx @@ -4,6 +4,14 @@ import { forwardRef, useEffect, useMemo, useState } from 'react'; import { renderModalWithEnterConfirm } from '../../app/main/modalKeyboard'; import { WidgetShell } from '../core'; import type { WidgetHandle } from '../core'; +import { + createTextMemoNote, + deleteTextMemoNote as deleteTextMemoNoteFromServer, + fetchTextMemoNotes, + importTextMemoNotes, + updateTextMemoNote as updateTextMemoNoteFromServer, + type TextMemoNoteRecord, +} from './textMemoApi'; import './TextMemoWidget.css'; const STORAGE_KEY = 'ai-code-app:text-memo-widget'; @@ -14,6 +22,7 @@ type MemoNote = { id: string; body: string; createdAt: string; + updatedAt: string; }; function readStoredNotes() { @@ -40,12 +49,32 @@ function readStoredNotes() { typeof note.body === 'string' && typeof note.createdAt === 'string' ); - }); + }).map((note) => ({ + ...note, + updatedAt: typeof note.updatedAt === 'string' ? note.updatedAt : note.createdAt, + })); } catch { return []; } } +function clearStoredNotes() { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.removeItem(STORAGE_KEY); +} + +function toMemoNote(note: TextMemoNoteRecord): MemoNote { + return { + id: note.id, + body: note.body, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + }; +} + function formatMemoTimestamp(value: string) { return new Intl.DateTimeFormat('ko-KR', { month: 'short', @@ -75,26 +104,69 @@ export const TextMemoWidget = forwardRef(func const [selectedId, setSelectedId] = useState(null); const [isListOpen, setIsListOpen] = useState(false); const [isEditing, setIsEditing] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); useEffect(() => { - const storedNotes = readStoredNotes(); - setNotes(storedNotes); + let cancelled = false; - if (storedNotes[0]) { - setSelectedId(storedNotes[0].id); - setBody(storedNotes[0].body); - setIsEditing(false); - } + void (async () => { + setIsLoading(true); + + try { + const serverNotes = await fetchTextMemoNotes(); + let nextNotes = serverNotes.map(toMemoNote); + + if (nextNotes.length === 0) { + const storedNotes = readStoredNotes(); + + if (storedNotes.length > 0) { + const imported = await importTextMemoNotes( + storedNotes.map((note) => ({ + id: note.id, + body: note.body, + createdAt: note.updatedAt || note.createdAt, + })), + ); + nextNotes = imported.map(toMemoNote); + clearStoredNotes(); + } + } + + if (cancelled) { + return; + } + + setNotes(nextNotes); + + if (nextNotes[0]) { + setSelectedId(nextNotes[0].id); + setBody(nextNotes[0].body); + setIsEditing(false); + } else { + setSelectedId(null); + setBody(''); + setIsEditing(true); + } + } catch (error) { + if (cancelled) { + return; + } + + const nextMessage = error instanceof Error ? error.message : '메모를 불러오지 못했습니다.'; + void messageApi.error(nextMessage); + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; }, []); - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(notes)); - }, [notes]); - const selectedIndex = useMemo(() => { if (!selectedId) { return -1; @@ -107,50 +179,49 @@ export const TextMemoWidget = forwardRef(func const selectedNote = selectedIndex >= 0 ? notes[selectedIndex] : null; const isDirty = selectedNote ? selectedNote.body !== body : hasDraft; - const saveNote = () => { + const saveNote = async () => { const trimmedBody = body.trim(); - if (!trimmedBody) { + if (!trimmedBody || isSaving) { return; } - const timestamp = new Date().toISOString(); + setIsSaving(true); - if (selectedNote) { - const updatedNote: MemoNote = { - ...selectedNote, - body: trimmedBody, - createdAt: timestamp, - }; - const nextNotes = [updatedNote, ...notes.filter((note) => note.id !== selectedNote.id)].slice( - 0, - MAX_NOTE_COUNT, - ); + try { + if (selectedNote) { + const updatedNote = toMemoNote(await updateTextMemoNoteFromServer(selectedNote.id, { body: trimmedBody })); + const nextNotes = [updatedNote, ...notes.filter((note) => note.id !== selectedNote.id)].slice( + 0, + MAX_NOTE_COUNT, + ); + + setNotes(nextNotes); + setSelectedId(updatedNote.id); + setBody(updatedNote.body); + setIsEditing(false); + void messageApi.success('저장됨'); + return; + } + + const nextNote = toMemoNote(await createTextMemoNote({ body: trimmedBody })); + const nextNotes = [nextNote, ...notes].slice(0, MAX_NOTE_COUNT); setNotes(nextNotes); - setSelectedId(updatedNote.id); - setBody(updatedNote.body); + setSelectedId(nextNote.id); + setBody(nextNote.body); setIsEditing(false); void messageApi.success('저장됨'); - return; + } catch (error) { + const nextMessage = error instanceof Error ? error.message : '메모 저장에 실패했습니다.'; + void messageApi.error(nextMessage); + } finally { + setIsSaving(false); } - - const nextNote: MemoNote = { - id: `${Date.now()}`, - body: trimmedBody, - createdAt: timestamp, - }; - const nextNotes = [nextNote, ...notes].slice(0, MAX_NOTE_COUNT); - - setNotes(nextNotes); - setSelectedId(nextNote.id); - setBody(nextNote.body); - setIsEditing(false); - void messageApi.success('저장됨'); }; const handlePrimaryAction = () => { if (isEditing || isDirty || !selectedNote) { - saveNote(); + void saveNote(); return; } @@ -180,7 +251,7 @@ export const TextMemoWidget = forwardRef(func autoFocusButton: 'ok', modalRender: renderModalWithEnterConfirm, okButtonProps: { danger: true }, - onOk: () => { + onOk: async () => { if (isDraftOnly) { setBody(''); setIsEditing(true); @@ -188,6 +259,14 @@ export const TextMemoWidget = forwardRef(func return; } + try { + await deleteTextMemoNoteFromServer(selectedNote.id); + } catch (error) { + const nextMessage = error instanceof Error ? error.message : '메모 삭제에 실패했습니다.'; + void messageApi.error(nextMessage); + return; + } + const nextNotes = notes.filter((note) => note.id !== selectedNote.id); const nextSelectedNote = nextNotes[0] ?? null; @@ -245,7 +324,7 @@ export const TextMemoWidget = forwardRef(func shape="circle" icon={} aria-label="메모 삭제" - disabled={!selectedNote && !hasDraft} + disabled={isLoading || isSaving || (!selectedNote && !hasDraft)} onClick={handleDelete} />