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
This commit is contained in:
Tienson Qin
2026-05-01 00:15:32 +08:00
parent 4bbb53cdf3
commit 9cfbaf80dc
9 changed files with 276 additions and 86 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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 <get-debug-datoms
[repo]

View File

@@ -27,20 +27,24 @@
(util/profile
:export-blocks-as-markdown
(try
(let [open-blocks-only? (boolean (get-in options [:other-options :open-blocks-only]))
(let [remove-options (set (:remove-options options))
include-properties? (not (contains? remove-options :property))
open-blocks-only? (boolean (get-in options [:other-options :open-blocks-only]))
content
(cond
;; page
(and (= 1 (count root-block-uuids-or-page-uuid))
(ldb/page? (db/entity [:block/uuid (first root-block-uuids-or-page-uuid)])))
(common/get-page-content (first root-block-uuids-or-page-uuid)
{:open-blocks-only? open-blocks-only?})
{:open-blocks-only? open-blocks-only?
:include-properties? include-properties?})
(and (coll? root-block-uuids-or-page-uuid) (every? #(ldb/page? (db/entity [:block/uuid %])) root-block-uuids-or-page-uuid))
(->> (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)]

View File

@@ -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)

View File

@@ -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) => {

View File

@@ -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 {} " " {}))))))

View File

@@ -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<N
(are [expect block-uuid-s]
(= expect (string/trim (export-text/export-blocks-as-markdown (state/get-current-repo) [(uuid block-uuid-s)]

View File

@@ -171,6 +171,28 @@
#js {:clipboardData #js {:getData (constantly clipboard)}})]
(is (= expected-blocks @actual-blocks))))))
(deftest-async editor-on-paste-prefers-blocks-from-memory-when-clipboard-custom-type-is-missing
(let [actual-blocks (atom nil)
expected-blocks [{:block/title "Memory cache block"}]
clipboard-blocks-str (pr-str {:graph "test"
:blocks expected-blocks})
clipboard "fallback text"]
(p/with-redefs
[util/stop (constantly nil)
state/get-current-repo (constantly "test")
paste-handler/get-copied-blocks (fn []
(throw (js/Error. "should not read async clipboard when memory cache has payload")))
utils/getCopiedBlocksFromMemory (fn [text]
(when (= text clipboard)
clipboard-blocks-str))
editor-handler/paste-blocks (fn [blocks _] (reset! actual-blocks blocks))]
(p/let [_ ((paste-handler/editor-on-paste! nil)
#js {:clipboardData #js {:getData (fn [k]
(cond
(= k "text") clipboard
:else ""))}})]
(is (= expected-blocks @actual-blocks))))))
(deftest-async editor-on-paste-with-selection-in-property
(let [clipboard "after"
expected-paste "after"