import { create } from "@bufbuild/protobuf"; import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt"; import { isEqual } from "lodash-es"; import { useCallback } from "react"; import { toast } from "react-hot-toast"; import type { LocalFile } from "@/components/memo-metadata"; import { memoServiceClient } from "@/grpcweb"; import { attachmentStore, memoStore } from "@/store"; import { Attachment, AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { Location, Memo, MemoRelation, Visibility } from "@/types/proto/api/v1/memo_service_pb"; import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; import type { Translations } from "@/utils/i18n"; interface MemoSaveContext { memoName?: string; parentMemoName?: string; visibility: Visibility; attachmentList: Attachment[]; relationList: MemoRelation[]; location?: Location; localFiles: LocalFile[]; createTime?: Date; updateTime?: Date; } interface MemoSaveCallbacks { onUploadingChange: (uploading: boolean) => void; onRequestingChange: (requesting: boolean) => void; onSuccess: (memoName: string) => void; onCancel: () => void; onReset: () => void; t: (key: Translations, params?: Record) => string; } async function uploadLocalFiles(localFiles: LocalFile[], onUploadingChange: (uploading: boolean) => void): Promise { if (localFiles.length === 0) return []; onUploadingChange(true); try { const attachments: Attachment[] = []; for (const { file } of localFiles) { const buffer = new Uint8Array(await file.arrayBuffer()); const attachment = await attachmentStore.createAttachment( create(AttachmentSchema, { filename: file.name, size: BigInt(file.size), type: file.type, content: buffer, }), ); attachments.push(attachment); } return attachments; } finally { onUploadingChange(false); } } function buildUpdateMask( prevMemo: Memo, content: string, allAttachments: Attachment[], context: MemoSaveContext, ): { mask: Set; patch: Partial } { const mask = new Set(); const patch: Partial = { name: prevMemo.name, content, }; if (!isEqual(content, prevMemo.content)) { mask.add("content"); patch.content = content; } if (!isEqual(context.visibility, prevMemo.visibility)) { mask.add("visibility"); patch.visibility = context.visibility; } if (!isEqual(allAttachments, prevMemo.attachments)) { mask.add("attachments"); patch.attachments = allAttachments; } if (!isEqual(context.relationList, prevMemo.relations)) { mask.add("relations"); patch.relations = context.relationList; } if (!isEqual(context.location, prevMemo.location)) { mask.add("location"); patch.location = context.location; } // Auto-update timestamp if content changed if (["content", "attachments", "relations", "location"].some((key) => mask.has(key))) { mask.add("update_time"); } // Handle custom timestamps if (context.createTime && !isEqual(context.createTime, prevMemo.createTime ? timestampDate(prevMemo.createTime) : undefined)) { mask.add("create_time"); patch.createTime = timestampFromDate(context.createTime); } if (context.updateTime && !isEqual(context.updateTime, prevMemo.updateTime ? timestampDate(prevMemo.updateTime) : undefined)) { mask.add("update_time"); patch.updateTime = timestampFromDate(context.updateTime); } return { mask, patch }; } export function useMemoSave(callbacks: MemoSaveCallbacks) { const { onUploadingChange, onRequestingChange, onSuccess, onCancel, onReset, t } = callbacks; const saveMemo = useCallback( async (content: string, context: MemoSaveContext) => { onRequestingChange(true); try { // 1. Upload local files const newAttachments = await uploadLocalFiles(context.localFiles, onUploadingChange); const allAttachments = [...context.attachmentList, ...newAttachments]; // 2. Update existing memo if (context.memoName) { const prevMemo = await memoStore.getOrFetchMemoByName(context.memoName); if (prevMemo) { const { mask, patch } = buildUpdateMask(prevMemo, content, allAttachments, context); if (mask.size === 0) { toast.error(t("editor.no-changes-detected")); onCancel(); return; } const memo = await memoStore.updateMemo(patch, Array.from(mask)); onSuccess(memo.name); } } else { // 3. Create new memo or comment const memo = context.parentMemoName ? await memoServiceClient.createMemoComment({ name: context.parentMemoName, comment: create(MemoSchema, { content, visibility: context.visibility, attachments: context.attachmentList, relations: context.relationList, location: context.location, }), }) : await memoStore.createMemo( create(MemoSchema, { content, visibility: context.visibility, attachments: allAttachments, relations: context.relationList, location: context.location, }), ); onSuccess(memo.name); } onReset(); } catch (error: unknown) { console.error(error); const errorMessage = error instanceof Error ? (error as { details?: string }).details || error.message : "Unknown error"; toast.error(errorMessage); } finally { onRequestingChange(false); } }, [onUploadingChange, onRequestingChange, onSuccess, onCancel, onReset, t], ); return { saveMemo }; }