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;