diff --git a/web/src/components/MemoActionMenu/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx index afe2c0ce26e43..b8dfb545866cd 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, @@ -49,6 +50,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => { handleToggleMemoStatusClick, handleCopyLink, handleCopyContent, + handleDownloadContent, handleDeleteMemoClick, confirmDeleteMemo, handleRemoveCompletedTaskListItemsClick, @@ -73,7 +75,11 @@ const MemoActionMenu = (props: MemoActionMenuProps) => { <> {!isComment && ( - {memo.pinned ? : } + {memo.pinned ? ( + + ) : ( + + )} {memo.pinned ? t("common.unpin") : t("common.pin")} )} @@ -91,6 +97,10 @@ const MemoActionMenu = (props: MemoActionMenuProps) => { {t("common.copy")} + + + {t("memo.download")} + @@ -109,7 +119,9 @@ const MemoActionMenu = (props: MemoActionMenuProps) => { <> {/* Remove completed tasks (non-archived, non-comment, has completed tasks) */} {!isArchived && !isComment && hasCompletedTaskList && ( - + {t("memo.remove-completed-task-list-items")} @@ -118,7 +130,11 @@ const MemoActionMenu = (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 18db5b28d7d15..a101bd9e3e4e3 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -10,8 +10,10 @@ import { userKeys } from "@/hooks/useUserQueries"; import { handleError } from "@/lib/error"; 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; @@ -20,7 +22,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(); @@ -56,7 +63,10 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe const handleToggleMemoStatusClick = useCallback(async () => { const isArchiving = memo.state !== State.ARCHIVED; 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 updateMemo({ @@ -99,6 +109,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 deleteMemo(memo.name); toast.success(t("message.deleted-successfully")); @@ -132,6 +147,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 41d8974151428..4c7a8a6b93c45 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -154,6 +154,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 0000000000000..44f869f9cbf87 --- /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;