From 3f3d7b4978ca4cd96a9c6455b70d364ec4d17c5c Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 9 Dec 2023 15:00:33 -0500 Subject: [PATCH] Add 'Copy as Markdown' action Remove smart quotes from Markdown export, closes #5303 --- app/actions/definitions/documents.tsx | 21 ++++++++++++++++ server/models/helpers/DocumentHelper.tsx | 9 ++----- server/routes/api/documents/documents.ts | 7 +----- shared/i18n/locales/en_US/translation.json | 2 ++ shared/utils/MarkdownHelper.ts | 29 ++++++++++++++++++++++ 5 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 shared/utils/MarkdownHelper.ts diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 808c0beea..667cee99e 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -1,3 +1,4 @@ +import copy from "copy-to-clipboard"; import invariant from "invariant"; import { DownloadIcon, @@ -24,10 +25,12 @@ import { PublishIcon, CommentIcon, GlobeIcon, + CopyIcon, } from "outline-icons"; import * as React from "react"; import { toast } from "sonner"; import { ExportContentType, TeamPreference } from "@shared/types"; +import MarkdownHelper from "@shared/utils/MarkdownHelper"; import { getEventFiles } from "@shared/utils/files"; import SharePopover from "~/scenes/Document/components/SharePopover"; import DocumentDelete from "~/scenes/DocumentDelete"; @@ -111,6 +114,23 @@ export const createDocumentFromTemplate = createAction({ ), }); +export const copyDocumentAsMarkdown = createAction({ + name: ({ t }) => t("Copy as Markdown"), + section: DocumentSection, + icon: , + keywords: "clipboard", + visible: ({ activeDocumentId }) => !!activeDocumentId, + perform: ({ stores, activeDocumentId, t }) => { + const document = activeDocumentId + ? stores.documents.get(activeDocumentId) + : undefined; + if (document) { + copy(MarkdownHelper.toMarkdown(document)); + toast.success(t("Markdown copied to clipboard")); + } + }, +}); + export const createNestedDocument = createAction({ name: ({ t }) => t("New nested document"), analyticsName: "New document", @@ -889,6 +909,7 @@ export const rootDocumentActions = [ deleteDocument, importDocument, downloadDocument, + copyDocumentAsMarkdown, starDocument, unstarDocument, publishDocument, diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx index 68f4ddc21..ad2543dd1 100644 --- a/server/models/helpers/DocumentHelper.tsx +++ b/server/models/helpers/DocumentHelper.tsx @@ -10,6 +10,7 @@ import { Transaction } from "sequelize"; import * as Y from "yjs"; import textBetween from "@shared/editor/lib/textBetween"; import { AttachmentPreset } from "@shared/types"; +import MarkdownHelper from "@shared/utils/MarkdownHelper"; import { getCurrentDateAsString, getCurrentDateTimeAsString, @@ -88,13 +89,7 @@ export default class DocumentHelper { * @returns The document title and content as a Markdown string */ static toMarkdown(document: Document | Revision) { - const text = document.text.replace(/\n\\\n/g, "\n\n"); - - if (document.version) { - return `# ${document.title}\n\n${text}`; - } - - return text; + return MarkdownHelper.toMarkdown(document); } /** diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index a82bb88f1..cfc3fc4fb 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -536,13 +536,8 @@ router.post( contentType = "text/markdown"; content = DocumentHelper.toMarkdown(document); } else { - contentType = "application/json"; - content = DocumentHelper.toMarkdown(document); - } - - if (contentType === "application/json") { ctx.body = { - data: content, + data: DocumentHelper.toMarkdown(document), }; return; } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 09598a19a..81370ec6e 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -20,6 +20,8 @@ "Open document": "Open document", "New document": "New document", "New from template": "New from template", + "Copy as Markdown": "Copy as Markdown", + "Markdown copied to clipboard": "Markdown copied to clipboard", "New nested document": "New nested document", "Publish": "Publish", "Published {{ documentName }}": "Published {{ documentName }}", diff --git a/shared/utils/MarkdownHelper.ts b/shared/utils/MarkdownHelper.ts new file mode 100644 index 000000000..b40edfdda --- /dev/null +++ b/shared/utils/MarkdownHelper.ts @@ -0,0 +1,29 @@ +interface DocumentInterface { + emoji?: string | null; + title: string; + text: string; +} + +export default class MarkdownHelper { + /** + * Returns the document as cleaned Markdown for export. + * + * @param document The document or revision to convert + * @returns The document title and content as a Markdown string + */ + static toMarkdown(document: DocumentInterface) { + const text = document.text + .replace(/\n\\\n/g, "\n\n") + .replace(/“/g, '"') + .replace(/”/g, '"') + .replace(/‘/g, "'") + .replace(/’/g, "'") + .trim(); + + const title = `${document.emoji ? document.emoji + " " : ""}${ + document.title + }`; + + return `# ${title}\n\n${text}`; + } +}