From 9cfbaf80dcc98d691bc76d4eb99f2d57c7c209fc Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 1 May 2026 00:15:32 +0800 Subject: [PATCH] fix: normalize copy/paste export property data - prefer memory-backed copied blocks before async clipboard read fallback in paste flow - normalize clipboard write payload construction for web ClipboardItem - render exported property keys with property titles instead of db ident suffixes - render datetime property integer values as journal titles using export date formatter - add regression tests for paste and export property rendering --- .../src/logseq/cli/common/export/common.cljs | 11 +- deps/cli/src/logseq/cli/common/file.cljs | 121 +++++++++++++++--- src/main/frontend/handler/export/common.cljs | 13 +- src/main/frontend/handler/export/text.cljs | 10 +- src/main/frontend/handler/paste.cljs | 19 ++- src/main/frontend/utils.js | 110 +++++++++------- .../handler/export_property_test.cljs | 29 +++++ src/test/frontend/handler/export_test.cljs | 27 +++- src/test/frontend/handler/paste_test.cljs | 22 ++++ 9 files changed, 276 insertions(+), 86 deletions(-) create mode 100644 src/test/frontend/handler/export_property_test.cljs diff --git a/deps/cli/src/logseq/cli/common/export/common.cljs b/deps/cli/src/logseq/cli/common/export/common.cljs index a6053a1ba9..d45e32ce18 100644 --- a/deps/cli/src/logseq/cli/common/export/common.cljs +++ b/deps/cli/src/logseq/cli/common/export/common.cljs @@ -76,7 +76,7 @@ tree)) (defn ^:api get-blocks-contents - [root-block-uuid & {:keys [init-level open-blocks-only?] + [root-block-uuid & {:keys [init-level open-blocks-only? include-properties?] :or {init-level 1}}] (let [block (d/entity *current-db* [:block/uuid root-block-uuid]) link (:block/link block) @@ -88,7 +88,9 @@ (remove-collapsed-descendants tree) tree)] (common-file/tree->file-content *current-db* tree - {:init-level init-level :link link} + {:init-level init-level + :include-properties? include-properties? + :link link} *content-config*))) (declare remove-block-ast-pos Properties-block-ast?) @@ -113,10 +115,11 @@ (gp-mldoc/->db-edn content format)))))) (defn ^:api get-page-content - [page-uuid & {:keys [open-blocks-only?]}] + [page-uuid & {:keys [open-blocks-only? include-properties?]}] (common-file/block->content *current-db* page-uuid - {:open-blocks-only? open-blocks-only?} + {:open-blocks-only? open-blocks-only? + :include-properties? include-properties?} *content-config*)) (defn- page-name->ast diff --git a/deps/cli/src/logseq/cli/common/file.cljs b/deps/cli/src/logseq/cli/common/file.cljs index 6a983549e7..0df8bff5fb 100644 --- a/deps/cli/src/logseq/cli/common/file.cljs +++ b/deps/cli/src/logseq/cli/common/file.cljs @@ -2,8 +2,10 @@ "Convert blocks to file content. Used for frontend exports and CLI" (:require [clojure.string :as string] [datascript.core :as d] + [logseq.common.util.date-time :as date-time-util] [logseq.db :as ldb] [logseq.db.frontend.content :as db-content] + [logseq.db.frontend.property :as db-property] [logseq.db.sqlite.create-graph :as sqlite-create-graph] [logseq.outliner.tree :as otree])) @@ -12,32 +14,109 @@ (let [lines (string/split-lines content)] (string/join (str "\n" spaces-tabs) lines))) +(defn- datetime-journal-title + [v context] + (when (integer? v) + (let [journal-day (cond + (<= 10000101 v 99991231) + v + + (>= v 100000000000) + (date-time-util/ms->journal-day v) + + :else + nil)] + (when journal-day + (date-time-util/int->journal-title + journal-day + (or (:date-formatter context) + date-time-util/default-journal-title-formatter)))))) + +(defn- property-value->string + [property v context] + (cond + (and (map? v) (:db/id v)) + (str (db-property/property-value-content v)) + + (set? v) + (->> v + (sort-by (fn [item] + [(if (:block/order item) 0 1) + (str (or (:block/order item) + (property-value->string property item context)))])) + (map #(property-value->string property % context)) + (string/join ", ")) + + (sequential? v) + (->> v + (map #(property-value->string property % context)) + (string/join ", ")) + + (keyword? v) + (name v) + + (and (= :datetime (:logseq.property/type property)) + (integer? v)) + (or (datetime-journal-title v context) + (str v)) + + (some? v) + (str v))) + +(defn- block-properties-content + [db block spaces-tabs context] + (let [properties (->> (db-property/properties block) + (remove (fn [[k _]] + (contains? db-property/db-attribute-properties k))) + (remove (fn [[k _]] + (:logseq.property/hide? (d/entity db k)))) + (into {}))] + (when (seq properties) + (let [sorted-properties (->> (keys properties) + (keep (fn [k] (d/entity db k))) + db-property/sort-properties)] + (->> sorted-properties + (keep (fn [property] + (let [property-ident (:db/ident property)] + (when (contains? properties property-ident) + (str spaces-tabs + (or (:block/title property) + (:block/raw-title property) + (name property-ident)) + ":: " + (property-value->string property (get properties property-ident) context)))))) + (string/join "\n")))))) + (defn- transform-content - [db b level {:keys [heading-to-list?]} context] + [db b level {:keys [heading-to-list? include-properties?] + :or {include-properties? true}} context] (let [heading (:logseq.property/heading b) ;; replace [[uuid]] with block's content title (db-content/recur-replace-uuid-in-block-title (d/entity db (:db/id b))) content (or title "") - content (let [[prefix spaces-tabs] - (let [level (if (and heading-to-list? heading) - (if (> heading 1) - (dec heading) - heading) - level) - spaces-tabs (->> - (repeat (dec level) (:export-bullet-indentation context)) - (apply str))] - [(str spaces-tabs "-") (str spaces-tabs " ")]) - content (if heading-to-list? - (-> (string/replace content #"^\s?#+\s+" "") - (string/replace #"^\s?#+\s?$" "")) - content) - new-content (indented-block-content (string/trim content) spaces-tabs) - sep (if (string/blank? new-content) - "" - " ")] - (str prefix sep new-content))] - content)) + level (if (and heading-to-list? heading) + (if (> heading 1) + (dec heading) + heading) + level) + spaces-tabs (->> + (repeat (dec level) (:export-bullet-indentation context)) + (apply str)) + prefix (str spaces-tabs "-") + property-spaces-tabs (str spaces-tabs " ") + content (if heading-to-list? + (-> (string/replace content #"^\s?#+\s+" "") + (string/replace #"^\s?#+\s?$" "")) + content) + new-content (indented-block-content (string/trim content) property-spaces-tabs) + sep (if (string/blank? new-content) + "" + " ") + content (str prefix sep new-content)] + (if-let [properties-content (when-not (false? include-properties?) + (block-properties-content db b property-spaces-tabs context))] + (str content "\n" properties-content) + content))) (defn- tree->file-content-aux [db tree {:keys [init-level link] :as opts} context] diff --git a/src/main/frontend/handler/export/common.cljs b/src/main/frontend/handler/export/common.cljs index dbf2d52c0b..cc365193d6 100644 --- a/src/main/frontend/handler/export/common.cljs +++ b/src/main/frontend/handler/export/common.cljs @@ -7,18 +7,20 @@ [promesa.core :as p])) (defn get-content-config [] - {:export-bullet-indentation (state/get-export-bullet-indentation)}) + {:export-bullet-indentation (state/get-export-bullet-indentation) + :date-formatter (state/get-date-formatter)}) (defn root-block-uuids->content "Converts given block uuids to content for given repo" ([repo root-block-uuids] (root-block-uuids->content repo root-block-uuids nil)) - ([repo root-block-uuids {:keys [open-blocks-only?]}] + ([repo root-block-uuids {:keys [open-blocks-only? include-properties?]}] (binding [cli-export-common/*current-db* (conn/get-db repo) cli-export-common/*content-config* (get-content-config)] (let [contents (mapv (fn [id] (cli-export-common/get-blocks-contents id - :open-blocks-only? open-blocks-only?)) + :open-blocks-only? open-blocks-only? + :include-properties? include-properties?)) root-block-uuids)] (string/join "\n" (mapv string/trim-newline contents)))))) @@ -26,11 +28,12 @@ "Gets page content for current repo, db and state" ([page-uuid] (get-page-content page-uuid nil)) - ([page-uuid {:keys [open-blocks-only?]}] + ([page-uuid {:keys [open-blocks-only? include-properties?]}] (binding [cli-export-common/*current-db* (conn/get-db (state/get-current-repo)) cli-export-common/*content-config* (get-content-config)] (cli-export-common/get-page-content page-uuid - :open-blocks-only? open-blocks-only?)))) + :open-blocks-only? open-blocks-only? + :include-properties? include-properties?)))) (defn > (mapv (fn [id] (:block/title (db/entity [:block/uuid id]))) root-block-uuids-or-page-uuid) (string/join "\n")) :else (common/root-block-uuids->content repo root-block-uuids-or-page-uuid - {:open-blocks-only? open-blocks-only?})) + {:open-blocks-only? open-blocks-only? + :include-properties? include-properties?})) first-block (and (coll? root-block-uuids-or-page-uuid) (db/entity [:block/uuid (first root-block-uuids-or-page-uuid)])) format (get first-block :block/format :markdown)] diff --git a/src/main/frontend/handler/paste.cljs b/src/main/frontend/handler/paste.cljs index 9fa8badf73..69e5f94e18 100644 --- a/src/main/frontend/handler/paste.cljs +++ b/src/main/frontend/handler/paste.cljs @@ -88,10 +88,21 @@ (when (contains? (set types) "web application/logseq") (.getType ^js (first clipboard-items) "web application/logseq")))) - blocks-str (when blocks-blob (.text blocks-blob))] + blocks-str (when (and blocks-blob (pos? (.-size blocks-blob))) + (.text blocks-blob))] (when blocks-str (common-util/safe-read-map-string blocks-str)))) +(defn- get-copied-blocks-from-memory + [text] + (when-let [blocks-str (utils/getCopiedBlocksFromMemory text)] + (let [copied-blocks (and (string? blocks-str) + (not (string/blank? blocks-str)) + (string/starts-with? (string/triml blocks-str) "{") + (common-util/safe-read-map-string blocks-str))] + (when (seq (:blocks copied-blocks)) + copied-blocks)))) + (defn- markdown-blocks? [text] (boolean (util/safe-re-find #"(?m)^\s*(?:[-+*]|#+)\s+" text))) @@ -161,9 +172,11 @@ (defn- paste-copied-blocks-or-text [input text e html] (util/stop e) - (let [repo (state/get-current-repo)] + (let [repo (state/get-current-repo) + copied-blocks-from-memory (get-copied-blocks-from-memory text)] (-> - (p/let [{:keys [graph blocks embed-block?]} (get-copied-blocks)] + (p/let [{:keys [graph blocks embed-block?]} (or copied-blocks-from-memory + (get-copied-blocks))] (if (and (seq blocks) (= graph repo)) ;; Handle internal paste (let [revert-cut-txs (get-revert-cut-txs blocks) diff --git a/src/main/frontend/utils.js b/src/main/frontend/utils.js index a3f93d5067..8906e0c366 100644 --- a/src/main/frontend/utils.js +++ b/src/main/frontend/utils.js @@ -260,57 +260,71 @@ export const getClipText = (cb, errorHandler) => { }) } -export const writeClipboard = ({text, html, blocks}, ownerWindow) => { - const navigator = (ownerWindow || window).navigator +const copiedBlocksMemoryCache = { + text: null, + blocks: null +} - navigator.permissions.query({ - name: "clipboard-write" - }).then((result) => { - if (result.state != "granted" && result.state != "prompt"){ - console.debug("Copy without `clipboard-write` permission:", text) - return - } - let promise_written = null - if (typeof ClipboardItem !== 'undefined') { - let blob = new Blob([text], { - type: ["text/plain"] - }); - let data = [new ClipboardItem({ - ["text/plain"]: blob - })]; - if (html) { - let richBlob = new Blob([html], { - type: ["text/html"] - }) - data = [new ClipboardItem({ - ["text/plain"]: blob, - ["text/html"]: richBlob - })]; - } - if (blocks) { - let blocksBlob = new Blob([blocks], { - type: ["web application/logseq"] - }) - let richBlob = new Blob([html], { - type: ["text/html"] - }) - data = [new ClipboardItem({ - ["text/plain"]: blob, - ["text/html"]: richBlob, - ["web application/logseq"]: blocksBlob - })]; - } - promise_written = navigator.clipboard.write(data) - } else { - console.debug("Degraded copy without `ClipboardItem` support:", text) - promise_written = navigator.clipboard.writeText(text) - } - promise_written.then(() => { - /* success */ - }).catch(e => { - console.log(e, "fail") +export const getCopiedBlocksFromMemory = (text) => { + if (!text || copiedBlocksMemoryCache.text !== text) return null + return copiedBlocksMemoryCache.blocks +} + +export const writeClipboard = ({text, html, blocks}, ownerWindow) => { + const navigator = (ownerWindow || window).navigator + const textBlob = new Blob([text], { + type: "text/plain" + }) + copiedBlocksMemoryCache.text = text + copiedBlocksMemoryCache.blocks = blocks || null + + navigator.permissions.query({ + name: "clipboard-write" + }).then((result) => { + if (result.state != "granted" && result.state != "prompt"){ + console.debug("Copy without `clipboard-write` permission:", text) + return + } + let promise_written = null + if (typeof ClipboardItem !== "undefined") { + let data = [new ClipboardItem({ + ["text/plain"]: textBlob + })] + if (html) { + const richBlob = new Blob([html], { + type: "text/html" }) + data = [new ClipboardItem({ + ["text/plain"]: textBlob, + ["text/html"]: richBlob + })] + } + if (blocks) { + const blocksBlob = new Blob([blocks], { + type: "application/logseq" + }) + const clipboardItemData = { + ["text/plain"]: textBlob, + ["web application/logseq"]: blocksBlob + } + if (html) { + clipboardItemData["text/html"] = new Blob([html], { + type: "text/html" + }) + } + data = [new ClipboardItem(clipboardItemData)] + } + promise_written = navigator.clipboard.write(data) + } else { + console.debug("Degraded copy without `ClipboardItem` support:", text) + promise_written = navigator.clipboard.writeText(text) + } + promise_written.then(() => { + /* success */ + }).catch(e => { + console.log(e, "fail") }) + }) } export const toPosixPath = (input) => { diff --git a/src/test/frontend/handler/export_property_test.cljs b/src/test/frontend/handler/export_property_test.cljs new file mode 100644 index 0000000000..f9852c05fd --- /dev/null +++ b/src/test/frontend/handler/export_property_test.cljs @@ -0,0 +1,29 @@ +(ns frontend.handler.export-property-test + (:require [cljs.test :refer [deftest is]] + [datascript.core :as d] + [logseq.cli.common.file :as common-file] + [logseq.common.util.date-time :as date-time-util] + [logseq.db.frontend.property :as db-property])) + +(deftest block-properties-content-uses-property-title-and-journal-title-for-datetime + (let [datetime-ms 1776441600000 + expected-journal-title (date-time-util/int->journal-title + (date-time-util/ms->journal-day datetime-ms) + date-time-util/default-journal-title-formatter) + properties (array-map + :logseq.property/deadline datetime-ms + :user.property/P1-MoCeM8Tf "hello")] + (with-redefs [db-property/properties (constantly properties) + db-property/sort-properties (fn [prop-entities] prop-entities) + d/entity (fn [_db lookup] + (case lookup + :logseq.property/deadline {:db/ident :logseq.property/deadline + :block/title "deadline" + :logseq.property/type :datetime} + :user.property/P1-MoCeM8Tf {:db/ident :user.property/P1-MoCeM8Tf + :block/title "P1" + :logseq.property/type :default} + nil))] + (is (= (str " deadline:: " expected-journal-title "\n" + " P1:: hello") + (@#'common-file/block-properties-content nil {} " " {})))))) diff --git a/src/test/frontend/handler/export_test.cljs b/src/test/frontend/handler/export_test.cljs index b2f58e5412..df510b7208 100644 --- a/src/test/frontend/handler/export_test.cljs +++ b/src/test/frontend/handler/export_test.cljs @@ -14,7 +14,8 @@ uuid-p2 #uuid "97a00e55-48c3-48d8-b9ca-417b16e3a616" uuid-5 #uuid "708f7836-c1e2-4212-bd26-b53c7e9f1449" uuid-6 #uuid "de7724d5-b045-453d-a643-31b81d310071" - uuid-p3 #uuid "de13830f-9691-4074-a0d6-cc8ab9cf9074"] + uuid-p3 #uuid "de13830f-9691-4074-a0d6-cc8ab9cf9074" + uuid-7 #uuid "f81f4f64-578a-42ff-8741-19adac45f42a"] [{:page {:block/title "page1"} :blocks [{:block/title "1" @@ -50,7 +51,13 @@ :build/children [{:block/title "hidden-child" :build/keep-uuid? true - :block/uuid uuid-6}]}]}])) + :block/uuid uuid-6}]}]} + {:page {:block/title "page4"} + :blocks + [{:block/title "issue" + :build/keep-uuid? true + :block/uuid uuid-7 + :build/properties {:user.property/reproducible-steps "Switch to a password protected graph"}}]}])) (use-fixtures :once {:before (fn [] @@ -85,6 +92,22 @@ - 4") "97a00e55-48c3-48d8-b9ca-417b16e3a616")) +(deftest export-blocks-as-markdown-with-properties + (is (= (string/trim " +- issue + reproducible-steps:: Switch to a password protected graph") + (string/trim + (export-text/export-blocks-as-markdown + (state/get-current-repo) + [(uuid "f81f4f64-578a-42ff-8741-19adac45f42a")] + {:remove-options #{}})))) + (is (= "- issue" + (string/trim + (export-text/export-blocks-as-markdown + (state/get-current-repo) + [(uuid "f81f4f64-578a-42ff-8741-19adac45f42a")] + {:remove-options #{:property}}))))) + (deftest export-blocks-as-markdown-level