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 { registerPlanRoutes } from './routes/plan.js';
|
||||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||||
import { registerSchemaRoutes } from './routes/schema.js';
|
import { registerSchemaRoutes } from './routes/schema.js';
|
||||||
|
import { registerTextMemoRoutes } from './routes/text-memo.js';
|
||||||
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
||||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||||
import { createErrorLog } from './services/error-log-service.js';
|
import { createErrorLog } from './services/error-log-service.js';
|
||||||
@@ -37,6 +38,7 @@ export function createApp() {
|
|||||||
app.register(registerNotificationRoutes);
|
app.register(registerNotificationRoutes);
|
||||||
app.register(registerPlanRoutes);
|
app.register(registerPlanRoutes);
|
||||||
app.register(registerServerCommandRoutes);
|
app.register(registerServerCommandRoutes);
|
||||||
|
app.register(registerTextMemoRoutes);
|
||||||
app.register(registerVisitorHistoryRoutes);
|
app.register(registerVisitorHistoryRoutes);
|
||||||
|
|
||||||
app.setNotFoundHandler(async (request, reply) => {
|
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',
|
id: 'text-memo-widget',
|
||||||
title: 'Text Memo Widget',
|
title: 'Text Memo Widget',
|
||||||
description:
|
description:
|
||||||
'텍스트 메모를 작성하고 최근 저장본을 브라우저 Local Storage에 유지한 뒤 다시 불러올 수 있는 위젯입니다.',
|
'텍스트 메모를 작성하고 최근 저장본을 work-server DB에 보관해 다시 불러올 수 있는 위젯입니다.',
|
||||||
features: ['component-sample', 'feature-registry', 'imperative-handle'],
|
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 { renderModalWithEnterConfirm } from '../../app/main/modalKeyboard';
|
||||||
import { WidgetShell } from '../core';
|
import { WidgetShell } from '../core';
|
||||||
import type { WidgetHandle } from '../core';
|
import type { WidgetHandle } from '../core';
|
||||||
|
import {
|
||||||
|
createTextMemoNote,
|
||||||
|
deleteTextMemoNote as deleteTextMemoNoteFromServer,
|
||||||
|
fetchTextMemoNotes,
|
||||||
|
importTextMemoNotes,
|
||||||
|
updateTextMemoNote as updateTextMemoNoteFromServer,
|
||||||
|
type TextMemoNoteRecord,
|
||||||
|
} from './textMemoApi';
|
||||||
import './TextMemoWidget.css';
|
import './TextMemoWidget.css';
|
||||||
|
|
||||||
const STORAGE_KEY = 'ai-code-app:text-memo-widget';
|
const STORAGE_KEY = 'ai-code-app:text-memo-widget';
|
||||||
@@ -14,6 +22,7 @@ type MemoNote = {
|
|||||||
id: string;
|
id: string;
|
||||||
body: string;
|
body: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function readStoredNotes() {
|
function readStoredNotes() {
|
||||||
@@ -40,12 +49,32 @@ function readStoredNotes() {
|
|||||||
typeof note.body === 'string' &&
|
typeof note.body === 'string' &&
|
||||||
typeof note.createdAt === 'string'
|
typeof note.createdAt === 'string'
|
||||||
);
|
);
|
||||||
});
|
}).map((note) => ({
|
||||||
|
...note,
|
||||||
|
updatedAt: typeof note.updatedAt === 'string' ? note.updatedAt : note.createdAt,
|
||||||
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
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) {
|
function formatMemoTimestamp(value: string) {
|
||||||
return new Intl.DateTimeFormat('ko-KR', {
|
return new Intl.DateTimeFormat('ko-KR', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -75,26 +104,69 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [isListOpen, setIsListOpen] = useState(false);
|
const [isListOpen, setIsListOpen] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(true);
|
const [isEditing, setIsEditing] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedNotes = readStoredNotes();
|
let cancelled = false;
|
||||||
setNotes(storedNotes);
|
|
||||||
|
|
||||||
if (storedNotes[0]) {
|
void (async () => {
|
||||||
setSelectedId(storedNotes[0].id);
|
setIsLoading(true);
|
||||||
setBody(storedNotes[0].body);
|
|
||||||
setIsEditing(false);
|
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(() => {
|
const selectedIndex = useMemo(() => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -107,50 +179,49 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
const selectedNote = selectedIndex >= 0 ? notes[selectedIndex] : null;
|
const selectedNote = selectedIndex >= 0 ? notes[selectedIndex] : null;
|
||||||
const isDirty = selectedNote ? selectedNote.body !== body : hasDraft;
|
const isDirty = selectedNote ? selectedNote.body !== body : hasDraft;
|
||||||
|
|
||||||
const saveNote = () => {
|
const saveNote = async () => {
|
||||||
const trimmedBody = body.trim();
|
const trimmedBody = body.trim();
|
||||||
if (!trimmedBody) {
|
if (!trimmedBody || isSaving) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
setIsSaving(true);
|
||||||
|
|
||||||
if (selectedNote) {
|
try {
|
||||||
const updatedNote: MemoNote = {
|
if (selectedNote) {
|
||||||
...selectedNote,
|
const updatedNote = toMemoNote(await updateTextMemoNoteFromServer(selectedNote.id, { body: trimmedBody }));
|
||||||
body: trimmedBody,
|
const nextNotes = [updatedNote, ...notes.filter((note) => note.id !== selectedNote.id)].slice(
|
||||||
createdAt: timestamp,
|
0,
|
||||||
};
|
MAX_NOTE_COUNT,
|
||||||
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);
|
setNotes(nextNotes);
|
||||||
setSelectedId(updatedNote.id);
|
setSelectedId(nextNote.id);
|
||||||
setBody(updatedNote.body);
|
setBody(nextNote.body);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
void messageApi.success('저장됨');
|
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 = () => {
|
const handlePrimaryAction = () => {
|
||||||
if (isEditing || isDirty || !selectedNote) {
|
if (isEditing || isDirty || !selectedNote) {
|
||||||
saveNote();
|
void saveNote();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +251,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
autoFocusButton: 'ok',
|
autoFocusButton: 'ok',
|
||||||
modalRender: renderModalWithEnterConfirm,
|
modalRender: renderModalWithEnterConfirm,
|
||||||
okButtonProps: { danger: true },
|
okButtonProps: { danger: true },
|
||||||
onOk: () => {
|
onOk: async () => {
|
||||||
if (isDraftOnly) {
|
if (isDraftOnly) {
|
||||||
setBody('');
|
setBody('');
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@@ -188,6 +259,14 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
return;
|
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 nextNotes = notes.filter((note) => note.id !== selectedNote.id);
|
||||||
const nextSelectedNote = nextNotes[0] ?? null;
|
const nextSelectedNote = nextNotes[0] ?? null;
|
||||||
|
|
||||||
@@ -245,7 +324,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
aria-label="메모 삭제"
|
aria-label="메모 삭제"
|
||||||
disabled={!selectedNote && !hasDraft}
|
disabled={isLoading || isSaving || (!selectedNote && !hasDraft)}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -253,6 +332,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<UnorderedListOutlined />}
|
icon={<UnorderedListOutlined />}
|
||||||
aria-label="메모 목록"
|
aria-label="메모 목록"
|
||||||
|
disabled={isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsListOpen((previous) => !previous);
|
setIsListOpen((previous) => !previous);
|
||||||
}}
|
}}
|
||||||
@@ -265,7 +345,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<LeftOutlined />}
|
icon={<LeftOutlined />}
|
||||||
aria-label="이전 메모"
|
aria-label="이전 메모"
|
||||||
disabled={selectedIndex <= 0}
|
disabled={isLoading || selectedIndex <= 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
moveSelection(-1);
|
moveSelection(-1);
|
||||||
}}
|
}}
|
||||||
@@ -275,7 +355,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<RightOutlined />}
|
icon={<RightOutlined />}
|
||||||
aria-label="다음 메모"
|
aria-label="다음 메모"
|
||||||
disabled={selectedIndex < 0 || selectedIndex >= notes.length - 1}
|
disabled={isLoading || selectedIndex < 0 || selectedIndex >= notes.length - 1}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
moveSelection(1);
|
moveSelection(1);
|
||||||
}}
|
}}
|
||||||
@@ -285,14 +365,18 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
icon={isEditing || isDirty || !selectedNote ? <CheckOutlined /> : <EditOutlined />}
|
icon={isEditing || isDirty || !selectedNote ? <CheckOutlined /> : <EditOutlined />}
|
||||||
aria-label={isEditing || isDirty || !selectedNote ? '저장' : '편집'}
|
aria-label={isEditing || isDirty || !selectedNote ? '저장' : '편집'}
|
||||||
disabled={!hasDraft && !selectedNote}
|
disabled={isLoading || isSaving || (!hasDraft && !selectedNote)}
|
||||||
onClick={handlePrimaryAction}
|
onClick={handlePrimaryAction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`text-memo-widget__body${isListOpen ? ' text-memo-widget__body--list-open' : ''}`}>
|
<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">
|
<div className="text-memo-widget__sheet">
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<div className="text-memo-widget__empty">
|
<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__editor">
|
||||||
<div className="text-memo-widget__meta">
|
<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>
|
<span>{body.length}/{MAX_BODY_LENGTH}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -331,7 +415,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
|||||||
placeholder="메모 입력"
|
placeholder="메모 입력"
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
maxLength={MAX_BODY_LENGTH}
|
maxLength={MAX_BODY_LENGTH}
|
||||||
readOnly={!isEditing && !!selectedNote}
|
readOnly={isSaving || (!isEditing && !!selectedNote)}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setBody(event.target.value);
|
setBody(event.target.value);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const sampleMeta: SampleMeta = {
|
|||||||
id: 'text-memo-widget',
|
id: 'text-memo-widget',
|
||||||
componentId: 'text-memo-widget',
|
componentId: 'text-memo-widget',
|
||||||
title: 'Text Memo Widget',
|
title: 'Text Memo Widget',
|
||||||
description: '짧은 텍스트 메모를 작성하고 최근 저장본을 Local Storage에 유지하는 위젯입니다.',
|
description: '짧은 텍스트 메모를 작성하고 최근 저장본을 DB에 유지하는 위젯입니다.',
|
||||||
category: 'Widgets',
|
category: 'Widgets',
|
||||||
kind: 'feature',
|
kind: 'feature',
|
||||||
variantLabel: 'Memo / Notes',
|
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