feat: persist text memo notes in work server
This commit is contained in:
@@ -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) => {
|
||||
|
||||
78
etc/servers/work-server/src/routes/text-memo.ts
Normal file
78
etc/servers/work-server/src/routes/text-memo.ts
Normal file
@@ -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<string, unknown>) {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
286
etc/servers/work-server/src/services/text-memo-service.ts
Normal file
286
etc/servers/work-server/src/services/text-memo-service.ts
Normal file
@@ -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<string, unknown>): 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<typeof textMemoNoteCreateSchema>) {
|
||||
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<typeof textMemoNoteUpdateSchema>,
|
||||
) {
|
||||
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<typeof textMemoNoteImportSchema>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<WidgetHandle, TextMemoWidgetProps>(func
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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<WidgetHandle, TextMemoWidgetProps>(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<WidgetHandle, TextMemoWidgetProps>(func
|
||||
autoFocusButton: 'ok',
|
||||
modalRender: renderModalWithEnterConfirm,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
onOk: async () => {
|
||||
if (isDraftOnly) {
|
||||
setBody('');
|
||||
setIsEditing(true);
|
||||
@@ -188,6 +259,14 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(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<WidgetHandle, TextMemoWidgetProps>(func
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="메모 삭제"
|
||||
disabled={!selectedNote && !hasDraft}
|
||||
disabled={isLoading || isSaving || (!selectedNote && !hasDraft)}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
<Button
|
||||
@@ -253,6 +332,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
||||
shape="circle"
|
||||
icon={<UnorderedListOutlined />}
|
||||
aria-label="메모 목록"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setIsListOpen((previous) => !previous);
|
||||
}}
|
||||
@@ -265,7 +345,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
||||
shape="circle"
|
||||
icon={<LeftOutlined />}
|
||||
aria-label="이전 메모"
|
||||
disabled={selectedIndex <= 0}
|
||||
disabled={isLoading || selectedIndex <= 0}
|
||||
onClick={() => {
|
||||
moveSelection(-1);
|
||||
}}
|
||||
@@ -275,7 +355,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
||||
shape="circle"
|
||||
icon={<RightOutlined />}
|
||||
aria-label="다음 메모"
|
||||
disabled={selectedIndex < 0 || selectedIndex >= notes.length - 1}
|
||||
disabled={isLoading || selectedIndex < 0 || selectedIndex >= notes.length - 1}
|
||||
onClick={() => {
|
||||
moveSelection(1);
|
||||
}}
|
||||
@@ -285,14 +365,18 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
||||
shape="circle"
|
||||
icon={isEditing || isDirty || !selectedNote ? <CheckOutlined /> : <EditOutlined />}
|
||||
aria-label={isEditing || isDirty || !selectedNote ? '저장' : '편집'}
|
||||
disabled={!hasDraft && !selectedNote}
|
||||
disabled={isLoading || isSaving || (!hasDraft && !selectedNote)}
|
||||
onClick={handlePrimaryAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`text-memo-widget__body${isListOpen ? ' text-memo-widget__body--list-open' : ''}`}>
|
||||
{isListOpen ? (
|
||||
{isLoading ? (
|
||||
<div className="text-memo-widget__empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="메모를 불러오는 중입니다." />
|
||||
</div>
|
||||
) : isListOpen ? (
|
||||
<div className="text-memo-widget__sheet">
|
||||
{notes.length === 0 ? (
|
||||
<div className="text-memo-widget__empty">
|
||||
@@ -321,7 +405,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
||||
) : (
|
||||
<div className="text-memo-widget__editor">
|
||||
<div className="text-memo-widget__meta">
|
||||
<span>{selectedNote ? formatMemoTimestamp(selectedNote.createdAt) : ''}</span>
|
||||
<span>{selectedNote ? formatMemoTimestamp(selectedNote.updatedAt) : ''}</span>
|
||||
<span>{body.length}/{MAX_BODY_LENGTH}</span>
|
||||
</div>
|
||||
|
||||
@@ -331,7 +415,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
||||
placeholder="메모 입력"
|
||||
variant="borderless"
|
||||
maxLength={MAX_BODY_LENGTH}
|
||||
readOnly={!isEditing && !!selectedNote}
|
||||
readOnly={isSaving || (!isEditing && !!selectedNote)}
|
||||
onChange={(event) => {
|
||||
setBody(event.target.value);
|
||||
}}
|
||||
|
||||
@@ -5,7 +5,7 @@ export const sampleMeta: SampleMeta = {
|
||||
id: 'text-memo-widget',
|
||||
componentId: 'text-memo-widget',
|
||||
title: 'Text Memo Widget',
|
||||
description: '짧은 텍스트 메모를 작성하고 최근 저장본을 Local Storage에 유지하는 위젯입니다.',
|
||||
description: '짧은 텍스트 메모를 작성하고 최근 저장본을 DB에 유지하는 위젯입니다.',
|
||||
category: 'Widgets',
|
||||
kind: 'feature',
|
||||
variantLabel: 'Memo / Notes',
|
||||
|
||||
420
src/widgets/text-memo-widget/textMemoApi.ts
Normal file
420
src/widgets/text-memo-widget/textMemoApi.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { appendClientIdHeader, getOrCreateClientId } from '../../app/main/clientIdentity';
|
||||
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
|
||||
|
||||
const TEXT_MEMO_API_BASE_URL = import.meta.env.VITE_WORK_SERVER_URL || '/api';
|
||||
const TEXT_MEMO_API_PATH = '/text-memo/notes';
|
||||
const TEXT_MEMO_TABLE = 'text_memo_notes';
|
||||
const TEXT_MEMO_REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
export type TextMemoNoteRecord = {
|
||||
id: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type TextMemoApiListResponse = {
|
||||
ok: boolean;
|
||||
items: TextMemoNoteRecord[];
|
||||
};
|
||||
|
||||
type TextMemoApiItemResponse = {
|
||||
ok: boolean;
|
||||
item: TextMemoNoteRecord;
|
||||
};
|
||||
|
||||
type CrudResponse<Row> = {
|
||||
ok: boolean;
|
||||
rows: Row[];
|
||||
};
|
||||
|
||||
type SchemaTableRow = {
|
||||
table_name: string;
|
||||
};
|
||||
|
||||
type RawTextMemoRow = {
|
||||
note_id: string;
|
||||
body: string;
|
||||
created_at_text: string;
|
||||
updated_at_text: string;
|
||||
};
|
||||
|
||||
class TextMemoApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'TextMemoApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestTextMemoApi<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TEXT_MEMO_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const token = getRegisteredAccessToken();
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${TEXT_MEMO_API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new TextMemoApiError('메모 서버 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new TextMemoApiError(payload.message || '메모 요청에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new TextMemoApiError(text || '메모 요청에 실패했습니다.', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
throw new TextMemoApiError('메모 서버 응답 형식이 올바르지 않습니다.', 502);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function getRequiredClientId() {
|
||||
const clientId = getOrCreateClientId().trim();
|
||||
|
||||
if (!clientId) {
|
||||
throw new TextMemoApiError('메모를 저장할 클라이언트 식별자를 만들지 못했습니다.', 500);
|
||||
}
|
||||
|
||||
return clientId;
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown) {
|
||||
return error instanceof TextMemoApiError && error.status === 404;
|
||||
}
|
||||
|
||||
function mapRow(row: RawTextMemoRow): TextMemoNoteRecord {
|
||||
return {
|
||||
id: row.note_id,
|
||||
body: row.body,
|
||||
createdAt: row.created_at_text,
|
||||
updatedAt: row.updated_at_text,
|
||||
};
|
||||
}
|
||||
|
||||
let fallbackSetupPromise: Promise<void> | null = null;
|
||||
|
||||
async function ensureFallbackTable() {
|
||||
if (!fallbackSetupPromise) {
|
||||
fallbackSetupPromise = (async () => {
|
||||
const schema = await requestTextMemoApi<{ items: SchemaTableRow[] }>('/schema/tables');
|
||||
const hasTable = schema.items.some((item) => item.table_name === TEXT_MEMO_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await requestTextMemoApi('/ddl/raw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sql: `
|
||||
create table if not exists ${TEXT_MEMO_TABLE} (
|
||||
id bigserial primary key,
|
||||
client_id varchar(120) not null,
|
||||
note_id varchar(120) not null,
|
||||
body text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
created_at_text text null,
|
||||
updated_at_text text null
|
||||
);
|
||||
create unique index if not exists ${TEXT_MEMO_TABLE}_client_note_uidx on ${TEXT_MEMO_TABLE} (client_id, note_id);
|
||||
create index if not exists ${TEXT_MEMO_TABLE}_client_updated_idx on ${TEXT_MEMO_TABLE} (client_id, updated_at desc);
|
||||
`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
await requestTextMemoApi('/ddl/raw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sql: `
|
||||
alter table ${TEXT_MEMO_TABLE} add column if not exists created_at_text text null;
|
||||
alter table ${TEXT_MEMO_TABLE} add column if not exists updated_at_text text null;
|
||||
`,
|
||||
}),
|
||||
});
|
||||
})().catch((error) => {
|
||||
fallbackSetupPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
await fallbackSetupPromise;
|
||||
}
|
||||
|
||||
async function selectFallbackRows() {
|
||||
await ensureFallbackTable();
|
||||
const clientId = getRequiredClientId();
|
||||
|
||||
const response = await requestTextMemoApi<CrudResponse<RawTextMemoRow>>(`/crud/${TEXT_MEMO_TABLE}/select`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
columns: ['note_id', 'body', 'created_at_text', 'updated_at_text'],
|
||||
where: [{ field: 'client_id', operator: 'eq', value: clientId }],
|
||||
orderBy: [
|
||||
{ field: 'updated_at', direction: 'desc' },
|
||||
{ field: 'id', direction: 'desc' },
|
||||
],
|
||||
limit: 12,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.rows.map(mapRow);
|
||||
}
|
||||
|
||||
async function trimFallbackRows() {
|
||||
const clientId = getRequiredClientId();
|
||||
const response = await requestTextMemoApi<CrudResponse<{ note_id: string }>>(`/crud/${TEXT_MEMO_TABLE}/select`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
columns: ['note_id'],
|
||||
where: [{ field: 'client_id', operator: 'eq', value: clientId }],
|
||||
orderBy: [
|
||||
{ field: 'updated_at', direction: 'desc' },
|
||||
{ field: 'id', direction: 'desc' },
|
||||
],
|
||||
limit: 500,
|
||||
offset: 12,
|
||||
}),
|
||||
});
|
||||
|
||||
const extraIds = response.rows.map((row) => row.note_id).filter(Boolean);
|
||||
|
||||
if (extraIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestTextMemoApi(`/crud/${TEXT_MEMO_TABLE}/delete`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
where: [
|
||||
{ field: 'client_id', operator: 'eq', value: clientId },
|
||||
{ field: 'note_id', operator: 'in', value: extraIds },
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchTextMemoNotesFallback() {
|
||||
return selectFallbackRows();
|
||||
}
|
||||
|
||||
async function createTextMemoNoteFallback(payload: {
|
||||
id?: string;
|
||||
body: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}) {
|
||||
await ensureFallbackTable();
|
||||
const clientId = getRequiredClientId();
|
||||
|
||||
const noteId = payload.id?.trim() || `memo-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const now = new Date().toISOString();
|
||||
const createdAt = payload.createdAt?.trim() || now;
|
||||
const updatedAt = payload.updatedAt?.trim() || createdAt;
|
||||
|
||||
const response = await requestTextMemoApi<CrudResponse<RawTextMemoRow>>(`/crud/${TEXT_MEMO_TABLE}/insert`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
client_id: clientId,
|
||||
note_id: noteId,
|
||||
body: payload.body,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
created_at_text: createdAt,
|
||||
updated_at_text: updatedAt,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await trimFallbackRows();
|
||||
return mapRow(response.rows[0]);
|
||||
}
|
||||
|
||||
async function updateTextMemoNoteFallback(noteId: string, payload: { body: string }) {
|
||||
await ensureFallbackTable();
|
||||
const clientId = getRequiredClientId();
|
||||
|
||||
const response = await requestTextMemoApi<CrudResponse<RawTextMemoRow>>(`/crud/${TEXT_MEMO_TABLE}/update`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
body: payload.body,
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_at_text: new Date().toISOString(),
|
||||
},
|
||||
where: [
|
||||
{ field: 'client_id', operator: 'eq', value: clientId },
|
||||
{ field: 'note_id', operator: 'eq', value: noteId },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.rows[0]) {
|
||||
throw new TextMemoApiError('수정할 메모를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
return mapRow(response.rows[0]);
|
||||
}
|
||||
|
||||
async function deleteTextMemoNoteFallback(noteId: string) {
|
||||
await ensureFallbackTable();
|
||||
const clientId = getRequiredClientId();
|
||||
|
||||
const response = await requestTextMemoApi<CrudResponse<RawTextMemoRow>>(`/crud/${TEXT_MEMO_TABLE}/delete`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
where: [
|
||||
{ field: 'client_id', operator: 'eq', value: clientId },
|
||||
{ field: 'note_id', operator: 'eq', value: noteId },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if ((response.rows?.length ?? 0) === 0) {
|
||||
throw new TextMemoApiError('삭제할 메모를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
}
|
||||
|
||||
async function importTextMemoNotesFallback(notes: Array<{ id: string; body: string; createdAt: string }>) {
|
||||
const existingNotes = await fetchTextMemoNotesFallback();
|
||||
if (existingNotes.length > 0) {
|
||||
return existingNotes;
|
||||
}
|
||||
|
||||
const sortedNotes = [...notes]
|
||||
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
|
||||
.slice(0, 12);
|
||||
|
||||
for (const note of sortedNotes) {
|
||||
await createTextMemoNoteFallback({
|
||||
id: note.id,
|
||||
body: note.body,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
return fetchTextMemoNotesFallback();
|
||||
}
|
||||
|
||||
export async function fetchTextMemoNotes() {
|
||||
try {
|
||||
const response = await requestTextMemoApi<TextMemoApiListResponse>(TEXT_MEMO_API_PATH);
|
||||
return response.items;
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return fetchTextMemoNotesFallback();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTextMemoNote(payload: {
|
||||
id?: string;
|
||||
body: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}) {
|
||||
try {
|
||||
const response = await requestTextMemoApi<TextMemoApiItemResponse>(TEXT_MEMO_API_PATH, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return response.item;
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return createTextMemoNoteFallback(payload);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTextMemoNote(noteId: string, payload: { body: string }) {
|
||||
try {
|
||||
const response = await requestTextMemoApi<TextMemoApiItemResponse>(
|
||||
`${TEXT_MEMO_API_PATH}/${encodeURIComponent(noteId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
return response.item;
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return updateTextMemoNoteFallback(noteId, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTextMemoNote(noteId: string) {
|
||||
try {
|
||||
await requestTextMemoApi<{ ok: boolean }>(`${TEXT_MEMO_API_PATH}/${encodeURIComponent(noteId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await deleteTextMemoNoteFallback(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function importTextMemoNotes(notes: Array<{ id: string; body: string; createdAt: string }>) {
|
||||
try {
|
||||
const response = await requestTextMemoApi<TextMemoApiListResponse>(`${TEXT_MEMO_API_PATH}/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notes }),
|
||||
});
|
||||
|
||||
return response.items;
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return importTextMemoNotesFallback(notes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user