mirror of
https://github.com/logseq/logseq.git
synced 2026-05-16 08:52:20 +00:00
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:
@@ -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
|
||||
|
||||
121
deps/cli/src/logseq/cli/common/file.cljs
vendored
121
deps/cli/src/logseq/cli/common/file.cljs
vendored
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
29
src/test/frontend/handler/export_property_test.cljs
Normal file
29
src/test/frontend/handler/export_property_test.cljs
Normal 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 {} " " {}))))))
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user