From 747c61ea0e0998c7858cf3387b8759215c48130b Mon Sep 17 00:00:00 2001 From: cyl3el2cleal2 Date: Fri, 19 Dec 2025 14:08:38 +0700 Subject: [PATCH] feat: download memo --- .../MemoActionMenu/MemoActionMenu.tsx | 22 +++- web/src/components/MemoActionMenu/hooks.ts | 29 ++++- web/src/locales/en.json | 1 + web/src/utils/content.ts | 111 ++++++++++++++++++ 4 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 web/src/utils/content.ts diff --git a/web/src/components/MemoActionMenu/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx index 361e0a106..1d0305982 100644 --- a/web/src/components/MemoActionMenu/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu/MemoActionMenu.tsx @@ -4,6 +4,7 @@ import { BookmarkMinusIcon, BookmarkPlusIcon, CopyIcon, + DownloadIcon, Edit3Icon, FileTextIcon, LinkIcon, @@ -50,6 +51,7 @@ const MemoActionMenu = observer((props: MemoActionMenuProps) => { handleToggleMemoStatusClick, handleCopyLink, handleCopyContent, + handleDownloadContent, handleDeleteMemoClick, confirmDeleteMemo, handleRemoveCompletedTaskListItemsClick, @@ -74,7 +76,11 @@ const MemoActionMenu = observer((props: MemoActionMenuProps) => { <> {!isComment && ( - {memo.pinned ? : } + {memo.pinned ? ( + + ) : ( + + )} {memo.pinned ? t("common.unpin") : t("common.pin")} )} @@ -92,6 +98,10 @@ const MemoActionMenu = observer((props: MemoActionMenuProps) => { {t("common.copy")} + + + {t("memo.download")} + @@ -110,7 +120,9 @@ const MemoActionMenu = observer((props: MemoActionMenuProps) => { <> {/* Remove completed tasks (non-archived, non-comment, has completed tasks) */} {!isArchived && !isComment && hasCompletedTaskList && ( - + {t("memo.remove-completed-task-list-items")} @@ -119,7 +131,11 @@ const MemoActionMenu = observer((props: MemoActionMenuProps) => { {/* Archive/Restore (non-comment) */} {!isComment && ( - {isArchived ? : } + {isArchived ? ( + + ) : ( + + )} {isArchived ? t("common.restore") : t("common.archive")} )} diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts index 3f92da8f4..eb43dcc0b 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -6,8 +6,10 @@ import useNavigateTo from "@/hooks/useNavigateTo"; import { instanceStore, memoStore, userStore } from "@/store"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import { toAttachmentItems } from "@/components/memo-metadata"; import { useTranslate } from "@/utils/i18n"; import { removeCompletedTasks } from "@/utils/markdown-manipulation"; +import { downloadMemoContentAndAttachments } from "@/utils/content"; interface UseMemoActionHandlersOptions { memo: Memo; @@ -16,7 +18,12 @@ interface UseMemoActionHandlersOptions { setRemoveTasksDialogOpen: (open: boolean) => void; } -export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRemoveTasksDialogOpen }: UseMemoActionHandlersOptions) => { +export const useMemoActionHandlers = ({ + memo, + onEdit, + setDeleteDialogOpen, + setRemoveTasksDialogOpen, +}: UseMemoActionHandlersOptions) => { const t = useTranslate(); const location = useLocation(); const navigateTo = useNavigateTo(); @@ -46,7 +53,10 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe const handleToggleMemoStatusClick = useCallback(async () => { const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED; - const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully"); + const message = + memo.state === State.ARCHIVED + ? t("message.restored-successfully") + : t("message.archived-successfully"); try { await memoStore.updateMemo( @@ -68,7 +78,14 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived"); } memoUpdatedCallback(); - }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]); + }, [ + memo.name, + memo.state, + t, + isInMemoDetailPage, + navigateTo, + memoUpdatedCallback, + ]); const handleCopyLink = useCallback(() => { let host = instanceStore.state.profile.instanceUrl; @@ -88,6 +105,11 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe setDeleteDialogOpen(true); }, [setDeleteDialogOpen]); + const handleDownloadContent = useCallback(() => { + const attachmentItems = toAttachmentItems(memo.attachments, []); + downloadMemoContentAndAttachments(memo, attachmentItems); + }, [memo, memo.attachments]); + const confirmDeleteMemo = useCallback(async () => { await memoStore.deleteMemo(memo.name); toast.success(t("message.deleted-successfully")); @@ -121,6 +143,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe handleCopyLink, handleCopyContent, handleDeleteMemoClick, + handleDownloadContent, confirmDeleteMemo, handleRemoveCompletedTaskListItemsClick, confirmRemoveCompletedTaskListItems, diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 3a60b8a8e..ce5b25922 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -152,6 +152,7 @@ }, "copy-content": "Copy Content", "copy-link": "Copy Link", + "download": "Download", "count-memos-in-date": "{{count}} {{memos}} in {{date}}", "delete-confirm": "Are you sure you want to delete this memo?", "delete-confirm-description": "This action is irreversible. Attachments, links, and references will also be removed.", diff --git a/web/src/utils/content.ts b/web/src/utils/content.ts new file mode 100644 index 000000000..44f869f9c --- /dev/null +++ b/web/src/utils/content.ts @@ -0,0 +1,111 @@ +import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import type { AttachmentItem } from "@/components/memo-metadata"; + +/** + * Downloads a file using a temporary anchor element + * @param url - The URL to download from + * @param filename - The filename to save as + * @param isLocal - Whether this is a local file that needs special handling + */ +const downloadFile = ( + url: string, + filename: string, + isLocal: boolean = false, +): void => { + const downloadElement = document.createElement("a"); + downloadElement.href = url; + downloadElement.download = filename; + + // For local files, ensure proper download behavior + if (isLocal) { + downloadElement.setAttribute("download", filename); + downloadElement.target = "_blank"; + } + + document.body.appendChild(downloadElement); + downloadElement.click(); + document.body.removeChild(downloadElement); +}; + +/** + * Creates a date prefix in YYYY/MM/DD format + * @param date - Date object to format (defaults to current date) + * @returns Formatted date string + */ +const createDatePrefix = (date: Date = new Date()): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}/${month}/${day}`; +}; + +/** + * Extracts memo short name from full name path + * @param memoName - Full memo name (format: memos/{id}) + * @returns Short memo name + */ +const extractMemoName = (memoName: string): string => { + return memoName.split("/").pop() || "memo"; +}; + +/** + * Downloads memo content as a markdown file if content exists + * @param memo - The memo object + * @param namePrefix - The filename prefix to use + */ +const downloadMemoContent = (memo: Memo, namePrefix: string): void => { + if (!memo.content || !memo.content.trim()) { + return; + } + + const contentBlob = new Blob([memo.content], { + type: "text/markdown;charset=utf-8", + }); + const contentUrl = URL.createObjectURL(contentBlob); + + downloadFile(contentUrl, `${namePrefix}.md`); + + URL.revokeObjectURL(contentUrl); +}; + +/** + * Downloads all attachments from a memo + * @param attachmentItems - Array of attachment items + * @param namePrefix - The filename prefix to use + */ +const downloadAttachments = ( + attachmentItems: AttachmentItem[], + namePrefix: string, +): void => { + attachmentItems.forEach((item) => { + downloadFile( + item.sourceUrl, + `${namePrefix} - ${item.filename}`, + item.isLocal, + ); + }); +}; + +/** + * Downloads all content and attachments from a memo + * @param memo - The memo object + * @param attachmentItems - Array of attachment items + */ +export const downloadMemoContentAndAttachments = ( + memo: Memo, + attachmentItems: AttachmentItem[], +): void => { + const date = new Date(); + const datePrefix = createDatePrefix(date); + const memoShortName = extractMemoName(memo.name); + const namePrefix = `${datePrefix} ${memoShortName}`; + + // Download memo content first + downloadMemoContent(memo, namePrefix); + + // Then download all attachments + downloadAttachments(attachmentItems, namePrefix); +}; + +// Legacy export for backward compatibility +export const download = downloadMemoContentAndAttachments;