Establishes version control for the personal Logseq DB fork before starting the server-client architecture migration and domain features described in docs/agent-guide and the approved implementation plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2485 lines
131 KiB
Clojure
2485 lines
131 KiB
Clojure
(ns ^:node-only logseq.graph-parser.exporter-test
|
|
(:require ["fs" :as fs]
|
|
["os" :as os]
|
|
["path" :as node-path]
|
|
[cljs.test :refer [are deftest is testing]]
|
|
[clojure.set :as set]
|
|
[clojure.string :as string]
|
|
[datascript.core :as d]
|
|
[logseq.common.config :as common-config]
|
|
[logseq.common.graph :as common-graph]
|
|
[logseq.common.path :as path]
|
|
[logseq.common.util.block-ref :as block-ref]
|
|
[logseq.common.util.date-time :as date-time-util]
|
|
[logseq.common.util.page-ref :as page-ref]
|
|
[logseq.common.uuid :as common-uuid]
|
|
[logseq.db :as ldb]
|
|
[logseq.db.common.entity-plus :as entity-plus]
|
|
[logseq.db.frontend.asset :as db-asset]
|
|
[logseq.db.frontend.content :as db-content]
|
|
[logseq.db.frontend.malli-schema :as db-malli-schema]
|
|
[logseq.db.frontend.property :as db-property]
|
|
[logseq.db.frontend.rules :as rules]
|
|
[logseq.db.frontend.validate :as db-validate]
|
|
[logseq.db.test.helper :as db-test]
|
|
[logseq.graph-parser.block :as gp-block]
|
|
[logseq.graph-parser.exporter :as gp-exporter]
|
|
[logseq.graph-parser.test.docs-graph-helper :as docs-graph-helper]
|
|
[logseq.graph-parser.test.helper :as test-helper :include-macros true :refer [deftest-async]]
|
|
[logseq.outliner.db-pipeline :as db-pipeline]
|
|
[promesa.core :as p]))
|
|
|
|
;; Helpers
|
|
;; =======
|
|
;; some have been copied from db-import script
|
|
|
|
(defn- extract-rules
|
|
[rules]
|
|
(rules/extract-rules rules/db-query-dsl-rules
|
|
rules
|
|
{:deps rules/rules-dependencies}))
|
|
|
|
(defn- find-block-by-property [db property]
|
|
(d/q '[:find [?b ...]
|
|
:in $ ?prop %
|
|
:where (has-property ?b ?prop)]
|
|
db property (extract-rules [:has-property])))
|
|
|
|
(defn- find-block-by-property-value [db property property-value]
|
|
(->> (d/q '[:find [?b ...]
|
|
:in $ ?prop ?prop-value %
|
|
:where (property ?b ?prop ?prop-value)]
|
|
db property property-value (extract-rules [:property]))
|
|
first
|
|
(d/entity db)))
|
|
|
|
(defn- ordered-children
|
|
[block]
|
|
(->> (:block/_parent block)
|
|
(remove :logseq.property/created-from-property)
|
|
(sort-by :block/order)
|
|
vec))
|
|
|
|
(defn- block-tree-with-properties
|
|
[block]
|
|
{:title (:block/title block)
|
|
:properties (dissoc (db-test/readable-properties block) :block/tags)
|
|
:children (mapv block-tree-with-properties (ordered-children block))})
|
|
|
|
(defn- find-template-by-title
|
|
[db title]
|
|
(some->> (d/q '[:find [?b ...]
|
|
:in $ ?title
|
|
:where
|
|
[?b :block/title ?title]
|
|
[?b :block/tags :logseq.class/Template]]
|
|
db title)
|
|
first
|
|
(d/entity db)))
|
|
|
|
(defn- template-content-trees
|
|
[db title]
|
|
(some->> (find-template-by-title db title)
|
|
ordered-children
|
|
(mapv block-tree-with-properties)))
|
|
|
|
(defn- block-status
|
|
[db content]
|
|
(:logseq.property/status (db-test/find-block-by-content db content)))
|
|
|
|
(defn- status-content
|
|
[db content]
|
|
(db-property/closed-value-content (block-status db content)))
|
|
|
|
(defn- status-closed-value-contents
|
|
[db]
|
|
(set (map db-property/closed-value-content
|
|
(db-property/get-closed-property-values db :logseq.property/status))))
|
|
|
|
(defn- status-closed-value-content-frequencies
|
|
[db]
|
|
(frequencies (map db-property/closed-value-content
|
|
(db-property/get-closed-property-values db :logseq.property/status))))
|
|
|
|
(defn- blocks-by-title
|
|
[db title]
|
|
(->> (d/q '[:find [?b ...]
|
|
:in $ ?title
|
|
:where
|
|
[?b :block/page]
|
|
[?b :block/title ?title]]
|
|
db title)
|
|
(map #(d/entity db %))))
|
|
|
|
(defn- task-blocks-by-title
|
|
[db title]
|
|
(->> (d/q '[:find [?b ...]
|
|
:in $ ?title
|
|
:where
|
|
[?b :block/page]
|
|
[?b :block/title ?title]
|
|
[?b :block/tags :logseq.class/Task]]
|
|
db title)
|
|
(map #(d/entity db %))))
|
|
|
|
(defn- report-retracts-block-uuid?
|
|
[tx-report block-uuid]
|
|
(boolean
|
|
(some (fn [datom]
|
|
(and (= :block/uuid (:a datom))
|
|
(= block-uuid (:v datom))
|
|
(false? (:added datom))))
|
|
(:tx-data tx-report))))
|
|
|
|
(defn- imported-favorite-titles
|
|
[db]
|
|
(->> (ldb/get-page-blocks db (:db/id (ldb/get-page db common-config/favorites-page-name)))
|
|
(keep :block/link)
|
|
(map #(d/entity db (:db/id %)))
|
|
(map ldb/get-title-with-parents)))
|
|
|
|
|
|
(defn- build-graph-files
|
|
"Given a file graph directory, return all files including assets and adds relative paths
|
|
on ::rpath since paths are absolute by default and exporter needs relative paths for
|
|
some operations"
|
|
[dir*]
|
|
(let [dir (node-path/resolve dir*)]
|
|
(->> (common-graph/get-files dir)
|
|
(concat (when (fs/existsSync (path/path-join dir* "assets"))
|
|
(common-graph/readdir (path/path-join dir* "assets"))))
|
|
(mapv #(hash-map :path %
|
|
::rpath (node-path/relative dir* %))))))
|
|
|
|
(defn- <read-file
|
|
[file]
|
|
(p/let [s (fs/readFileSync (:path file))]
|
|
(str s)))
|
|
|
|
(defn- notify-user [m]
|
|
(println (:msg m))
|
|
(when (:ex-data m)
|
|
(println "Ex-data:" (pr-str (merge (dissoc (:ex-data m) :error)
|
|
(when-let [err (get-in m [:ex-data :error])]
|
|
{:original-error (ex-data (.-cause err))}))))
|
|
(println "Stacktrace:")
|
|
(if-let [stack (some-> (get-in m [:ex-data :error]) ex-data :sci.impl/callstack deref)]
|
|
(println (string/join
|
|
"\n"
|
|
(map
|
|
#(str (:file %)
|
|
(when (:line %) (str ":" (:line %)))
|
|
(when (:sci.impl/f-meta %)
|
|
(str " calls #'" (get-in % [:sci.impl/f-meta :ns]) "/" (get-in % [:sci.impl/f-meta :name]))))
|
|
(reverse stack))))
|
|
(println (some-> (get-in m [:ex-data :error]) .-stack))))
|
|
(when (= :error (:level m))
|
|
(js/process.exit 1)))
|
|
|
|
(def default-export-options
|
|
{;; common options
|
|
:rpath-key ::rpath
|
|
:notify-user notify-user
|
|
:<read-file <read-file
|
|
;; :set-ui-state prn
|
|
;; config file options
|
|
;; TODO: Add actual default
|
|
:default-config {}})
|
|
|
|
;; tweaked from db-import
|
|
(defn- <read-and-copy-asset [file assets buffer-handler *asset-ids]
|
|
(p/let [buffer (fs/readFileSync (:path file))
|
|
checksum (db-asset/<get-file-array-buffer-checksum buffer)
|
|
asset-id (d/squuid)
|
|
asset-name (gp-exporter/asset-path->name (:path file))
|
|
asset-type (db-asset/asset-path->type (:path file))
|
|
{:keys [with-edn-content pdf-annotation?]} (buffer-handler buffer)]
|
|
(when-not pdf-annotation?
|
|
(swap! *asset-ids conj asset-id))
|
|
(swap! assets assoc asset-name
|
|
(with-edn-content
|
|
{:size (.-length buffer)
|
|
:type asset-type
|
|
:path (:path file)
|
|
:checksum checksum
|
|
:asset-id asset-id}))
|
|
buffer))
|
|
|
|
;; Copied from db-import script and tweaked for an in-memory import
|
|
(defn- import-file-graph-to-db
|
|
"Import a file graph dir just like UI does. However, unlike the UI the
|
|
exporter receives file maps containing keys :path and ::rpath since :path
|
|
are full paths"
|
|
[file-graph-dir conn {:keys [assets] :or {assets (atom [])} :as options}]
|
|
(let [*files (build-graph-files file-graph-dir)
|
|
config-file (first (filter #(string/ends-with? (:path %) "logseq/config.edn") *files))
|
|
_ (assert config-file "No 'logseq/config.edn' found for file graph dir")
|
|
options' (merge default-export-options
|
|
{:user-options (merge {:convert-all-tags? false} (dissoc options :assets :verbose))
|
|
;; asset file options
|
|
:<get-file-stat (fn [path]
|
|
(let [abs-path (if (node-path/isAbsolute path)
|
|
path
|
|
(node-path/resolve file-graph-dir path))]
|
|
;; inline require to allow cljs tests to run
|
|
(.stat (js/require "fs/promises") abs-path)))
|
|
:<read-and-copy-asset (fn [file *assets buffer-handler]
|
|
(<read-and-copy-asset file *assets buffer-handler assets))}
|
|
(select-keys options [:verbose]))]
|
|
(gp-exporter/export-file-graph conn conn config-file *files options')))
|
|
|
|
(defn- import-files-to-db
|
|
"Import specific doc files for dev purposes"
|
|
[files conn options]
|
|
(reset! gp-block/*export-to-db-graph? true)
|
|
(-> (p/let [doc-options (gp-exporter/build-doc-options (merge {:macros {} :file/name-format :triple-lowbar}
|
|
(:user-config options))
|
|
(merge default-export-options
|
|
{:user-options (merge {:convert-all-tags? false}
|
|
(dissoc options :user-config :verbose))}
|
|
(select-keys options [:verbose])))
|
|
files' (mapv #(hash-map :path %) files)
|
|
_ (gp-exporter/export-doc-files conn files' <read-file doc-options)]
|
|
{:import-state (:import-state doc-options)})
|
|
(p/finally (fn [_]
|
|
(reset! gp-block/*export-to-db-graph? false)))))
|
|
|
|
(defn- write-temp-graph-file
|
|
[relative-path content]
|
|
(let [dir (fs/mkdtempSync (node-path/join (os/tmpdir) "logseq-graph-parser-test-"))
|
|
file-path (node-path/join dir relative-path)]
|
|
(fs/mkdirSync (node-path/dirname file-path) #js {:recursive true})
|
|
(fs/writeFileSync file-path content)
|
|
file-path))
|
|
|
|
(defn- write-temp-file-graph
|
|
[files]
|
|
(let [dir (fs/mkdtempSync (node-path/join (os/tmpdir) "logseq-graph-parser-test-"))]
|
|
(doseq [[relative-path content] files]
|
|
(let [file-path (node-path/join dir relative-path)]
|
|
(fs/mkdirSync (node-path/dirname file-path) #js {:recursive true})
|
|
(fs/writeFileSync file-path content)))
|
|
dir))
|
|
|
|
(defn- next-random!
|
|
[*seed n]
|
|
(let [next-seed (mod (* 48271 @*seed) 2147483647)]
|
|
(reset! *seed next-seed)
|
|
(mod next-seed n)))
|
|
|
|
(defn- random-choice!
|
|
[*seed choices]
|
|
(nth choices (next-random! *seed (count choices))))
|
|
|
|
(defn- generated-uuid
|
|
[n]
|
|
(str "10000000-0000-4000-8000-" (.padStart (.toString n 16) 12 "0")))
|
|
|
|
(def generated-file-graph-page-names
|
|
["Generated Alpha"
|
|
"Generated Beta"
|
|
"Generated Gamma"
|
|
"Generated Delta"
|
|
"Generated Epsilon"
|
|
"Generated Zeta"])
|
|
|
|
(def generated-file-graph-ref-page-names
|
|
(into generated-file-graph-page-names
|
|
["Generated/Missing Namespace"
|
|
"Generated Nested/Child"
|
|
"Missing Alias Target"
|
|
"Missing Tag Target"
|
|
"Generated PDF"]))
|
|
|
|
(def generated-file-graph-task-markers
|
|
[nil "TODO" "DOING" "DONE" "LATER" "NOW" "WAITING"])
|
|
|
|
(def generated-file-graph-tags
|
|
["generated"
|
|
"import"
|
|
"file-graph"
|
|
"edge-case"
|
|
"db-test"])
|
|
|
|
(def generated-file-graph-test-seeds
|
|
[1309 42 8675309])
|
|
|
|
(defn- generated-page-preamble
|
|
[*seed page-name]
|
|
(let [alias-name (str page-name " Alias " (next-random! *seed 30))
|
|
tag-page (random-choice! *seed generated-file-graph-ref-page-names)]
|
|
(case (next-random! *seed 5)
|
|
0 (str "alias:: [[" alias-name "]], [[Missing Alias Target]]\n"
|
|
"tags:: [[" tag-page "]], #generated-page\n"
|
|
"generated-page-rank:: " (next-random! *seed 100) "\n\n")
|
|
1 (str "title:: " page-name "\n"
|
|
"public:: true\n\n")
|
|
"")))
|
|
|
|
(defn- generated-title-suffix
|
|
[*seed]
|
|
(case (next-random! *seed 8)
|
|
0 (str " [missing asset](../assets/missing-" (next-random! *seed 50) ".pdf)")
|
|
1 (str "  ".png)")
|
|
2 (str " [[" (random-choice! *seed generated-file-graph-ref-page-names) "]]")
|
|
3 " #[[generated multi tag]]"
|
|
""))
|
|
|
|
(defn- generated-extra-lines
|
|
[*seed index]
|
|
(case (next-random! *seed 8)
|
|
0 (str " collapsed:: true\n"
|
|
" background-color:: yellow\n")
|
|
1 (str " alias:: [[Generated Block Alias " (next-random! *seed 100) "]]\n")
|
|
2 (str " | generated | table |\n"
|
|
" | row | " index " |\n")
|
|
3 (str " ```clojure\n"
|
|
" (def generated-" index " " (next-random! *seed 100) ")\n"
|
|
" ```\n")
|
|
""))
|
|
|
|
(defn- generated-md-block
|
|
[*seed index]
|
|
(let [block-id (generated-uuid (inc index))
|
|
duplicate-id (generated-uuid 1)
|
|
missing-ref-id (generated-uuid (+ 2000 index))
|
|
task-marker (random-choice! *seed generated-file-graph-task-markers)
|
|
page-name (random-choice! *seed generated-file-graph-ref-page-names)
|
|
missing-page-name (str "Generated Missing " (next-random! *seed 1000))
|
|
tag (random-choice! *seed generated-file-graph-tags)
|
|
ref-id (case (next-random! *seed 5)
|
|
0 block-id
|
|
1 duplicate-id
|
|
2 missing-ref-id
|
|
(generated-uuid (inc (next-random! *seed 90))))
|
|
id-value (case (next-random! *seed 11)
|
|
0 "broken-generated-id"
|
|
1 duplicate-id
|
|
block-id)
|
|
page-ref (if (zero? (next-random! *seed 3))
|
|
missing-page-name
|
|
page-name)
|
|
title-prefix (if task-marker (str task-marker " ") "")
|
|
title-suffix (generated-title-suffix *seed)
|
|
temporal-line (case (next-random! *seed 6)
|
|
0 " SCHEDULED: <2026-01-05 Mon .+1w>\n"
|
|
1 " DEADLINE: <2026-01-09 Fri +2d>\n"
|
|
"")
|
|
extra-lines (generated-extra-lines *seed index)
|
|
nested-line (when (zero? (next-random! *seed 3))
|
|
(str " - nested generated block " index
|
|
" [[Generated Nested " (next-random! *seed 30) "]]\n"))]
|
|
(str "- " title-prefix "generated block " index
|
|
" [[" page-ref "]] ((" ref-id ")) #" tag title-suffix "\n"
|
|
temporal-line
|
|
" id:: " id-value "\n"
|
|
" generated-ref:: [[" page-name "]]\n"
|
|
" generated-rank:: " (next-random! *seed 100) "\n"
|
|
extra-lines
|
|
nested-line)))
|
|
|
|
(defn- generated-md-file-content
|
|
[*seed file-index page-name block-count]
|
|
(str (generated-page-preamble *seed page-name)
|
|
(apply str
|
|
(map #(generated-md-block *seed (+ (* file-index 100) %))
|
|
(range block-count)))))
|
|
|
|
(defn- generated-md-file-graph
|
|
[seed]
|
|
(let [*seed (atom seed)
|
|
pages (map-indexed
|
|
(fn [index page-name]
|
|
[(str "pages/" (string/replace (string/lower-case page-name) " " "_") ".md")
|
|
(generated-md-file-content *seed index page-name (+ 8 (next-random! *seed 8)))])
|
|
generated-file-graph-page-names)
|
|
journals [["journals/2026_01_05.md"
|
|
(generated-md-file-content *seed 20 "2026_01_05" (+ 8 (next-random! *seed 8)))]
|
|
["journals/2026_01_06.md"
|
|
(generated-md-file-content *seed 21 "2026_01_06" (+ 8 (next-random! *seed 8)))]
|
|
["assets/generated.md"
|
|
"Generated asset content\n"]]]
|
|
(into {"logseq/config.edn" "{:preferred-format :markdown\n :journal/page-title-format \"yyyy_MM_dd\"}\n"}
|
|
(concat pages journals))))
|
|
|
|
(defn- assert-generated-md-file-graph-imports
|
|
[seed]
|
|
(let [graph-dir (write-temp-file-graph (generated-md-file-graph seed))]
|
|
(p/let [conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-file-graph-to-db graph-dir conn {})
|
|
generated-block-count (->> (d/q '[:find [?title ...]
|
|
:where [?b :block/title ?title]]
|
|
@conn)
|
|
(filter #(string/includes? % "generated block"))
|
|
count)
|
|
validation-errors (map :entity (:errors (db-validate/validate-local-db! @conn)))]
|
|
(is (<= 60 generated-block-count)
|
|
(str "Seed " seed " imports the generated block corpus"))
|
|
(is (empty? validation-errors)
|
|
(str "Seed " seed " generated Markdown file graph validates")))))
|
|
|
|
(defn- assert-generated-md-file-graphs-import
|
|
[seeds]
|
|
(p/loop [remaining-seeds (seq seeds)]
|
|
(when remaining-seeds
|
|
(p/let [_ (assert-generated-md-file-graph-imports (first remaining-seeds))]
|
|
(p/recur (next remaining-seeds))))))
|
|
|
|
;; Tests
|
|
;; =====
|
|
|
|
(deftest-async import-block-with-journal-ref-and-time-property-value
|
|
(p/let [file (write-temp-graph-file
|
|
"journals/2023_06_21.md"
|
|
"- DONE foo bar #sometag1 #sometag2\n completed:: [[Sun, 06.08.2023]] *14:42*\n")
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-files-to-db [file] conn {:user-config {:journal/page-title-format "EEE, dd.MM.yyyy"}})]
|
|
(is (some? (db-test/find-block-by-content @conn #"foo bar"))
|
|
"Block with a journal reference plus time in a property value imports")
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Imported graph validates")))
|
|
|
|
(deftest-async import-quote-with-email-address
|
|
(p/let [file (write-temp-graph-file
|
|
"pages/email.md"
|
|
"- > \"CachyOS <admin@cachyos.org>\"\n")
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-files-to-db [file] conn {})]
|
|
(is (= "\"CachyOS <admin@cachyos.org>\""
|
|
(:block/title (db-test/find-block-by-content @conn #"CachyOS")))
|
|
"Email addresses inside quotes are preserved during import")
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Imported graph validates")))
|
|
|
|
(deftest-async import-org-page-title-when-property-appears-in-middle
|
|
(p/let [file (write-temp-graph-file
|
|
"pages/20230410145300-end_to_end_note.org"
|
|
":PROPERTIES:
|
|
:ID: c537c812-1ec9-4f13-adaf-1a39fd7da967
|
|
:END:
|
|
#+title: end_to_end_note
|
|
#+date: <2023-04-10 Mon 14:53>
|
|
#+filetags: :PUBLIC:
|
|
|
|
abc
|
|
|
|
#+hugo: more
|
|
|
|
123
|
|
")
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-files-to-db [file] conn {})]
|
|
(is (some? (db-test/find-page-by-title @conn "end_to_end_note"))
|
|
"Org #+title is imported when another property appears in the middle of the file")
|
|
(is (nil? (db-test/find-page-by-title @conn "20230410145300-end_to_end_note"))
|
|
"Importer should not fall back to the org-roam file stem")
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Imported graph validates")))
|
|
|
|
(deftest-async import-empty-journal-file
|
|
(p/let [file (write-temp-graph-file "journals/2025_11_11.md" "\n")
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-files-to-db [file] conn {})]
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Empty imported files do not transact nil block refs")))
|
|
|
|
(deftest-async import-repeated-deadline-and-scheduled
|
|
(p/let [file (write-temp-graph-file
|
|
"pages/repeated-tasks.md"
|
|
(str "- TODO wish [[name]] a happy birthday\n"
|
|
" SCHEDULED: <2025-11-01 Sat 08:00 .+1y>\n"
|
|
"- TODO prepare weekly report\n"
|
|
" DEADLINE: <2025-11-07 Fri +2w>\n"
|
|
"- TODO plan release\n"
|
|
" DEADLINE: <2025-11-08 Sat>\n"
|
|
" SCHEDULED: <2025-11-07 Fri .+1w>\n"))
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-files-to-db [file] conn {})]
|
|
(let [birthday-properties (db-test/readable-properties
|
|
(db-test/find-block-by-content @conn #"happy birthday"))
|
|
report-properties (db-test/readable-properties
|
|
(db-test/find-block-by-content @conn #"weekly report"))
|
|
mixed-properties (db-test/readable-properties
|
|
(db-test/find-block-by-content @conn #"plan release"))
|
|
birthday-scheduled (:logseq.property/scheduled birthday-properties)
|
|
birthday-date (js/Date. birthday-scheduled)]
|
|
(is (= 20251101 (date-time-util/ms->journal-day birthday-scheduled))
|
|
"Repeated scheduled timestamp keeps its scheduled date")
|
|
(is (= [8 0] [(.getHours birthday-date) (.getMinutes birthday-date)])
|
|
"Repeated scheduled timestamp keeps its time")
|
|
(is (= {:logseq.property.repeat/repeated? true
|
|
:logseq.property.repeat/temporal-property :logseq.property/scheduled
|
|
:logseq.property.repeat/repeat-type :logseq.property.repeat/repeat-type.dotted-plus
|
|
:logseq.property.repeat/recur-frequency 1
|
|
:logseq.property.repeat/recur-unit :logseq.property.repeat/recur-unit.year}
|
|
(select-keys birthday-properties
|
|
[:logseq.property.repeat/repeated?
|
|
:logseq.property.repeat/temporal-property
|
|
:logseq.property.repeat/repeat-type
|
|
:logseq.property.repeat/recur-frequency
|
|
:logseq.property.repeat/recur-unit]))
|
|
"Repeated scheduled timestamp keeps its repeat properties including the `.+` cookie kind")
|
|
(is (= {:logseq.property/deadline 20251107
|
|
:logseq.property.repeat/repeated? true
|
|
:logseq.property.repeat/temporal-property :logseq.property/deadline
|
|
:logseq.property.repeat/repeat-type :logseq.property.repeat/repeat-type.plus
|
|
:logseq.property.repeat/recur-frequency 2
|
|
:logseq.property.repeat/recur-unit :logseq.property.repeat/recur-unit.week}
|
|
(-> report-properties
|
|
(update :logseq.property/deadline date-time-util/ms->journal-day)
|
|
(select-keys [:logseq.property/deadline
|
|
:logseq.property.repeat/repeated?
|
|
:logseq.property.repeat/temporal-property
|
|
:logseq.property.repeat/repeat-type
|
|
:logseq.property.repeat/recur-frequency
|
|
:logseq.property.repeat/recur-unit])))
|
|
"Repeated deadline timestamp keeps its repeat properties including the `+` cookie kind")
|
|
(is (= {:logseq.property/deadline 20251108
|
|
:logseq.property/scheduled 20251107
|
|
:logseq.property.repeat/repeated? true
|
|
:logseq.property.repeat/temporal-property :logseq.property/scheduled
|
|
:logseq.property.repeat/repeat-type :logseq.property.repeat/repeat-type.dotted-plus
|
|
:logseq.property.repeat/recur-frequency 1
|
|
:logseq.property.repeat/recur-unit :logseq.property.repeat/recur-unit.week}
|
|
(-> mixed-properties
|
|
(update :logseq.property/deadline date-time-util/ms->journal-day)
|
|
(update :logseq.property/scheduled date-time-util/ms->journal-day)
|
|
(select-keys [:logseq.property/deadline
|
|
:logseq.property/scheduled
|
|
:logseq.property.repeat/repeated?
|
|
:logseq.property.repeat/temporal-property
|
|
:logseq.property.repeat/repeat-type
|
|
:logseq.property.repeat/recur-frequency
|
|
:logseq.property.repeat/recur-unit])))
|
|
"Mixed deadline and scheduled timestamps keep both dates and the repeated temporal property")
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Imported graph validates"))))
|
|
|
|
(deftest-async import-preserves-legacy-task-markers-as-status-choices
|
|
(p/let [file (write-temp-graph-file
|
|
"pages/tasks.md"
|
|
(str "- TODO\n"
|
|
"- DONE\n"
|
|
"- I recorded a [[voice note]].\n"
|
|
" - TODO\n"
|
|
"- TODO todo item\n"
|
|
"- LATER later item\n"
|
|
"- NOW now item\n"
|
|
"- DOING doing item\n"
|
|
"- WAIT waiting item\n"
|
|
"- WAITING waiting full item\n"
|
|
"- IN-PROGRESS in-progress item\n"
|
|
"- DONE done item\n"))
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-files-to-db [file] conn {})]
|
|
(is (= :logseq.property/status.todo
|
|
(:db/ident (block-status @conn "todo item")))
|
|
"TODO still imports to the built-in Todo status")
|
|
(is (= :logseq.property/status.doing
|
|
(:db/ident (block-status @conn "doing item")))
|
|
"DOING still imports to the built-in Doing status")
|
|
(is (= :logseq.property/status.done
|
|
(:db/ident (block-status @conn "done item")))
|
|
"DONE still imports to the built-in Done status")
|
|
(is (= {:logseq.property/status.todo 2
|
|
:logseq.property/status.done 1}
|
|
(frequencies (map #(-> % db-test/readable-properties :logseq.property/status)
|
|
(task-blocks-by-title @conn ""))))
|
|
"Built-in task markers without titles still import as tasks")
|
|
(let [nested-empty-task (first (ordered-children (db-test/find-block-by-content @conn #"recorded")))]
|
|
(is (= "" (:block/title nested-empty-task))
|
|
"Nested nameless task keeps an empty title")
|
|
(is (= {:logseq.property/status :logseq.property/status.todo
|
|
:block/tags [:logseq.class/Task]}
|
|
(select-keys (db-test/readable-properties nested-empty-task)
|
|
[:logseq.property/status :block/tags]))
|
|
"Nested nameless TODO keeps its task properties"))
|
|
(is (= :logseq.property/status.todo
|
|
(:db/ident (block-status @conn "later item")))
|
|
"LATER imports to the built-in Todo status")
|
|
(is (= :logseq.property/status.doing
|
|
(:db/ident (block-status @conn "now item")))
|
|
"NOW imports to the built-in Doing status")
|
|
(is (= "WAIT" (status-content @conn "waiting item"))
|
|
"WAIT imports as its own status choice")
|
|
(is (= "WAITING" (status-content @conn "waiting full item"))
|
|
"WAITING imports as its own status choice")
|
|
(is (= "IN-PROGRESS" (status-content @conn "in-progress item"))
|
|
"IN-PROGRESS imports as its own status choice")
|
|
(is (set/subset? #{"WAIT" "WAITING" "IN-PROGRESS"}
|
|
(status-closed-value-contents @conn))
|
|
"Custom imported markers are added to Status closed values")
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Imported graph validates")))
|
|
|
|
(deftest-async import-custom-task-marker-across-multiple-files
|
|
(p/let [first-file (write-temp-graph-file
|
|
"pages/custom-status-a.md"
|
|
"- WAITING first custom status item\n")
|
|
second-file (write-temp-graph-file
|
|
"pages/custom-status-b.md"
|
|
"- WAITING second custom status item\n")
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-files-to-db [first-file second-file] conn {})]
|
|
(is (= "WAITING" (status-content @conn "first custom status item"))
|
|
"Custom status marker imports from the first file")
|
|
(is (= "WAITING" (status-content @conn "second custom status item"))
|
|
"Custom status marker imports from the second file")
|
|
(is (= 1 (get (status-closed-value-content-frequencies @conn) "WAITING"))
|
|
"Custom status closed value is shared across imported files")
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Imported graph validates")))
|
|
|
|
(deftest-async import-repairs-duplicated-block-ids
|
|
(let [duplicated-uuid #uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"]
|
|
(p/let [file (write-temp-graph-file
|
|
"pages/duplicated-ids.md"
|
|
(str "- First duplicated id\n"
|
|
" id:: " duplicated-uuid "\n"
|
|
"- Second duplicated id\n"
|
|
" id:: " duplicated-uuid "\n"))
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-files-to-db [file] conn {})
|
|
first-block (db-test/find-block-by-content @conn "First duplicated id")
|
|
second-block (db-test/find-block-by-content @conn "Second duplicated id")]
|
|
(is (= duplicated-uuid (:block/uuid first-block))
|
|
"The first imported block keeps the original id")
|
|
(is (some? (:block/uuid second-block))
|
|
"The duplicate imported block gets a replacement id")
|
|
(is (not= duplicated-uuid (:block/uuid second-block))
|
|
"The duplicate imported block does not keep the conflicting id")
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Imported graph validates"))))
|
|
|
|
(deftest-async import-removes-pre-block-marker-and-missing-block-refs
|
|
(let [missing-uuid #uuid "11111111-1111-1111-1111-111111111111"
|
|
missing-embed-uuid #uuid "55555555-5555-5555-5555-555555555555"
|
|
target-uuid #uuid "22222222-2222-2222-2222-222222222222"
|
|
empty-title-property-uuid #uuid "33333333-3333-3333-3333-333333333333"
|
|
empty-title-parent-uuid #uuid "44444444-4444-4444-4444-444444444444"]
|
|
(p/let [source-file (write-temp-graph-file
|
|
"pages/A.md"
|
|
(str "Plain pre-block\n"
|
|
"- Missing ref ((" missing-uuid "))\n"
|
|
"- {{embed ((" missing-embed-uuid "))}}\n"
|
|
"- Existing ref ((" target-uuid "))\n"
|
|
"- ((" empty-title-property-uuid "))\n"
|
|
" heading:: true\n"
|
|
" background-color:: yellow\n"
|
|
"- ((" empty-title-parent-uuid "))\n"
|
|
" - Child survives\n"))
|
|
target-file (write-temp-graph-file
|
|
"pages/Z.md"
|
|
(str "- Target block\n"
|
|
" id:: " target-uuid "\n"))
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-files-to-db [source-file target-file] conn {})
|
|
missing-block (db-test/find-block-by-content @conn #"Missing ref")
|
|
existing-block (db-test/find-block-by-content @conn #"Existing ref")
|
|
target-block (db-test/find-block-by-content @conn "Target block")
|
|
empty-title-blocks (blocks-by-title @conn "")
|
|
empty-title-property-block (some #(when (:logseq.property/heading %) %) empty-title-blocks)
|
|
empty-title-parent-block (some #(when (some (fn [child]
|
|
(= "Child survives" (:block/title child)))
|
|
(ordered-children %))
|
|
%)
|
|
empty-title-blocks)]
|
|
(is (empty? (filter #(= :block/pre-block? (:a %))
|
|
(d/datoms @conn :eavt)))
|
|
"Legacy pre-block markers are never transacted")
|
|
(is (= "Missing ref" (:block/title missing-block))
|
|
"Missing OG block refs are removed from imported content")
|
|
(is (empty? (:block/refs missing-block))
|
|
"Missing OG block refs are removed from imported refs")
|
|
(is (nil? (d/entity @conn [:block/uuid missing-uuid]))
|
|
"Missing OG block refs do not leave placeholder entities")
|
|
(is (nil? (d/entity @conn [:block/uuid missing-embed-uuid]))
|
|
"Missing OG block embeds do not leave placeholder entities")
|
|
(is (empty? (d/q '[:find ?b
|
|
:where [?b :block/link ?target]
|
|
[(missing? $ ?target :block/uuid)]]
|
|
@conn))
|
|
"Missing OG block embeds do not leave dangling block links")
|
|
(is (nil? (d/entity @conn [:block/uuid empty-title-property-uuid]))
|
|
"Missing OG block refs in empty-title property blocks do not leave placeholder entities")
|
|
(is (nil? (d/entity @conn [:block/uuid empty-title-parent-uuid]))
|
|
"Missing OG block refs in empty-title parent blocks do not leave placeholder entities")
|
|
(is (= 3 (count empty-title-blocks))
|
|
"Blocks whose titles become empty after cleanup are preserved")
|
|
(is (true? (:logseq.property/heading empty-title-property-block))
|
|
"Empty-title blocks keep imported heading properties")
|
|
(is (= "yellow"
|
|
(:logseq.property/background-color (db-test/readable-properties empty-title-property-block)))
|
|
"Empty-title blocks keep imported background colors")
|
|
(is (= ["Child survives"] (mapv :block/title (ordered-children empty-title-parent-block)))
|
|
"Empty-title parent blocks keep their children")
|
|
(is (= [(:db/id target-block)] (mapv :db/id (:block/refs existing-block)))
|
|
"Existing block refs are preserved, including forward refs from later files")
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Imported graph validates"))))
|
|
|
|
(deftest-async import-generated-markdown-file-graph
|
|
(assert-generated-md-file-graphs-import generated-file-graph-test-seeds))
|
|
|
|
(deftest-async ^:integration import-generated-markdown-file-graph-fuzz
|
|
(assert-generated-md-file-graphs-import (range 1 101)))
|
|
|
|
(deftest-async export-doc-files-propagates-missing-block-ref-cleanup-report
|
|
(let [missing-uuid #uuid "55555555-5555-5555-5555-555555555555"
|
|
tx-reports (atom [])]
|
|
(p/let [file (write-temp-graph-file
|
|
"pages/A.md"
|
|
(str "- Missing ref ((" missing-uuid "))\n"))
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
doc-options (gp-exporter/build-doc-options
|
|
{:macros {} :file/name-format :triple-lowbar}
|
|
(merge default-export-options
|
|
{:user-options {:convert-all-tags? false}
|
|
:on-tx-report #(swap! tx-reports conj %)}))
|
|
_ (gp-exporter/export-doc-files conn [{:path file}] <read-file doc-options)]
|
|
(is (some #(report-retracts-block-uuid? % missing-uuid) @tx-reports)
|
|
"Missing block ref cleanup tx-report is propagated to import callers")
|
|
(is (nil? (d/entity @conn [:block/uuid missing-uuid]))
|
|
"Missing OG block ref placeholder is removed"))))
|
|
|
|
(deftest export-doc-files-aborts-on-export-file-failure
|
|
(cljs.test/async
|
|
done
|
|
(let [attempted-paths (atom [])
|
|
failed-error (ex-info "worker transact failed" {:code :worker-transact-failed})
|
|
graph-dir (write-temp-file-graph [["pages/A.md" "- first\n"]
|
|
["pages/B.md" "- second\n"]])
|
|
first-file (node-path/join graph-dir "pages/A.md")
|
|
second-file (node-path/join graph-dir "pages/B.md")
|
|
conn (db-test/create-conn)
|
|
doc-options (gp-exporter/build-doc-options
|
|
{:macros {} :file/name-format :triple-lowbar}
|
|
(merge default-export-options
|
|
{:notify-user (constantly nil)
|
|
:user-options {:convert-all-tags? false}
|
|
:<export-file (fn [_conn {:file/keys [path]} _opts]
|
|
(swap! attempted-paths conj path)
|
|
(p/rejected failed-error))}))
|
|
assert-failure (fn [result]
|
|
(is (= :worker-transact-failed
|
|
(or (:code (ex-data result))
|
|
(:code (ex-data (.-cause result)))))
|
|
"Export file failure is propagated to the caller")
|
|
(is (= [first-file] @attempted-paths)
|
|
"Import stops after the first export file failure")
|
|
(done))]
|
|
(try
|
|
(-> (gp-exporter/export-doc-files
|
|
conn
|
|
[{:path first-file} {:path second-file}]
|
|
<read-file
|
|
doc-options)
|
|
(.then (fn [_]
|
|
(is false "Export file failure should reject")
|
|
(done)))
|
|
(.catch assert-failure))
|
|
(catch :default e
|
|
(assert-failure e))))))
|
|
|
|
(deftest-async export-doc-files-preserves-filesystem-timestamps
|
|
(let [created-at (js/Date. "2020-01-02T03:04:05.000Z")
|
|
modified-at (js/Date. "2021-06-07T08:09:10.000Z")]
|
|
(p/let [file (write-temp-graph-file "pages/timestamps.md" "- timestamped\n")
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
doc-options (gp-exporter/build-doc-options
|
|
{:macros {} :file/name-format :triple-lowbar}
|
|
(merge default-export-options
|
|
{:user-options {:convert-all-tags? false}
|
|
:<get-file-stat (fn [_]
|
|
{:birthtime created-at
|
|
:mtime modified-at})
|
|
:<export-file (fn [conn' file-map opts]
|
|
(gp-exporter/<add-file-to-db-graph
|
|
conn' (:file/path file-map) (:file/content file-map) opts))}))
|
|
_ (gp-exporter/export-doc-files conn [{:path file}] <read-file doc-options)
|
|
page (ldb/get-page @conn "timestamps")
|
|
block (db-test/find-block-by-content @conn "timestamped")]
|
|
(is (= (.getTime created-at) (:block/created-at page) (:block/created-at block)))
|
|
(is (= (.getTime modified-at) (:block/updated-at page) (:block/updated-at block)))
|
|
(is (empty? (:errors (db-validate/validate-local-db! @conn)))))))
|
|
|
|
(deftest update-asset-links-in-block-title
|
|
(are [x y]
|
|
(= y (@#'gp-exporter/update-asset-links-in-block-title (first x) {(second x) "UUID"} (atom {})))
|
|
;; Standard image link with metadata
|
|
["{:height 288, :width 100} says pop"
|
|
"assets/greg-popovich-thumbs-up_1704749687791_0.png"]
|
|
"[[UUID]] says pop"
|
|
|
|
;; Image link with no metadata
|
|
[""
|
|
"assets/CleanShot_2022-10-12_at_15.53.20@2x_1665561216083_0.png"]
|
|
"[[UUID]]"
|
|
|
|
;; 2nd link
|
|
["[[FIRST UUID]] and "
|
|
"assets/subdir/partydino.gif"]
|
|
"[[FIRST UUID]] and [[UUID]]"))
|
|
|
|
(deftest-async import-missing-local-pdf-asset-link-is-ignored-quietly
|
|
(let [graph-dir (write-temp-file-graph
|
|
{"logseq/config.edn" "{}"
|
|
"pages/missing-asset.md" "- Missing local PDF [paper](../assets/missing-paper.pdf)\n"})
|
|
console-errors (atom [])
|
|
original-stderr-write (.-write (.-stderr js/process))]
|
|
(set! (.-write (.-stderr js/process))
|
|
(fn [& args]
|
|
(swap! console-errors conj (first args))
|
|
true))
|
|
(-> (p/let [conn (db-test/create-conn)
|
|
assets (atom [])
|
|
{:keys [import-state]} (import-file-graph-to-db graph-dir conn {:assets assets})]
|
|
(is (= [{:reason "No asset data found for this asset path"
|
|
:path "../assets/missing-paper.pdf"
|
|
:location {:block "Missing local PDF [paper](../assets/missing-paper.pdf)"}}]
|
|
@(:ignored-assets import-state))
|
|
"Missing local PDF asset links are reported through ignored assets")
|
|
(is (empty? @console-errors)
|
|
"Missing local PDF asset links are ignored without noisy console errors")
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Imported graph validates"))
|
|
(p/finally (fn [_]
|
|
(set! (.-write (.-stderr js/process)) original-stderr-write))))))
|
|
|
|
(deftest extract-template-blocks
|
|
(let [page-uuid (random-uuid)
|
|
parent-uuid (random-uuid)
|
|
child-uuid (random-uuid)
|
|
include-children-only-uuid (random-uuid)
|
|
child-only-1-uuid (random-uuid)
|
|
child-only-2-uuid (random-uuid)
|
|
blocks [{:block/uuid parent-uuid
|
|
:block/title "source parent"
|
|
:block/page [:block/uuid page-uuid]
|
|
:block/parent {:block/uuid page-uuid}
|
|
:block/order "a"
|
|
:block/properties {:template " trimmed template "
|
|
:name ""}
|
|
:block/properties-text-values {:template " trimmed template "
|
|
:name ""}
|
|
:block/properties-order [:template :name]}
|
|
{:block/uuid child-uuid
|
|
:block/title "child"
|
|
:block/page [:block/uuid page-uuid]
|
|
:block/parent [:block/uuid parent-uuid]
|
|
:block/order "b"
|
|
:block/properties {:template "nested child"
|
|
:name "child default"}
|
|
:block/properties-text-values {:template "nested child"
|
|
:name "child default"}
|
|
:block/properties-order [:template :name]}
|
|
{:block/uuid include-children-only-uuid
|
|
:block/title "exclude source block"
|
|
:block/page [:block/uuid page-uuid]
|
|
:block/parent {:block/uuid page-uuid}
|
|
:block/order "c"
|
|
:block/properties {:template "children only"
|
|
:template-including-parent false}
|
|
:block/properties-text-values {:template "children only"
|
|
:template-including-parent "false"}
|
|
:block/properties-order [:template :template-including-parent]}
|
|
{:block/uuid child-only-1-uuid
|
|
:block/title "first child"
|
|
:block/page [:block/uuid page-uuid]
|
|
:block/parent [:block/uuid include-children-only-uuid]
|
|
:block/order "d"}
|
|
{:block/uuid child-only-2-uuid
|
|
:block/title "second child"
|
|
:block/page [:block/uuid page-uuid]
|
|
:block/parent [:block/uuid include-children-only-uuid]
|
|
:block/order "e"}]
|
|
{:keys [blocks preserve-empty-properties-uuids]}
|
|
(#'gp-exporter/handle-template-blocks blocks)]
|
|
(testing "template roots replace source blocks"
|
|
(is (= ["trimmed template"
|
|
"source parent"
|
|
"nested child"
|
|
"child"
|
|
"children only"
|
|
"first child"
|
|
"second child"]
|
|
(mapv :block/title blocks)))
|
|
(is (= #{parent-uuid child-uuid include-children-only-uuid child-only-1-uuid child-only-2-uuid}
|
|
(set/intersection preserve-empty-properties-uuids
|
|
#{parent-uuid child-uuid include-children-only-uuid child-only-1-uuid child-only-2-uuid}))))
|
|
|
|
(testing "template roots use trimmed names and include parent content when configured"
|
|
(is (= #{"trimmed template" "nested child" "children only"}
|
|
(->> blocks
|
|
(filter #(some #{:logseq.class/Template} (:block/tags %)))
|
|
(map :block/title)
|
|
set)))
|
|
(is (= ["source parent"]
|
|
(->> blocks
|
|
(remove #(some #{:logseq.class/Template} (:block/tags %)))
|
|
(filter #(= [:block/uuid parent-uuid] (:block/parent %)))
|
|
(map :block/title))))
|
|
(is (= 2
|
|
(count (set/difference preserve-empty-properties-uuids
|
|
#{parent-uuid child-uuid include-children-only-uuid child-only-1-uuid child-only-2-uuid})))
|
|
"in-place template content blocks are marked to preserve empty properties"))))
|
|
|
|
(deftest-async ^:integration export-docs-graph-with-convert-all-tags
|
|
(p/let [file-graph-dir "test/resources/docs-0.10.12"
|
|
_ (docs-graph-helper/clone-docs-repo-if-not-exists file-graph-dir "v0.10.12")
|
|
start-time (cljs.core/system-time)
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
{:keys [import-state]}
|
|
(import-file-graph-to-db file-graph-dir conn {:convert-all-tags? true})
|
|
end-time (cljs.core/system-time)]
|
|
|
|
;; Add multiplicative factor for CI as it runs about twice as slow
|
|
(let [max-time (-> 25 (* (if js/process.env.CI 2 1)))]
|
|
(is (< (-> end-time (- start-time) (/ 1000)) max-time)
|
|
(str "Importing large graph takes less than " max-time "s")))
|
|
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Created graph has no validation errors")
|
|
(is (= 0 (count @(:ignored-properties import-state))) "No ignored properties")
|
|
(is (= 0 (count @(:ignored-assets import-state))) "No ignored assets")
|
|
(is (= []
|
|
(->> (d/q '[:find (pull ?b [:block/title {:block/tags [:db/ident]}])
|
|
:where [?b :block/tags :logseq.class/Tag]]
|
|
@conn)
|
|
(map first)
|
|
(remove #(= [{:db/ident :logseq.class/Tag}] (:block/tags %)))))
|
|
"All classes only have :logseq.class/Tag as their tag (and don't have Page)")))
|
|
|
|
(deftest-async import-linked-file-pdf-annotations
|
|
(let [annotation-id #uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
|
area-annotation-id #uuid "cccccccc-cccc-cccc-cccc-cccccccccccc"
|
|
dir (fs/mkdtempSync (node-path/join (os/tmpdir) "logseq-graph-parser-test-"))
|
|
external-pdf-path (node-path/join dir "external/Linked Paper.pdf")
|
|
graph-dir (node-path/join dir "graph")
|
|
area-image-stamp "area-stamp"
|
|
encoded-pdf-uri (str "file://" (string/replace external-pdf-path " " "%20"))]
|
|
(fs/mkdirSync (node-path/dirname external-pdf-path) #js {:recursive true})
|
|
(fs/writeFileSync external-pdf-path "pdf")
|
|
(doseq [[relative-path content]
|
|
{"logseq/config.edn" "{}"
|
|
"pages/source.md" (str "- \n")
|
|
"pages/hls__Linked Paper.md" (str "file:: [Linked Paper.pdf](" encoded-pdf-uri ")\n"
|
|
"file-path:: " encoded-pdf-uri "\n\n"
|
|
"- External highlight from linked pdf\n"
|
|
" ls-type:: annotation\n"
|
|
" hl-page:: 3\n"
|
|
" hl-color:: yellow\n"
|
|
" id:: " annotation-id "\n"
|
|
"- External area highlight from linked pdf\n"
|
|
" ls-type:: annotation\n"
|
|
" hl-page:: 4\n"
|
|
" hl-color:: yellow\n"
|
|
" id:: " area-annotation-id "\n")
|
|
"assets/Linked Paper.edn" (str "{:highlights [{:id #uuid \"" annotation-id "\","
|
|
" :page 3,"
|
|
" :position {:bounding {:x1 1 :y1 2 :x2 3 :y2 4 :width 10 :height 20},"
|
|
" :rects (),"
|
|
" :page 3},"
|
|
" :content {:text \"External highlight from linked pdf\"},"
|
|
" :properties {:color \"yellow\"}}"
|
|
" {:id #uuid \"" area-annotation-id "\","
|
|
" :page 4,"
|
|
" :position {:bounding {:x1 11 :y1 12 :x2 13 :y2 14 :width 10 :height 20},"
|
|
" :rects (),"
|
|
" :page 4},"
|
|
" :content {:image \"" area-image-stamp "\"},"
|
|
" :properties {:color \"yellow\"}}]}")
|
|
(str "assets/Linked Paper/4_" area-annotation-id "_" area-image-stamp ".png") "png"}]
|
|
(let [file-path (node-path/join graph-dir relative-path)]
|
|
(fs/mkdirSync (node-path/dirname file-path) #js {:recursive true})
|
|
(fs/writeFileSync file-path content)))
|
|
(p/let [conn (db-test/create-conn)
|
|
assets (atom [])
|
|
{:keys [import-state]} (import-file-graph-to-db graph-dir conn {:assets assets})
|
|
asset (db-test/find-block-by-content @conn "Linked Paper")
|
|
annotation (db-test/find-block-by-content @conn "External highlight from linked pdf")
|
|
area-annotation (db-test/find-block-by-content @conn "External area highlight from linked pdf")]
|
|
(is (some? asset)
|
|
"Linked file PDF imports as an external Asset")
|
|
(is (= {:block/tags [:logseq.class/Asset]
|
|
:logseq.property.asset/type "pdf"
|
|
:logseq.property.asset/external-url encoded-pdf-uri}
|
|
(select-keys (db-test/readable-properties asset)
|
|
[:block/tags
|
|
:logseq.property.asset/type
|
|
:logseq.property.asset/external-url]))
|
|
"Linked file PDF keeps the file URI as external asset metadata")
|
|
(is (= {:block/tags [:logseq.class/Pdf-annotation]
|
|
:logseq.property/asset "Linked Paper"
|
|
:logseq.property.pdf/hl-page 3}
|
|
(select-keys (db-test/readable-properties annotation)
|
|
[:block/tags
|
|
:logseq.property/asset
|
|
:logseq.property.pdf/hl-page]))
|
|
"Linked file PDF annotations import and point at the external Asset")
|
|
(is (= {:block/tags [:logseq.class/Pdf-annotation]
|
|
:logseq.property/asset "Linked Paper"
|
|
:logseq.property.pdf/hl-page 4
|
|
:logseq.property.pdf/hl-image "pdf area highlight"
|
|
:logseq.property.pdf/hl-type :area}
|
|
(select-keys (db-test/readable-properties area-annotation)
|
|
[:block/tags
|
|
:logseq.property/asset
|
|
:logseq.property.pdf/hl-page
|
|
:logseq.property.pdf/hl-image
|
|
:logseq.property.pdf/hl-type]))
|
|
"Linked file PDF area highlights import their image assets")
|
|
(is (= 0 (count @(:ignored-assets import-state))) "No ignored assets"))))
|
|
|
|
(deftest-async import-linked-file-pdf-annotations-with-uppercase-extension
|
|
(let [annotation-id #uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
|
dir (fs/mkdtempSync (node-path/join (os/tmpdir) "logseq-graph-parser-test-"))
|
|
external-pdf-path (node-path/join dir "external/Linked Paper.PDF")
|
|
graph-dir (node-path/join dir "graph")
|
|
encoded-pdf-uri (str "file://" (string/replace external-pdf-path " " "%20"))]
|
|
(fs/mkdirSync (node-path/dirname external-pdf-path) #js {:recursive true})
|
|
(fs/writeFileSync external-pdf-path "pdf")
|
|
(doseq [[relative-path content]
|
|
{"logseq/config.edn" "{}"
|
|
"pages/source.md" (str "- \n")
|
|
"pages/hls__Linked Paper.md" (str "file:: [Linked Paper.PDF](" encoded-pdf-uri ")\n"
|
|
"file-path:: " encoded-pdf-uri "\n\n"
|
|
"- External highlight from linked pdf\n"
|
|
" ls-type:: annotation\n"
|
|
" hl-page:: 3\n"
|
|
" hl-color:: yellow\n"
|
|
" id:: " annotation-id "\n")
|
|
"assets/Linked Paper.edn" (str "{:highlights [{:id #uuid \"" annotation-id "\","
|
|
" :page 3,"
|
|
" :position {:bounding {:x1 1 :y1 2 :x2 3 :y2 4 :width 10 :height 20},"
|
|
" :rects (),"
|
|
" :page 3},"
|
|
" :content {:text \"External highlight from linked pdf\"},"
|
|
" :properties {:color \"yellow\"}}]}")}]
|
|
(let [file-path (node-path/join graph-dir relative-path)]
|
|
(fs/mkdirSync (node-path/dirname file-path) #js {:recursive true})
|
|
(fs/writeFileSync file-path content)))
|
|
(p/let [conn (db-test/create-conn)
|
|
assets (atom [])
|
|
{:keys [import-state]} (import-file-graph-to-db graph-dir conn {:assets assets})
|
|
asset (db-test/find-block-by-content @conn "Linked Paper")
|
|
annotation (db-test/find-block-by-content @conn "External highlight from linked pdf")]
|
|
(is (some? asset)
|
|
"Linked file PDF imports as an external Asset")
|
|
(is (= {:block/tags [:logseq.class/Asset]
|
|
:logseq.property.asset/type "pdf"
|
|
:logseq.property.asset/external-url encoded-pdf-uri}
|
|
(select-keys (db-test/readable-properties asset)
|
|
[:block/tags
|
|
:logseq.property.asset/type
|
|
:logseq.property.asset/external-url]))
|
|
"Linked file PDF keeps the file URI as external asset metadata")
|
|
(is (= {:block/tags [:logseq.class/Pdf-annotation]
|
|
:logseq.property/asset "Linked Paper"
|
|
:logseq.property.pdf/hl-page 3}
|
|
(select-keys (db-test/readable-properties annotation)
|
|
[:block/tags
|
|
:logseq.property/asset
|
|
:logseq.property.pdf/hl-page]))
|
|
"Linked file PDF annotations import and keep highlight positions from the EDN file")
|
|
(is (= 0 (count @(:ignored-assets import-state))) "No ignored assets"))))
|
|
|
|
(deftest-async ^:integration import-large-flat-file-without-stack-overflow
|
|
(p/let [file (write-temp-graph-file
|
|
"pages/large.md"
|
|
(apply str (map #(str "- large line " % " #tag\n") (range 45000))))
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-files-to-db [file] conn {:convert-all-tags? true})]
|
|
(is (= 45000
|
|
(->> (d/q '[:find [?title ...]
|
|
:where [?b :block/title ?title]]
|
|
@conn)
|
|
(filter #(string/starts-with? % "large line "))
|
|
count))
|
|
"Large flat files import without overflowing the stack")
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Imported graph validates")))
|
|
|
|
(deftest-async export-basic-graph-with-convert-all-tags
|
|
;; This graph will contain basic examples of different features to import
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
conn (db-test/create-conn)
|
|
;; Calculate refs like frontend
|
|
_ (db-pipeline/add-listener conn)
|
|
assets (atom [])
|
|
{:keys [import-state]} (import-file-graph-to-db file-graph-dir conn {:assets assets :convert-all-tags? true})]
|
|
|
|
(testing "whole graph"
|
|
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Created graph has no validation errors")
|
|
|
|
;; Counts
|
|
;; Includes journals as property values e.g. :logseq.property/deadline
|
|
(is (= 34 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Journal]] @conn))))
|
|
|
|
(is (= 9 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Asset]] @conn))))
|
|
(is (= 6 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Task]] @conn))))
|
|
(is (= 4 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Query]] @conn))))
|
|
(is (= 2 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Card]] @conn))))
|
|
(is (= 1 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Cards]] @conn))))
|
|
(is (= 2 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Code-block]] @conn))))
|
|
(is (= 1 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Math-block]] @conn))))
|
|
(is (= 9 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Template]] @conn))))
|
|
(is (= 6 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Quote-block]] @conn))))
|
|
(is (= 8 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Pdf-annotation]] @conn))))
|
|
|
|
;; Properties and tags aren't included in this count as they aren't a Page
|
|
(is (= 13
|
|
(->> (d/q '[:find [?b ...]
|
|
:where
|
|
[?b :block/title]
|
|
[_ :block/page ?b]
|
|
(not [?b :logseq.property/built-in?])] @conn)
|
|
(map #(d/entity @conn %))
|
|
(filter ldb/internal-page?)
|
|
#_(map #(select-keys % [:block/title :block/tags]))
|
|
count))
|
|
"Correct number of pages with block content")
|
|
(is (= 16 (->> @conn
|
|
(d/q '[:find [?ident ...]
|
|
:where [?b :block/tags :logseq.class/Tag] [?b :db/ident ?ident] (not [?b :logseq.property/built-in?])])
|
|
count))
|
|
"Correct number of user classes")
|
|
(is (= 0 (count @(:ignored-properties import-state))) "No ignored properties")
|
|
(is (= 0 (count @(:ignored-assets import-state))) "No ignored assets")
|
|
(is (= 1 (count @(:ignored-files import-state))) "Ignore .edn for now")
|
|
;; 2 zotero pdf are external files so not counted here
|
|
(is (= 7 (count @assets))))
|
|
|
|
(testing "logseq files"
|
|
(is (= ".foo {}\n"
|
|
(ffirst (d/q '[:find ?content :where [?b :file/path "logseq/custom.css"] [?b :file/content ?content]] @conn))))
|
|
(is (= "logseq.api.show_msg('hello good sir!');\n"
|
|
(ffirst (d/q '[:find ?content :where [?b :file/path "logseq/custom.js"] [?b :file/content ?content]] @conn)))))
|
|
|
|
(testing "favorites"
|
|
(is (= #{"Interstellar" "some page" "new page" "n1/x/y"}
|
|
(set (imported-favorite-titles @conn)))))
|
|
|
|
(testing "user properties"
|
|
(is (= 23
|
|
(->> @conn
|
|
(d/q '[:find [(pull ?b [:db/ident]) ...]
|
|
:where [?b :block/tags :logseq.class/Property]])
|
|
(remove #(db-malli-schema/internal-ident? (:db/ident %)))
|
|
count))
|
|
"Correct number of user properties")
|
|
(is (= #{{:db/ident :user.property/prop-bool :logseq.property/type :checkbox}
|
|
{:db/ident :user.property/prop-string :logseq.property/type :default}
|
|
{:db/ident :user.property/prop-num :logseq.property/type :number}
|
|
{:db/ident :user.property/sameas :logseq.property/type :url}
|
|
{:db/ident :user.property/rangeincludes :logseq.property/type :node}
|
|
{:db/ident :user.property/startedat :logseq.property/type :date}}
|
|
(->> @conn
|
|
(d/q '[:find [(pull ?b [:db/ident :logseq.property/type]) ...]
|
|
:where [?b :block/tags :logseq.class/Property]])
|
|
(filter #(contains? #{:prop-bool :prop-string :prop-num :rangeincludes :sameas :startedat}
|
|
(keyword (name (:db/ident %)))))
|
|
set))
|
|
"Main property types have correct inferred :type")
|
|
(is (= :default
|
|
(:logseq.property/type (d/entity @conn :user.property/description)))
|
|
"Property value consisting of text and refs is inferred as :default")
|
|
(is (= :url
|
|
(:logseq.property/type (d/entity @conn :user.property/url)))
|
|
"Property value with a macro correctly inferred as :url")
|
|
|
|
(is (= {:user.property/prop-bool true
|
|
:user.property/prop-num 5
|
|
:user.property/prop-string "woot"}
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn "b1")))
|
|
"Basic block has correct properties")
|
|
(is (= #{"prop-num" "prop-string" "prop-bool"}
|
|
(->> (db-test/find-block-by-content @conn "b1")
|
|
:block/refs
|
|
(map :block/title)
|
|
set))
|
|
"Block with properties has correct refs")
|
|
|
|
(is (= {:user.property/prop-num2 10
|
|
:block/tags [:logseq.class/Page]}
|
|
(db-test/readable-properties (db-test/find-page-by-title @conn "new page")))
|
|
"New page has correct properties")
|
|
(is (= {:user.property/prop-bool true
|
|
:user.property/prop-num 5
|
|
:user.property/prop-string "yeehaw"
|
|
:block/tags [:logseq.class/Page :user.class/SomeNamespace]}
|
|
(db-test/readable-properties (db-test/find-page-by-title @conn "some page")))
|
|
"Existing page has correct properties")
|
|
|
|
(is (= {:user.property/rating 5.5}
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn ":rating float")))
|
|
"Block with float property imports as a float")
|
|
|
|
(is (= []
|
|
(->> (d/q '[:find (pull ?b [:block/title {:block/tags [:db/ident]}])
|
|
:where [?b :block/tags :logseq.class/Property]]
|
|
@conn)
|
|
(map first)
|
|
(remove #(= [{:db/ident :logseq.class/Property}] (:block/tags %)))))
|
|
"All properties only have :logseq.class/Property as their tag (and don't have Page)"))
|
|
|
|
(testing "built-in properties"
|
|
(is (= [(:db/id (db-test/find-block-by-content @conn "original block"))]
|
|
(mapv :db/id (:block/refs (db-test/find-block-by-content @conn #"ref to"))))
|
|
"block with a block-ref has correct :block/refs")
|
|
|
|
(is (= "ref to [[65cbb772-fb79-462d-87c8-6f0dad751dee]]"
|
|
(:block/title (db-test/find-block-by-content @conn #"ref to")))
|
|
"block-ref ((uuid)) is converted to page-ref [[uuid]] in block title on import")
|
|
|
|
(is (= 20221126
|
|
(-> (db-test/readable-properties (db-test/find-block-by-content @conn "only deadline"))
|
|
:logseq.property/deadline
|
|
date-time-util/ms->journal-day))
|
|
"deadline block has correct journal as property value")
|
|
|
|
(is (= {:logseq.property/scheduled 20221125
|
|
:logseq.property/deadline 20221125}
|
|
(-> (db-test/readable-properties (db-test/find-block-by-content @conn #"deadline and scheduled"))
|
|
(select-keys [:logseq.property/scheduled :logseq.property/deadline])
|
|
(update-vals date-time-util/ms->journal-day)))
|
|
"scheduled block converted to correct deadline")
|
|
|
|
(is (= 1 (count (d/q '[:find [(pull ?b [*]) ...]
|
|
:in $ ?content
|
|
:where [?b :block/title ?content]]
|
|
@conn "Apr 1st, 2024")))
|
|
"Only one journal page exists when deadline is on same day as journal")
|
|
|
|
(is (= {:logseq.property/priority :logseq.property/priority.high}
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn "high priority")))
|
|
"priority block has correct property")
|
|
|
|
(is (= {:logseq.property/status :logseq.property/status.doing
|
|
:logseq.property/priority :logseq.property/priority.medium
|
|
:block/tags [:logseq.class/Task]}
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn "status test")))
|
|
"status block has correct task properties and class")
|
|
(let [empty-title-task (first (task-blocks-by-title @conn ""))]
|
|
(is (= {:logseq.property/status :logseq.property/status.todo
|
|
:block/tags [:logseq.class/Task]}
|
|
(select-keys (db-test/readable-properties empty-title-task)
|
|
[:logseq.property/status :block/tags]))
|
|
"Empty-title TODO from file graph imports as a task"))
|
|
|
|
(is (= #{:logseq.property/status :block/tags}
|
|
(set (keys (db-test/readable-properties (db-test/find-block-by-content @conn "old todo block")))))
|
|
"old task properties like 'todo' are ignored")
|
|
|
|
(is (= {:logseq.property/order-list-type "number"}
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn "list one")))
|
|
"numered block has correct property")
|
|
|
|
(is (= #{"gpt"}
|
|
(:block/alias (db-test/readable-properties (db-test/find-page-by-title @conn "chat-gpt"))))
|
|
"alias set correctly")
|
|
(is (= ["y"]
|
|
(->> (d/q '[:find [?b ...] :where [?b :block/title "y"] [?b :block/parent]]
|
|
@conn)
|
|
first
|
|
(d/entity @conn)
|
|
:block/alias
|
|
(map :block/title)))
|
|
"alias set correctly on namespaced page")
|
|
|
|
(is (= {:logseq.property.linked-references/includes #{"Oct 9th, 2024"}
|
|
:logseq.property.linked-references/excludes #{"ref2"}}
|
|
(select-keys (db-test/readable-properties (db-test/find-page-by-title @conn "chat-gpt"))
|
|
[:logseq.property.linked-references/excludes :logseq.property.linked-references/includes]))
|
|
"linked ref filters set correctly"))
|
|
|
|
(testing "built-in classes and their properties"
|
|
;; Queries
|
|
(is (= {:logseq.property.table/sorting [{:id :user.property/prop-num, :asc? false}]
|
|
:logseq.property.view/type :logseq.property.view/type.table
|
|
:logseq.property.table/ordered-columns [:block/title :user.property/prop-string :user.property/prop-num]
|
|
:logseq.property/query "(property :prop-string)"
|
|
:block/tags [:logseq.class/Query]}
|
|
(db-test/readable-properties (find-block-by-property-value @conn :logseq.property/query "(property :prop-string)")))
|
|
"simple query block has correct query properties")
|
|
(is (= "For example, here's a query with title text:"
|
|
(:block/title (db-test/find-block-by-content @conn #"query with title text")))
|
|
"Text around a simple query block is set as a query's title")
|
|
(is (= {:logseq.property.view/type :logseq.property.view/type.list
|
|
:logseq.property/query "{:query (task todo doing)}"
|
|
:block/tags [:logseq.class/Query]
|
|
:logseq.property.table/ordered-columns [:block/title]}
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn #"tasks with todo")))
|
|
"Advanced query has correct query properties")
|
|
(is (= "tasks with todo and doing"
|
|
(:block/title (db-test/find-block-by-content @conn #"tasks with todo")))
|
|
"Advanced query has custom title migrated")
|
|
|
|
;; Card
|
|
(is (= {:block/tags [:logseq.class/Card]}
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn "card 1")))
|
|
"None of the card properties are imported since they are deprecated")
|
|
|
|
;; Cards (flashcard browser)
|
|
(is (= {:block/tags [:logseq.class/Cards]
|
|
:logseq.property/query "(tags #Card)"}
|
|
(db-test/readable-properties (find-block-by-property-value @conn :logseq.property/query "(tags #Card)")))
|
|
"cards macro block has correct Cards class and query property")
|
|
|
|
;; Math blocks
|
|
(is (= {:block/tags [:logseq.class/Math-block]
|
|
:logseq.property.node/display-type :math}
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn "E=mc^2")))
|
|
"Math block has correct Math-block class and display-type")
|
|
(is (= "E=mc^2" (:block/title (db-test/find-block-by-content @conn "E=mc^2")))
|
|
"Math block title has delimiters stripped")
|
|
|
|
;; Templates
|
|
(is (= #{"meeting"
|
|
"title-only-no-children"
|
|
"properties-only-no-children"
|
|
"title-only-with-children"
|
|
"empty-title-with-children"
|
|
"children-only"
|
|
"nested-father"
|
|
"nested-child-1"
|
|
"nested-child-2"}
|
|
(->> (d/q '[:find [?title ...]
|
|
:where
|
|
[?b :block/tags :logseq.class/Template]
|
|
[?b :block/title ?title]]
|
|
@conn)
|
|
set))
|
|
"All template definitions are imported as Template blocks")
|
|
(let [journal-uuid (:block/uuid (db-test/find-journal-by-journal-day @conn 20240216))
|
|
template-page-uuids (->> (d/q '[:find [?page-uuid ...]
|
|
:where
|
|
[?b :block/tags :logseq.class/Template]
|
|
[?b :block/page ?page]
|
|
[?page :block/uuid ?page-uuid]]
|
|
@conn)
|
|
set)]
|
|
(is (= #{journal-uuid} template-page-uuids)
|
|
"All template blocks are created on their source journal page"))
|
|
(is (= [{:title "MEETING TITLE"
|
|
:properties {:user.property/participants #{"TODO"}}
|
|
:children []}]
|
|
(template-content-trees @conn "meeting")))
|
|
(is (= [{:title "TITLE"
|
|
:properties {}
|
|
:children []}]
|
|
(template-content-trees @conn "title-only-no-children")))
|
|
(is (= [{:title ""
|
|
:properties {:user.property/name ""
|
|
:user.property/author ""}
|
|
:children []}]
|
|
(template-content-trees @conn "properties-only-no-children")))
|
|
(is (= [{:title "TITLE"
|
|
:properties {}
|
|
:children [{:title "intro" :properties {} :children []}
|
|
{:title "notes" :properties {} :children []}]}]
|
|
(template-content-trees @conn "title-only-with-children")))
|
|
(is (= [{:title ""
|
|
:properties {}
|
|
:children [{:title "intro" :properties {} :children []}
|
|
{:title "notes" :properties {} :children []}]}]
|
|
(template-content-trees @conn "empty-title-with-children")))
|
|
(is (= [{:title "intro" :properties {} :children []}
|
|
{:title "notes" :properties {} :children []}]
|
|
(template-content-trees @conn "children-only")))
|
|
(is (= [{:title "it's a template with nested templates"
|
|
:properties {:user.property/name "you named it"}
|
|
:children [{:title "nested-child-1"
|
|
:properties {}
|
|
:children [{:title "child-1"
|
|
:properties {:user.property/name ""}
|
|
:children [{:title "child-1-1"
|
|
:properties {:user.property/name ""}
|
|
:children []}]}]}
|
|
{:title "nested-child-2"
|
|
:properties {}
|
|
:children [{:title "child-2-1"
|
|
:properties {:user.property/name ""}
|
|
:children []}]}
|
|
{:title "child-3"
|
|
:properties {:user.property/name ""}
|
|
:children []}]}]
|
|
(template-content-trees @conn "nested-father")))
|
|
(is (= [{:title "child-1"
|
|
:properties {:user.property/name ""}
|
|
:children [{:title "child-1-1"
|
|
:properties {:user.property/name ""}
|
|
:children []}]}]
|
|
(template-content-trees @conn "nested-child-1")))
|
|
(is (= [{:title "child-2-1"
|
|
:properties {:user.property/name ""}
|
|
:children []}]
|
|
(template-content-trees @conn "nested-child-2")))
|
|
|
|
;; Assets
|
|
(is (= {:block/tags [:logseq.class/Asset]
|
|
:logseq.property.asset/type "png"
|
|
:logseq.property.asset/checksum "3d5e620cac62159d8196c118574bfea7a16e86fa86efd1c3fa15a00a0a08792d"
|
|
:logseq.property.asset/size 753471
|
|
:logseq.property.asset/resize-metadata {:height 288, :width 252}}
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn "greg-popovich-thumbs-up_1704749687791_0")))
|
|
"Asset has correct properties")
|
|
(is (= {:block/tags [:logseq.class/Asset]
|
|
:logseq.property.asset/type "pdf"
|
|
:logseq.property.asset/external-url "zotero://select/library/items/QDM8H6EH"
|
|
:logseq.property.asset/external-file-name "zotero-link://it/Understanding EXPLAIN.pdf"}
|
|
(select-keys
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn "Understanding EXPLAIN"))
|
|
[:block/tags
|
|
:logseq.property.asset/type
|
|
:logseq.property.asset/external-url
|
|
:logseq.property.asset/external-file-name]))
|
|
"Zotero linked pdf asset has correct external path info")
|
|
(is (= {:block/tags [:logseq.class/Asset]
|
|
:logseq.property.asset/type "pdf"
|
|
:logseq.property.asset/external-url "zotero://select/library/items/RX5JS7SY"
|
|
:logseq.property.asset/external-file-name "zotero-path://RX5JS7SY/zlib.pdf"}
|
|
(select-keys
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn "zlib"))
|
|
[:block/tags
|
|
:logseq.property.asset/type
|
|
:logseq.property.asset/external-url
|
|
:logseq.property.asset/external-file-name]))
|
|
"Zotero imported pdf asset has correct external path info")
|
|
(is (= (d/entity @conn :logseq.class/Asset)
|
|
(:block/page (db-test/find-block-by-content @conn "greg-popovich-thumbs-up_1704749687791_0")))
|
|
"Imported into Asset page")
|
|
|
|
;; Annotations
|
|
(is (= {:logseq.property.pdf/hl-color :logseq.property/color.blue
|
|
:logseq.property.pdf/hl-page 8
|
|
:block/tags [:logseq.class/Pdf-annotation]
|
|
:logseq.property/asset "Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0"}
|
|
(dissoc (db-test/readable-properties (db-test/find-block-by-content @conn #"Duke School - modified"))
|
|
:logseq.property.pdf/hl-value :logseq.property/ls-type))
|
|
"Pdf text highlight has correct properties")
|
|
(is (= ["note about duke" "sub note"]
|
|
(mapv :block/title (rest (ldb/get-block-and-children @conn (:block/uuid (db-test/find-block-by-content @conn #"Duke School - modified"))))))
|
|
"Pdf text highlight has correct children blocks")
|
|
(is (= {:logseq.property.pdf/hl-color :logseq.property/color.yellow
|
|
:logseq.property.pdf/hl-page 1
|
|
:block/tags [:logseq.class/Pdf-annotation]
|
|
:logseq.property/asset "Sina_de_Capoeria_Batizado_2025_-_Program_Itinerary_1752179325104_0"
|
|
:logseq.property.pdf/hl-image "pdf area highlight"
|
|
:logseq.property.pdf/hl-type :area}
|
|
(dissoc (->> (d/q '[:find [?b ...]
|
|
:where [?b :block/tags :logseq.class/Pdf-annotation] [?b :block/title ""]] @conn)
|
|
first
|
|
(d/entity @conn)
|
|
db-test/readable-properties)
|
|
:logseq.property.pdf/hl-value :logseq.property/ls-type))
|
|
"Pdf area highlight has correct properties")
|
|
(is (= ""
|
|
(:block/title
|
|
(d/entity @conn [:block/uuid #uuid "68702499-159a-4a14-a0cf-cf5f015535c2"])))
|
|
"Pdf annotation without text imports with an empty title")
|
|
(is (= {:block/tags [:logseq.class/Pdf-annotation]
|
|
:logseq.property/asset "Understanding EXPLAIN"
|
|
:logseq.property.pdf/hl-color :logseq.property/color.yellow
|
|
:logseq.property.pdf/hl-page 6}
|
|
(select-keys
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn #"EXPLAIN is a really nice command"))
|
|
[:block/tags
|
|
:logseq.property/asset
|
|
:logseq.property.pdf/hl-color
|
|
:logseq.property.pdf/hl-page]))
|
|
"Zotero linked pdf text highlight links to correct asset")
|
|
(is (= {:block/tags [:logseq.class/Pdf-annotation]
|
|
:logseq.property/asset "zlib"
|
|
:logseq.property.pdf/hl-color :logseq.property/color.red
|
|
:logseq.property.pdf/hl-page 1}
|
|
(select-keys
|
|
(db-test/readable-properties (db-test/find-block-by-content @conn #"The zlib library is a general purpose data compression library"))
|
|
[:block/tags
|
|
:logseq.property/asset
|
|
:logseq.property.pdf/hl-color
|
|
:logseq.property.pdf/hl-page]))
|
|
"Zotero imported pdf text highlight links to correct asset")
|
|
(let [area-hl (d/q '[:find (pull ?b [:block/title
|
|
{:block/tags [:db/ident]}
|
|
{:logseq.property/asset [:block/title]}
|
|
{:logseq.property.pdf/hl-image [:block/title]}
|
|
{:logseq.property.pdf/hl-color [:db/ident]}
|
|
:logseq.property.pdf/hl-type
|
|
:logseq.property.pdf/hl-page]) .
|
|
:where
|
|
[?asset :block/title "Understanding EXPLAIN"]
|
|
[?asset :block/tags :logseq.class/Asset]
|
|
[?b :block/title "[:span]"]
|
|
[?b :logseq.property/asset ?asset]]
|
|
@conn)]
|
|
(is (= {:logseq.property.pdf/hl-color :logseq.property/color.green
|
|
:logseq.property.pdf/hl-page 8
|
|
:block/tags [:logseq.class/Pdf-annotation]
|
|
:logseq.property/asset "Understanding EXPLAIN"
|
|
:logseq.property.pdf/hl-image "pdf area highlight"
|
|
:logseq.property.pdf/hl-type :area}
|
|
(-> area-hl
|
|
(update :block/tags #(mapv :db/ident %))
|
|
(update :logseq.property/asset #(:block/title %))
|
|
(update :logseq.property.pdf/hl-color #(:db/ident %))
|
|
(update :logseq.property.pdf/hl-image #(:block/title %))
|
|
(select-keys [:block/tags
|
|
:logseq.property/asset
|
|
:logseq.property.pdf/hl-color
|
|
:logseq.property.pdf/hl-page
|
|
:logseq.property.pdf/hl-image
|
|
:logseq.property.pdf/hl-type])))
|
|
"Zotero linked pdf area highlight links to correct asset"))
|
|
(let [area-hl (d/q '[:find (pull ?b [:block/title
|
|
{:block/tags [:db/ident]}
|
|
{:logseq.property/asset [:block/title]}
|
|
{:logseq.property.pdf/hl-image [:block/title]}
|
|
{:logseq.property.pdf/hl-color [:db/ident]}
|
|
:logseq.property.pdf/hl-type
|
|
:logseq.property.pdf/hl-page]) .
|
|
:where
|
|
[?asset :block/title "zlib"]
|
|
[?asset :block/tags :logseq.class/Asset]
|
|
[?b :block/title "[:span]"]
|
|
[?b :logseq.property/asset ?asset]]
|
|
@conn)]
|
|
(is (= {:logseq.property.pdf/hl-color :logseq.property/color.blue
|
|
:logseq.property.pdf/hl-page 1
|
|
:block/tags [:logseq.class/Pdf-annotation]
|
|
:logseq.property/asset "zlib"
|
|
:logseq.property.pdf/hl-image "pdf area highlight"
|
|
:logseq.property.pdf/hl-type :area}
|
|
(-> area-hl
|
|
(update :block/tags #(mapv :db/ident %))
|
|
(update :logseq.property/asset #(:block/title %))
|
|
(update :logseq.property.pdf/hl-color #(:db/ident %))
|
|
(update :logseq.property.pdf/hl-image #(:block/title %))
|
|
(select-keys [:block/tags
|
|
:logseq.property/asset
|
|
:logseq.property.pdf/hl-color
|
|
:logseq.property.pdf/hl-page
|
|
:logseq.property.pdf/hl-image
|
|
:logseq.property.pdf/hl-type])))
|
|
"Zotero imported pdf area highlight links to correct asset"))
|
|
|
|
;; Quotes
|
|
(is (string/starts-with? (:block/title (db-test/find-block-by-content @conn #"Saito")) "From Inception:\n> Saito:")
|
|
"Mixed #+BEGIN_QUOTE block: heading retained and quote content prefixed with '>'")
|
|
(is (nil? (:logseq.property.node/display-type (db-test/find-block-by-content @conn #"Saito")))
|
|
"Mixed #+BEGIN_QUOTE block is not converted to a Quote-block")
|
|
(is (= "markdown quote\n[[wut]]\nline 3"
|
|
(:block/title (db-test/find-block-by-content @conn #"markdown quote")))
|
|
"Markdown quote imports as full multi-line quote")
|
|
(is (string/starts-with? (:block/title (db-test/find-block-by-content @conn #"Learn Datalog"))
|
|
"Test of various ast types:\n> *Italic*")
|
|
"Mixed #+BEGIN_QUOTE block retains heading and quote content in block title")
|
|
(is (= "Blockquotes\n> Nested Blockquotes"
|
|
(:block/title (db-test/find-block-by-content @conn #"Nested Blockquotes")))
|
|
"Nested '>> quote' is preserved as '> ' prefix in Quote-block title")
|
|
(is (= :quote (:logseq.property.node/display-type (db-test/find-block-by-content @conn #"Nested Blockquotes")))
|
|
"Nested markdown quote block is tagged as Quote-block")
|
|
(is (= "it's a\n\norg blockquote"
|
|
(:block/title (db-test/find-block-by-content @conn #"org blockquote")))
|
|
"#+BEGIN_QUOTE pure block title has no '> ' prefix — display-type provides blockquote styling")
|
|
(is (= :quote (:logseq.property.node/display-type (db-test/find-block-by-content @conn #"org blockquote")))
|
|
"#+BEGIN_QUOTE pure block is tagged as Quote-block")
|
|
(is (= "> Blockquotes\n> and\n\nsomething else"
|
|
(:block/title (db-test/find-block-by-content @conn #"Blockquotes\n> and")))
|
|
"Mixed #+BEGIN_QUOTE at start of block: quote content prefixed with '>' and blank line separates following text")
|
|
(is (nil? (:logseq.property.node/display-type (db-test/find-block-by-content @conn #"Blockquotes\n> and")))
|
|
"Mixed #+BEGIN_QUOTE block at start is not converted to a Quote-block")
|
|
(let [block (db-test/find-block-by-content @conn #"Question 1")]
|
|
(is (string/includes? (:block/title block) "\n>> nested")
|
|
"Mixed markdown quote block preserves nested quote depth")
|
|
(is (nil? (:logseq.property.node/display-type block))
|
|
"Mixed markdown quote block is not converted to a Quote-block"))
|
|
(let [block (db-test/find-block-by-content @conn #"Question 2")]
|
|
(is (string/includes? (:block/title block) "> Question 3")
|
|
"Mixed markdown quote child block preserves separated quote lines")
|
|
(is (string/includes? (:block/title block) "> Answer 3")
|
|
"Mixed markdown quote child block preserves quote content after blank quote line")))
|
|
|
|
(testing "embeds"
|
|
(is (= {:block/title ""}
|
|
(-> (d/q '[:find [(pull ?b [*]) ...]
|
|
:in $ ?title
|
|
:where [?b :block/link ?l] [?b :block/page ?bp] [?bp :block/journal-day 20250612] [?l :block/title ?title]]
|
|
@conn
|
|
"page embed")
|
|
first
|
|
(select-keys [:block/title])))
|
|
"Page embed linked correctly")
|
|
(is (= {:block/title ""}
|
|
(-> (d/q '[:find [(pull ?b [*]) ...]
|
|
:in $ ?title
|
|
:where [?b :block/link ?l] [?b :block/page ?bp] [?bp :block/journal-day 20250612] [?l :block/title ?title]]
|
|
@conn
|
|
"test block embed")
|
|
first
|
|
(select-keys [:block/title])))
|
|
"Block embed linked correctly"))
|
|
|
|
(testing "tags convert to classes"
|
|
(is (= :user.class/Quotes___life
|
|
(:db/ident (db-test/find-page-by-title @conn "life")))
|
|
"Namespaced tag's ident has hierarchy to make it unique")
|
|
|
|
(is (= [:logseq.class/Tag]
|
|
(map :db/ident (:block/tags (db-test/find-page-by-title @conn "life"))))
|
|
"When a class is used and referenced on the same page, there should only be one instance of it")
|
|
|
|
(is (= [:user.class/Quotes___life]
|
|
(mapv :db/ident (:block/tags (db-test/find-block-by-content @conn #"with namespace tag"))))
|
|
"Block tagged with namespace tag is only associated with leaf child tag")
|
|
|
|
(is (= #{:user.class/ai :user.class/block-tag :user.class/p1}
|
|
(set (map :db/ident (:block/tags (db-test/find-block-by-content @conn #"Block tags")))))
|
|
"Block with tags through tags property")
|
|
|
|
(is (= []
|
|
(->> (d/q '[:find (pull ?b [:block/title {:block/tags [:db/ident]}])
|
|
:where [?b :block/tags :logseq.class/Tag]]
|
|
@conn)
|
|
(map first)
|
|
(remove #(= [{:db/ident :logseq.class/Tag}] (:block/tags %)))))
|
|
"All classes only have :logseq.class/Tag as their tag (and don't have Page)"))
|
|
|
|
(testing "namespaces"
|
|
(let [expand-children (fn expand-children [ent parent]
|
|
(if-let [children (:block/_parent ent)]
|
|
(cons {:parent (:block/title parent) :child (:block/title ent)}
|
|
(mapcat #(expand-children % ent) children))
|
|
[{:parent (:block/title parent) :child (:block/title ent)}]))]
|
|
;; check pages only
|
|
(is (= [{:parent "n1" :child "x"}
|
|
{:parent "x" :child "z"}
|
|
{:parent "x" :child "y"}]
|
|
(take 3 (rest (expand-children (db-test/find-page-by-title @conn "n1") nil))))
|
|
"First namespace tests duplicate parent page name")
|
|
(is (= [{:parent "n2" :child "x"}
|
|
{:parent "x" :child "z"}
|
|
{:parent "n2" :child "alias"}]
|
|
(rest (expand-children (db-test/find-page-by-title @conn "n2") nil)))
|
|
"First namespace tests duplicate child page name and built-in page name")))
|
|
|
|
(testing "journal timestamps"
|
|
(is (= (date-time-util/journal-day->ms 20240207)
|
|
(:block/created-at (db-test/find-page-by-title @conn "Feb 7th, 2024")))
|
|
"journal pages are created on their journal day")
|
|
(is (= (date-time-util/journal-day->ms 20240207)
|
|
(:block/created-at (db-test/find-block-by-content @conn #"Inception")))
|
|
"journal blocks are created on their page's journal day"))
|
|
|
|
(testing "db attributes"
|
|
(is (= true
|
|
(:block/collapsed? (db-test/find-block-by-content @conn "collapsed block")))
|
|
"Collapsed blocks are imported"))
|
|
|
|
(testing "property :type changes"
|
|
(is (= :node
|
|
(:logseq.property/type (d/entity @conn :user.property/finishedat)))
|
|
":date property to :node value changes to :node")
|
|
(is (= :default
|
|
(:logseq.property/type (d/entity @conn :user.property/participants)))
|
|
"template values cause participants to remain a :default property")
|
|
(is (= #{"[[Feb 7th, 2024]]"}
|
|
(:user.property/participants (db-test/readable-properties (db-test/find-block-by-content @conn #"test :node -> :date"))))
|
|
":default participants property keeps the imported text value")
|
|
|
|
(is (= :default
|
|
(:logseq.property/type (d/entity @conn :user.property/description)))
|
|
":default property to :node (or any non :default value) remains :default")
|
|
(is (= "[[Jakob]]"
|
|
(:user.property/description (db-test/readable-properties (db-test/find-block-by-content @conn #":default to :node"))))
|
|
":default to :node property saves :default property value default with full text")
|
|
|
|
(testing "with changes to upstream/existing property value"
|
|
(is (= :default
|
|
(:logseq.property/type (d/entity @conn :user.property/duration)))
|
|
":number property to :default value changes to :default")
|
|
(is (= "20"
|
|
(:user.property/duration (db-test/readable-properties (db-test/find-block-by-content @conn "existing :number to :default"))))
|
|
"existing :number property value correctly saved as :default")
|
|
(is (= :default
|
|
(:logseq.property/type (d/entity @conn :user.property/people2)))
|
|
":node property changes to :default when :node is defined in same file")
|
|
|
|
;; tests :node :many to :default transition after :node is defined in separate file
|
|
(is (= {:logseq.property/type :default :db/cardinality :db.cardinality/many}
|
|
(select-keys (d/entity @conn :user.property/people) [:logseq.property/type :db/cardinality]))
|
|
":node property to :default value changes to :default and keeps existing cardinality")
|
|
(is (= #{"[[Jakob]] [[Gabriel]]"}
|
|
(:user.property/people (db-test/readable-properties (db-test/find-block-by-content @conn ":node people"))))
|
|
"existing :node property value correctly saved as :default with full text")
|
|
(is (= #{"[[Gabriel]] [[Jakob]]"}
|
|
(:user.property/people (db-test/readable-properties (db-test/find-block-by-content @conn #"pending block for :node"))))
|
|
"pending :node property value correctly saved as :default with full text")
|
|
(is (some? (db-test/find-page-by-title @conn "Jakob"))
|
|
"Previous :node property value still exists")
|
|
(is (= 3 (count (find-block-by-property @conn :user.property/people)))
|
|
"Converted property has correct number of property values")))
|
|
|
|
(testing "imported concepts can have names of new-built concepts"
|
|
(is (= #{:logseq.property/description :user.property/description}
|
|
(set (d/q '[:find [?ident ...] :where [?b :db/ident ?ident] [?b :block/name "description"]] @conn)))
|
|
"user description property is separate from built-in one")
|
|
(is (= #{"Page" "Tag"}
|
|
(set (d/q '[:find [?t-title ...] :where
|
|
[?b :block/tags ?t]
|
|
[?b :block/name "task"]
|
|
[?t :block/title ?t-title]] @conn)))
|
|
"user page is separate from built-in class"))
|
|
|
|
(testing "multiline blocks"
|
|
(is (= "|markdown| table|\n|some|thing|" (:block/title (db-test/find-block-by-content @conn #"markdown.*table"))))
|
|
(is (= "normal multiline block\na 2nd\nand a 3rd" (:block/title (db-test/find-block-by-content @conn #"normal multiline block"))))
|
|
(is (= "colored multiline block\nlast line" (:block/title (db-test/find-block-by-content @conn #"colored multiline block"))))
|
|
|
|
(let [block (db-test/find-block-by-content @conn #"multiline block with prop and deadline")]
|
|
(is (= "multiline block with prop and deadline\nlast line" (:block/title block)))
|
|
(is (= 20221126
|
|
(-> (db-test/readable-properties block)
|
|
:logseq.property/deadline
|
|
date-time-util/ms->journal-day))
|
|
"multiline block has correct journal as property value")
|
|
(is (= "red"
|
|
(-> (db-test/readable-properties block)
|
|
:logseq.property/background-color))
|
|
"multiline block has correct background color as property value"))
|
|
|
|
(let [block (db-test/find-block-by-content @conn #"multiline block with deadline and scheduled in 1 line and sth else")]
|
|
(is (= "multiline block with deadline and scheduled in 1 line and sth else\nsomething else\nlast line" (:block/title block)))
|
|
(is (= 20221126
|
|
(-> (db-test/readable-properties block)
|
|
:logseq.property/deadline
|
|
date-time-util/ms->journal-day))
|
|
"multiline block with deadline and scheduled has correct deadline journal as property value")
|
|
(is (= 20221126
|
|
(-> (db-test/readable-properties block)
|
|
:logseq.property/scheduled
|
|
date-time-util/ms->journal-day))
|
|
"multiline block with deadline and scheduled has correct scheduled journal as property value"))
|
|
|
|
(is (= "logbook block" (:block/title (db-test/find-block-by-content @conn #"^logbook block"))))
|
|
(is (= "multiline logbook block\nlast line" (:block/title (db-test/find-block-by-content @conn #"multiline logbook block")))))
|
|
|
|
(testing ":block/refs"
|
|
(let [page (db-test/find-page-by-title @conn "chat-gpt")]
|
|
(is (set/subset?
|
|
#{"type" "LargeLanguageModel"}
|
|
(->> page :block/refs (map #(:block/title (d/entity @conn (:db/id %)))) set))
|
|
"Page has correct property and property value :block/refs"))
|
|
|
|
(let [block (db-test/find-block-by-content @conn "old todo block")]
|
|
(is (set/subset?
|
|
#{:logseq.property/status :logseq.class/Task}
|
|
(->> block
|
|
:block/refs
|
|
(map #(:db/ident (d/entity @conn (:db/id %))))
|
|
set))
|
|
"Block has correct task tag and property :block/refs")))))
|
|
|
|
(deftest-async export-basic-graph-with-convert-all-tags-option-disabled
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
conn (db-test/create-conn)
|
|
{:keys [import-state]}
|
|
(import-file-graph-to-db file-graph-dir conn {:convert-all-tags? false})]
|
|
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Created graph has no validation errors")
|
|
(is (= 0 (count @(:ignored-properties import-state))) "No ignored properties")
|
|
(is (= 0 (->> @conn
|
|
(d/q '[:find [?ident ...]
|
|
:where [?b :block/tags :logseq.class/Tag] [?b :db/ident ?ident] (not [?b :logseq.property/built-in?])])
|
|
count))
|
|
"Correct number of user classes")
|
|
|
|
(is (= 6 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Task]] @conn))))
|
|
(is (= 4 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Query]] @conn))))
|
|
(is (= 2 (count (d/q '[:find ?b :where [?b :block/tags :logseq.class/Card]] @conn))))
|
|
|
|
(testing "replacing refs in :block/title when :remove-inline-tags? set"
|
|
(is (= 2
|
|
(->> (entity-plus/lookup-kv-then-entity
|
|
(db-test/find-block-by-content @conn #"replace with same start string")
|
|
:block/raw-title)
|
|
(re-seq db-content/id-ref-pattern)
|
|
distinct
|
|
count))
|
|
"A block with ref names that start with same string has 2 distinct refs")
|
|
|
|
(is (= 1
|
|
(->> (entity-plus/lookup-kv-then-entity
|
|
(db-test/find-block-by-content @conn #"replace case insensitive")
|
|
:block/raw-title)
|
|
(re-seq db-content/id-ref-pattern)
|
|
distinct
|
|
count))
|
|
"A block with different case of same ref names has 1 distinct ref"))
|
|
|
|
(testing "tags convert to page, refs and page-tags"
|
|
(let [block (db-test/find-block-by-content @conn #"Inception")
|
|
tag-page (db-test/find-page-by-title @conn "Movie")
|
|
tagged-page (db-test/find-page-by-title @conn "Interstellar")]
|
|
(is (string/starts-with? (str (:block/title block)) "Inception [[")
|
|
"tagged block tag converts tag to page ref")
|
|
(is (= [(:db/id tag-page)] (map :db/id (:block/refs block)))
|
|
"tagged block has correct refs")
|
|
(is (and tag-page (not (ldb/class? tag-page)))
|
|
"tag page is not a class")
|
|
|
|
(is (= #{"Movie"}
|
|
(:logseq.property/page-tags (db-test/readable-properties tagged-page)))
|
|
"tagged page has existing page imported as a tag to page-tags")
|
|
(is (= #{"LargeLanguageModel" "fun" "ai"}
|
|
(:logseq.property/page-tags (db-test/readable-properties (db-test/find-page-by-title @conn "chat-gpt"))))
|
|
"tagged page has new page and other pages marked with '#' and '[[]]` imported as tags to page-tags")))))
|
|
|
|
(deftest-async import-journals-use-standard-uuids-and-keep-uuid-refs
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
files (mapv #(path/path-join file-graph-dir %) ["journals/2026_01_27.md"])
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db files conn {})]
|
|
(let [journal (db-test/find-journal-by-journal-day @conn 20260127)
|
|
ref-journal (db-test/find-journal-by-journal-day @conn 20260101)
|
|
ref-block (some->> (d/q '[:find [?b ...]
|
|
:in $ ?page ?ref-page
|
|
:where
|
|
[?b :block/page ?page]
|
|
[?b :block/refs ?ref-page]]
|
|
@conn (:db/id journal) (:db/id ref-journal))
|
|
first
|
|
(d/entity @conn))]
|
|
(is (= (common-uuid/gen-uuid :journal-page-uuid 20260127)
|
|
(:block/uuid journal))
|
|
"Imported journal page keeps the standard journal uuid")
|
|
(is (= (common-uuid/gen-uuid :journal-page-uuid 20260101)
|
|
(:block/uuid ref-journal))
|
|
"Referenced journal page keeps the standard journal uuid")
|
|
(is (= #{(:block/uuid ref-journal)}
|
|
(set (map :block/uuid (:block/refs ref-block))))
|
|
"Journal refs point at the standard journal uuid"))))
|
|
|
|
(deftest-async import-journal-with-slash-title-format-does-not-create-namespace-pages
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
files (mapv #(path/path-join file-graph-dir %) ["journals/2026_01_27.md"])
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db files conn {:user-config {:journal/page-title-format "yyyy/MM/dd"}})
|
|
journal (db-test/find-journal-by-journal-day @conn 20260127)]
|
|
(is (= "2026/01/27" (:block/title journal))
|
|
"Journal title follows slash title format")
|
|
(is (= (common-uuid/gen-uuid :journal-page-uuid 20260127)
|
|
(:block/uuid journal))
|
|
"Slash-formatted journal keeps the standard journal uuid")
|
|
(is (nil? (:block/namespace journal))
|
|
"Slash-formatted journal does not keep a namespace attribute")
|
|
(is (nil? (db-test/find-page-by-title @conn "2026"))
|
|
"Journal title is not split into a year namespace page")
|
|
(is (nil? (db-test/find-page-by-title @conn "01"))
|
|
"Journal title is not split into a month namespace page")
|
|
(is (nil? (db-test/find-page-by-title @conn "27"))
|
|
"Journal title is not split into a day namespace page")))
|
|
|
|
(deftest-async import-slash-journal-ref-does-not-create-namespace-pages
|
|
(p/let [file (write-temp-graph-file "journals/2026_05_18.md" "- yes\n- [[Sun, 2026/05/17]]\n")
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db [file] conn {:user-config {:journal/page-title-format "EEE, yyyy/MM/dd"}})
|
|
ref-journal (db-test/find-journal-by-journal-day @conn 20260517)]
|
|
(is (= "Sun, 2026/05/17" (:block/title ref-journal))
|
|
"Journal reference is imported as a journal page")
|
|
(is (nil? (:block/namespace ref-journal))
|
|
"Referenced slash-formatted journal does not keep a namespace attribute")
|
|
(is (nil? (db-test/find-page-by-title @conn "Sun, 2026"))
|
|
"Journal reference is not split into a parent namespace page")
|
|
(is (nil? (db-test/find-page-by-title @conn "05"))
|
|
"Journal reference is not split into a child namespace page")))
|
|
|
|
(deftest-async import-legacy-journal-file-name-refs-as-journals
|
|
(p/let [source-file (write-temp-graph-file
|
|
"journals/2026_04_01.md"
|
|
"- legacy journal ref [[2026_04_02]]\n")
|
|
target-file (write-temp-graph-file
|
|
"journals/2026_04_02.md"
|
|
"- target journal\n")
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db [source-file target-file] conn {})
|
|
legacy-journal (db-test/find-journal-by-journal-day @conn 20260402)
|
|
legacy-ref-block (db-test/find-block-by-content @conn #"legacy journal ref")]
|
|
(is (some? legacy-journal)
|
|
"Legacy yyyy_MM_dd journal page refs resolve imported journal files")
|
|
(is (= #{(:block/uuid legacy-journal)}
|
|
(set (map :block/uuid (:block/refs legacy-ref-block))))
|
|
"Legacy journal page ref points at the journal page")
|
|
(is (nil? (db-test/find-page-by-title @conn "2026_04_02"))
|
|
"Legacy journal page ref does not create an ordinary page")))
|
|
|
|
(deftest-async import-default-format-journal-refs-with-custom-title-format
|
|
(p/let [dir (write-temp-file-graph
|
|
{"pages/source.md"
|
|
"- existing journal [[May 18th, 2021]]\n- missing journal [[May 19th, 2021]]\n"
|
|
"journals/2021_05_18.md"
|
|
"- journal entry\n"
|
|
"pages/2021_05_19.md"
|
|
"- ordinary date-named page\n"})
|
|
source-file (-> (node-path/join dir "pages/source.md")
|
|
(string/replace "\\" "/"))
|
|
journal-file (-> (node-path/join dir "journals/2021_05_18.md")
|
|
(string/replace "\\" "/"))
|
|
ordinary-date-file (-> (node-path/join dir "pages/2021_05_19.md")
|
|
(string/replace "\\" "/"))
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db [source-file journal-file ordinary-date-file] conn
|
|
{:user-config {:journal/page-title-format "EEEE, dd-MM-yyyy"}})
|
|
existing-journal (db-test/find-journal-by-journal-day @conn 20210518)
|
|
missing-page (db-test/find-page-by-title @conn "May 19th, 2021")
|
|
existing-ref-block (db-test/find-block-by-content @conn #"existing journal")
|
|
missing-ref-block (db-test/find-block-by-content @conn #"missing journal")]
|
|
(is (= "Tuesday, 18-05-2021" (:block/title existing-journal))
|
|
"Default-format ref resolves the journal file using the configured title")
|
|
(is (= #{:logseq.class/Journal}
|
|
(set (map :db/ident (:block/tags existing-journal))))
|
|
"Existing journal does not retain the ordinary page tag")
|
|
(is (= #{(:block/uuid existing-journal)}
|
|
(set (map :block/uuid (:block/refs existing-ref-block))))
|
|
"Existing journal ref points at the journal page")
|
|
(is (= #{:logseq.class/Page}
|
|
(set (map :db/ident (:block/tags missing-page))))
|
|
"Default-format ref without a file under journals remains an ordinary page")
|
|
(is (nil? (db-test/find-journal-by-journal-day @conn 20210519))
|
|
"Date-named file outside journals does not create a journal")
|
|
(is (= #{(:block/uuid missing-page)}
|
|
(set (map :block/uuid (:block/refs missing-ref-block))))
|
|
"Missing journal ref points at the ordinary page")))
|
|
|
|
(deftest-async import-creates-missing-ordinary-page-refs
|
|
(p/let [file (write-temp-graph-file
|
|
"pages/source.md"
|
|
"- missing page ref [[Missing Page]]\n")
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db [file] conn {})
|
|
missing-page (db-test/find-page-by-title @conn "Missing Page")
|
|
source-block (db-test/find-block-by-content @conn #"missing page ref")]
|
|
(is (some? missing-page)
|
|
"Missing ordinary page refs create ordinary pages")
|
|
(is (= #{(:block/uuid missing-page)}
|
|
(set (map :block/uuid (:block/refs source-block))))
|
|
"Missing ordinary page ref points at the created page")))
|
|
|
|
(deftest-async import-favorites-from-og-config-edn
|
|
(p/let [dir (write-temp-file-graph
|
|
{"logseq/config.edn"
|
|
(str "{:favorites [\"Projects\" \"[[Projects]]\" \"foo/bar\"]\n"
|
|
" :file/name-format :triple-lowbar}\n")
|
|
"pages/Projects.md" "- project work\n- [[foo/bar]]\n"})
|
|
conn (db-test/create-conn)
|
|
_ (db-pipeline/add-listener conn)
|
|
_ (import-file-graph-to-db dir conn {})
|
|
favorite-titles (imported-favorite-titles @conn)]
|
|
(is (= 3 (count favorite-titles))
|
|
"Bare names, bracketed page refs, and namespaced pages each become a favorite link")
|
|
(is (= #{"Projects" "foo/bar"}
|
|
(set favorite-titles))
|
|
"Imported favorites resolve to the original pages including flattened namespaces")))
|
|
|
|
(deftest-async import-normalizes-existing-random-journal-uuid-and-text-refs
|
|
(let [old-journal-uuid (random-uuid)
|
|
standard-journal-uuid (common-uuid/gen-uuid :journal-page-uuid 20260127)
|
|
title (str "refs " (page-ref/->page-ref old-journal-uuid)
|
|
" and " (block-ref/->block-ref old-journal-uuid))
|
|
conn (db-test/create-conn-with-blocks
|
|
{:pages-and-blocks
|
|
[{:page {:build/journal 20260127
|
|
:block/uuid old-journal-uuid
|
|
:build/keep-uuid? true}
|
|
:blocks [{:block/title title}]}]})
|
|
file (write-temp-graph-file "pages/trigger-normalize.md" "- trigger normalize\n")]
|
|
(p/let [_ (import-files-to-db [file] conn {})
|
|
journal (db-test/find-journal-by-journal-day @conn 20260127)
|
|
ref-block (db-test/find-block-by-content @conn #"refs")]
|
|
(is (= standard-journal-uuid (:block/uuid journal))
|
|
"Existing random journal uuid is normalized to the standard journal uuid")
|
|
(is (nil? (d/entity @conn [:block/uuid old-journal-uuid]))
|
|
"Old journal uuid no longer resolves after normalization")
|
|
(is (= (str "refs " (page-ref/->page-ref standard-journal-uuid)
|
|
" and " (block-ref/->block-ref standard-journal-uuid))
|
|
(:block/title ref-block))
|
|
"Text references are rewritten to the standard journal uuid")
|
|
(is (= (:db/id journal) (get-in ref-block [:block/page :db/id]))
|
|
"Structured block page reference still points to the same journal entity"))))
|
|
|
|
(deftest-async export-files-with-tag-classes-option
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
files (mapv #(path/path-join file-graph-dir %) ["journals/2024_02_07.md" "pages/Interstellar.md"])
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db files conn {:tag-classes ["movie"]})]
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Created graph has no validation errors")
|
|
|
|
(let [block (db-test/find-block-by-content @conn #"Inception")
|
|
tag-page (db-test/find-page-by-title @conn "Movie")
|
|
another-tag-page (db-test/find-page-by-title @conn "p0")]
|
|
(is (= (:block/title block) "Inception")
|
|
"tagged block with configured tag strips tag from content")
|
|
(is (= [:user.class/Movie]
|
|
(:block/tags (db-test/readable-properties block)))
|
|
"tagged block has configured tag imported as a class")
|
|
|
|
(is (= [:logseq.class/Tag] (mapv :db/ident (:block/tags tag-page)))
|
|
"configured tag page in :tag-classes is a class")
|
|
(is (and another-tag-page (not (ldb/class? another-tag-page)))
|
|
"unconfigured tag page is not a class")
|
|
|
|
(is (= {:block/tags [:logseq.class/Page :user.class/Movie]}
|
|
(db-test/readable-properties (db-test/find-page-by-title @conn "Interstellar")))
|
|
"tagged page has configured tag imported as a class"))))
|
|
|
|
(deftest-async export-files-with-property-classes-option
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
files (mapv #(path/path-join file-graph-dir %)
|
|
["journals/2024_02_23.md" "pages/url.md" "pages/Whiteboard___Tool.md"
|
|
"pages/Whiteboard___Arrow_head_toggle.md"
|
|
"pages/Library.md"])
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db files conn {:property-classes ["type"]})
|
|
_ (@#'gp-exporter/export-class-properties conn conn)]
|
|
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Created graph has no validation errors")
|
|
|
|
(is (= #{:user.class/Property :user.class/Movie :user.class/Class :user.class/Tool}
|
|
(->> @conn
|
|
(d/q '[:find [?ident ...]
|
|
:where [?b :block/tags :logseq.class/Tag] [?b :db/ident ?ident] (not [?b :logseq.property/built-in?])])
|
|
set))
|
|
"All classes are correctly defined by :type")
|
|
|
|
(is (= #{:user.property/url :user.property/sameas :user.property/rangeincludes}
|
|
(->> (d/entity @conn :user.class/Property)
|
|
:logseq.property.class/properties
|
|
(map :db/ident)
|
|
set))
|
|
"Properties are correctly inferred for a class")
|
|
|
|
(let [block (db-test/find-block-by-content @conn #"The Creator")
|
|
tag-page (db-test/find-page-by-title @conn "Movie")]
|
|
(is (= (:block/title block) "The Creator")
|
|
"tagged block with configured tag strips tag from content")
|
|
(is (= [:user.class/Movie]
|
|
(:block/tags (db-test/readable-properties block)))
|
|
"tagged block has configured tag imported as a class")
|
|
(is (= (:user.property/testtagclass block) (:block/tags block))
|
|
"tagged block can have another property that references the same class it is tagged with,
|
|
without creating a duplicate class")
|
|
|
|
(is (= [:logseq.class/Tag] (map :db/ident (:block/tags tag-page)))
|
|
"configured tag page derived from :property-classes is a class")
|
|
(is (nil? (db-test/find-page-by-title @conn "type"))
|
|
"No page exists for configured property")
|
|
|
|
(is (= #{:user.class/Property :logseq.class/Property}
|
|
(set (:block/tags (db-test/readable-properties (db-test/find-page-by-title @conn "url")))))
|
|
"tagged page has correct tags including one from option"))))
|
|
|
|
(deftest-async export-files-with-remove-inline-tags
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
files (mapv #(path/path-join file-graph-dir %) ["journals/2024_02_07.md"
|
|
"journals/2026_01_27.md"])
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db files conn {:remove-inline-tags? false :convert-all-tags? true})
|
|
namespaced-file (write-temp-graph-file
|
|
"pages/namespace-inline-tag.md"
|
|
"- #parent/child\n")
|
|
namespaced-conn (db-test/create-conn)
|
|
_ (import-files-to-db [namespaced-file] namespaced-conn {:remove-inline-tags? false :convert-all-tags? true})
|
|
[block tag] (->> (d/q '[:find ?b ?t
|
|
:where
|
|
[?b :block/tags ?t]
|
|
[?b :block/page]
|
|
[?t :db/ident :user.class/parent___child]]
|
|
@namespaced-conn)
|
|
first
|
|
(map #(d/entity @namespaced-conn %)))
|
|
raw-title (:block/title block)]
|
|
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Created graph has no validation errors")
|
|
(is (string/starts-with? (:block/title (db-test/find-block-by-content @conn #"Inception"))
|
|
"Inception #Movie")
|
|
"block with tag preserves inline tag")
|
|
(is (string/includes? (:block/title (db-test/find-block-by-content @conn #"block with multi word tag"))
|
|
"#[[another test]]")
|
|
"block with multi word tag preserves inline tag")
|
|
(testing "namespaced inline tag on first line is preserved as inline tag"
|
|
(is (some? block)
|
|
"imported first-line namespaced tag block")
|
|
(is (string? raw-title)
|
|
"imported block has raw title")
|
|
(when (string? raw-title)
|
|
(is (ldb/inline-tag? raw-title tag)
|
|
"first-line namespaced tag is stored as an inline tag")))))
|
|
|
|
(deftest-async export-files-with-ignored-properties
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
files (mapv #(path/path-join file-graph-dir %) ["ignored/icon-page.md"])
|
|
conn (db-test/create-conn)
|
|
{:keys [import-state]} (import-files-to-db files conn {})]
|
|
(is (= 2
|
|
(count (filter #(= :icon (:property %)) @(:ignored-properties import-state))))
|
|
"icon properties are visibly ignored in order to not fail import")))
|
|
|
|
(deftest-async export-files-with-property-parent-classes-option
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
files (mapv #(path/path-join file-graph-dir %) ["journals/2024_11_26.md"
|
|
"pages/CreativeWork.md" "pages/Movie.md" "pages/type.md"
|
|
"pages/Whiteboard___Tool.md" "pages/Whiteboard___Arrow_head_toggle.md"
|
|
"pages/Property.md" "pages/url.md"])
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db files conn {:property-parent-classes ["parent"]
|
|
;; Also add this option to trigger some edge cases with namespace pages
|
|
:property-classes ["type"]})]
|
|
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Created graph has no validation errors")
|
|
|
|
(is (= #{:user.class/Movie :user.class/CreativeWork :user.class/Thing :user.class/Feature
|
|
:user.class/Class :user.class/Tool :user.class/Whiteboard___Tool :user.class/Property}
|
|
(->> @conn
|
|
(d/q '[:find [?ident ...]
|
|
:where [?b :block/tags :logseq.class/Tag] [?b :db/ident ?ident] (not [?b :logseq.property/built-in?])])
|
|
set))
|
|
"All classes are correctly defined by :type")
|
|
|
|
(is (= ["CreativeWork"] (map :block/title (:logseq.property.class/extends (d/entity @conn :user.class/Movie))))
|
|
"Existing page correctly set as class parent")
|
|
(is (= ["Thing"] (map :block/title (:logseq.property.class/extends (d/entity @conn :user.class/CreativeWork))))
|
|
"New page correctly set as class parent")))
|
|
|
|
(deftest-async export-files-with-property-pages-disabled
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
;; any page with properties
|
|
files (mapv #(path/path-join file-graph-dir %) ["journals/2024_01_17.md"])
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db files conn {:user-config {:property-pages/enabled? false
|
|
:property-pages/excludelist #{:prop-string}}})]
|
|
|
|
(is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn))))
|
|
"Created graph has no validation errors")))
|
|
|
|
(deftest-async export-config-file-sets-title-format
|
|
(p/let [conn (db-test/create-conn)
|
|
read-file #(p/do! "{:journal/page-title-format \"yyyy-MM-dd\"}")
|
|
_ (gp-exporter/export-config-file conn "logseq/config.edn" read-file {})]
|
|
(is (= "yyyy-MM-dd"
|
|
(:logseq.property.journal/title-format (d/entity @conn :logseq.class/Journal)))
|
|
"title format set correctly by config")))
|
|
|
|
(deftest split-title-by-code-fences
|
|
(let [split-fn #'gp-exporter/split-title-by-code-fences]
|
|
(testing "standalone code fence with language"
|
|
(is (= {:text-parts []
|
|
:code-segs [{:text "it's an individual code snippet with language tag"
|
|
:lang "markdown"}]}
|
|
(split-fn "```markdown\nit's an individual code snippet with language tag\n```"))))
|
|
|
|
(testing "standalone code fence without language"
|
|
(is (= {:text-parts []
|
|
:code-segs [{:text "it's an individual code snippet without language tag"
|
|
:lang nil}]}
|
|
(split-fn "```\nit's an individual code snippet without language tag\n```"))))
|
|
|
|
(testing "one code fence with leading text"
|
|
(is (= {:text-parts ["before code snippet"]
|
|
:code-segs [{:text "echo \"ok\"\nexit"
|
|
:lang nil}]}
|
|
(split-fn "before code snippet\n```\necho \"ok\"\nexit\n```"))))
|
|
|
|
(testing "one code fence with leading and trailing text"
|
|
(is (= {:text-parts ["before code snippet" "after code snippet"]
|
|
:code-segs [{:text "echo \"ok\"\nexit"
|
|
:lang "bash"}]}
|
|
(split-fn "before code snippet\n```bash\necho \"ok\"\nexit\n```\nafter code snippet"))))
|
|
|
|
(testing "one code fence followed by trailing text"
|
|
(is (= {:text-parts ["after code snippet"]
|
|
:code-segs [{:text "echo \"ok\"\nexit"
|
|
:lang "bash"}]}
|
|
(split-fn "```bash\necho \"ok\"\nexit\n```\nafter code snippet"))))
|
|
|
|
(testing "multiple code fences mixed with text"
|
|
(is (= {:text-parts ["before code snippet" "middle" "after code snippet"]
|
|
:code-segs [{:text "echo \"ok\"\nexit"
|
|
:lang "bash"}
|
|
{:text "echo \"bye\"\nexit"
|
|
:lang "bash"}]}
|
|
(split-fn "before code snippet\n```bash\necho \"ok\"\nexit\n```\nmiddle\n```bash\necho \"bye\"\nexit\n```\nafter code snippet"))))
|
|
|
|
(testing "edge: one code fence followed by opening fence without closing fence"
|
|
(is (= {:text-parts ["echo \"missing end fence\""] ;; no "```bash" ahead, it's fine as is; let's leave it
|
|
:code-segs [{:text "echo \"ok\"\nexit"
|
|
:lang "bash"}]}
|
|
(split-fn "```bash\necho \"ok\"\nexit\n```\n```bash\necho \"missing end fence\""))))
|
|
|
|
(testing "edge: pure multiple code fences with no extra text"
|
|
(let [{:keys [text-parts code-segs]} (split-fn "```markdown\n1st code snippet with language tag\n```\n```\n2nd code snippet without language tag\n```")]
|
|
(is (and (empty? text-parts) (> (count code-segs) 1)) "not pure single code and no mixed content")))
|
|
|
|
(testing "edge: opening fence without closing fence"
|
|
(let [title "```bash\necho \"missing end fence\""
|
|
{:keys [text-parts code-segs]} (split-fn title)]
|
|
(is (and (= (count text-parts) 1) (not= (first text-parts) title) (empty? code-segs)) "not pure single code and no mixed content")))
|
|
|
|
(testing "edge: plain text without any code fence"
|
|
(is (= {:text-parts ["plain text only"]
|
|
:code-segs []}
|
|
(split-fn "plain text only"))))
|
|
|
|
(testing "edge: empty title"
|
|
(is (= {:text-parts [""]
|
|
:code-segs []}
|
|
(split-fn ""))))))
|
|
|
|
(deftest-async export-files-with-extract-code-snippet
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
files (mapv #(path/path-join file-graph-dir %) ["journals/2026_03_01.md"])
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db files conn {:extract-code-snippets? true})
|
|
journal-page-eid (d/q '[:find ?p . :where [?p :block/journal-day 20260301]] @conn)
|
|
top-blocks (->> (d/q '[:find [?b ...]
|
|
:in $ ?page
|
|
:where
|
|
[?b :block/page ?page]
|
|
[?b :block/parent ?page]]
|
|
@conn journal-page-eid)
|
|
(map #(d/entity @conn %))
|
|
(sort-by :block/order)
|
|
vec)
|
|
get-direct-children (fn [block]
|
|
(->> (d/q '[:find [?c ...]
|
|
:in $ ?parent
|
|
:where [?c :block/parent ?parent]]
|
|
@conn (:db/id block))
|
|
(map #(d/entity @conn %))))]
|
|
|
|
(testing "standalone code block with language tag"
|
|
(let [b (nth top-blocks 0)]
|
|
(is (= "it's an individual code snippet with language tag" (:block/title b))
|
|
"Standalone code block title has fences stripped")
|
|
(is (= 0 (count (get-direct-children b)))
|
|
"Standalone code block has no children")
|
|
(is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags b))))
|
|
"Standalone code block is tagged as Code-block")
|
|
(is (= "markdown" (:logseq.property.code/lang b))
|
|
"Standalone code block has markdown language property")))
|
|
|
|
(testing "standalone code block without language tag"
|
|
(let [b (nth top-blocks 1)]
|
|
(is (= "it's an individual code snippet without language tag" (:block/title b))
|
|
"Standalone code block title has fences stripped")
|
|
(is (= 0 (count (get-direct-children b)))
|
|
"Standalone code block has no children")
|
|
(is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags b))))
|
|
"Standalone code block is tagged as Code-block")
|
|
(is (= nil (:logseq.property.code/lang b))
|
|
"Standalone code block has no language property")))
|
|
|
|
(testing "text before code snippet"
|
|
(let [b (nth top-blocks 2)
|
|
children (get-direct-children b)]
|
|
(is (= "before code snippet" (:block/title b))
|
|
"Block title has text only without code")
|
|
(is (= 1 (count children))
|
|
"Block has 1 code child")
|
|
(is (= "echo \"ok\"\nexit" (:block/title (first children)))
|
|
"Child code block has correct content without fence markers")
|
|
(is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags (first children)))))
|
|
"Child block is tagged as Code-block")
|
|
(is (= nil (:logseq.property.code/lang (first children)))
|
|
"Child block has no language property")))
|
|
|
|
(testing "text before and after code snippet"
|
|
(let [b (nth top-blocks 3)
|
|
children (get-direct-children b)]
|
|
(is (= "before code snippet\nafter code snippet" (:block/title b))
|
|
"Block title has text only without code")
|
|
(is (= 1 (count children))
|
|
"Block has 1 code child")
|
|
(is (= "echo \"ok\"\nexit" (:block/title (first children)))
|
|
"Child code block has correct content without fence markers")
|
|
(is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags (first children)))))
|
|
"Child block is tagged as Code-block")
|
|
(is (= "bash" (:logseq.property.code/lang (first children)))
|
|
"Child block has bash language property")))
|
|
|
|
(testing "code snippet before text"
|
|
(let [b (nth top-blocks 4)
|
|
children (get-direct-children b)]
|
|
(is (= "after code snippet" (:block/title b))
|
|
"Block title has text only without code")
|
|
(is (= 1 (count children))
|
|
"Block has 1 code child")
|
|
(is (= "echo \"ok\"\nexit" (:block/title (first children)))
|
|
"Child code block has correct content without fence markers")
|
|
(is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags (first children)))))
|
|
"Child block is tagged as Code-block")
|
|
(is (= "bash" (:logseq.property.code/lang (first children)))
|
|
"Child block has bash language property")))
|
|
|
|
(testing "multiple code snippets mixed with text"
|
|
(let [b (nth top-blocks 5)
|
|
children (sort-by :block/order (get-direct-children b))]
|
|
(is (= "before code snippet\nmiddle\nafter code snippet" (:block/title b))
|
|
"Block title has all text parts without code")
|
|
(is (= 2 (count children))
|
|
"Block has 2 code children")
|
|
(is (= "echo \"ok\"\nexit" (:block/title (first children)))
|
|
"First child code block has correct content without fence markers")
|
|
(is (= "echo \"bye\"\nexit" (:block/title (second children)))
|
|
"Second child code block has correct content without fence markers")
|
|
(is (every? #(= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags %)))) children)
|
|
"Both child blocks are tagged as Code-block")
|
|
(is (every? #(= "bash" (:logseq.property.code/lang %)) children)
|
|
"Both child blocks have bash language property")))))
|
|
|
|
(deftest-async export-files-without-extract-code-snippet
|
|
(p/let [file-graph-dir "test/resources/exporter-test-graph"
|
|
files (mapv #(path/path-join file-graph-dir %) ["journals/2026_03_01.md"])
|
|
conn (db-test/create-conn)
|
|
_ (import-files-to-db files conn {:extract-code-snippets? false})
|
|
journal-page-eid (d/q '[:find ?p . :where [?p :block/journal-day 20260301]] @conn)
|
|
top-blocks (->> (d/q '[:find [?b ...]
|
|
:in $ ?page
|
|
:where
|
|
[?b :block/page ?page]
|
|
[?b :block/parent ?page]]
|
|
@conn journal-page-eid)
|
|
(map #(d/entity @conn %))
|
|
(sort-by :block/order)
|
|
vec)
|
|
get-direct-children (fn [block]
|
|
(->> (d/q '[:find [?c ...]
|
|
:in $ ?parent
|
|
:where [?c :block/parent ?parent]]
|
|
@conn (:db/id block))
|
|
(map #(d/entity @conn %))))]
|
|
|
|
(testing "standalone code block with language tag is still tagged as Code-block"
|
|
(let [b (nth top-blocks 0)]
|
|
(is (= "it's an individual code snippet with language tag" (:block/title b))
|
|
"Standalone code block title has fences stripped")
|
|
(is (= 0 (count (get-direct-children b)))
|
|
"Standalone code block has no children")
|
|
(is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags b))))
|
|
"Standalone code block is tagged as Code-block")
|
|
(is (= "markdown" (:logseq.property.code/lang b))
|
|
"Standalone code block has markdown language property")))
|
|
|
|
(testing "standalone code block without language tag is still tagged as Code-block"
|
|
(let [b (nth top-blocks 1)]
|
|
(is (= "it's an individual code snippet without language tag" (:block/title b))
|
|
"Standalone code block title has fences stripped")
|
|
(is (= 0 (count (get-direct-children b)))
|
|
"Standalone code block has no children")
|
|
(is (= #{:logseq.class/Code-block} (set (map :db/ident (:block/tags b))))
|
|
"Standalone code block is tagged as Code-block")
|
|
(is (= nil (:logseq.property.code/lang b))
|
|
"Standalone code block has no language property")))
|
|
|
|
(testing "mixed-content block is NOT extracted into children when extract-code-snippets? is false"
|
|
(let [b (nth top-blocks 2)]
|
|
(is (= 0 (count (get-direct-children b)))
|
|
"Block with text before code has no children extracted")
|
|
(is (string/includes? (:block/title b) "```")
|
|
"Block title retains raw code fence markup")))
|
|
|
|
(testing "another mixed-content block is NOT extracted when extract-code-snippets? is false"
|
|
(let [b (nth top-blocks 3)]
|
|
(is (= 0 (count (get-direct-children b)))
|
|
"Block with text surrounding code has no children extracted")
|
|
(is (string/includes? (:block/title b) "```")
|
|
"Block title retains raw code fence markup")))))
|
|
|
|
(deftest page-alias-sanitise-for-import
|
|
(testing "duplicate-owner alias is dropped and reported"
|
|
(let [alias-owners (atom {})
|
|
ignored-props (atom [])
|
|
pages [{:block/name "p1" :block/alias [{:block/name "shared"}]}
|
|
{:block/name "p2" :block/alias [{:block/name "shared"}]}]
|
|
result (gp-exporter/sanitize-page-aliases-for-import!
|
|
pages alias-owners ignored-props)]
|
|
(is (= "p1" (get @alias-owners "shared"))
|
|
"first declarer wins ownership")
|
|
(is (= 1 (count (filter #(= :alias/duplicate-owner (:reason %)) @ignored-props)))
|
|
"duplicate alias is reported in ignored-properties")
|
|
(is (nil? (:block/alias (second result)))
|
|
"second page's conflicting alias is removed")))
|
|
(testing "alias-of-alias is dropped and reported"
|
|
(let [alias-owners (atom {})
|
|
ignored-props (atom [])
|
|
pages [{:block/name "root" :block/alias [{:block/name "mid"}]}
|
|
{:block/name "mid" :block/alias [{:block/name "leaf"}]}]
|
|
result (gp-exporter/sanitize-page-aliases-for-import!
|
|
pages alias-owners ignored-props)]
|
|
(is (= 1 (count (filter #(= :alias/alias-owns-aliases (:reason %)) @ignored-props)))
|
|
"alias-of-alias is reported in ignored-properties")
|
|
(is (nil? (:block/alias (first result)))
|
|
"alias pointing to a page that owns aliases is removed")))
|
|
(testing "self-alias is dropped and reported"
|
|
(let [alias-owners (atom {})
|
|
ignored-props (atom [])
|
|
pages [{:block/name "self" :block/alias [{:block/name "self"}]}]
|
|
result (gp-exporter/sanitize-page-aliases-for-import!
|
|
pages alias-owners ignored-props)]
|
|
(is (nil? (:block/alias (first result)))
|
|
"self-alias declaration is removed")
|
|
(is (some #(= :alias/self (:reason %)) @ignored-props)
|
|
"self-alias is reported in ignored-properties"))))
|
|
|
|
(deftest page-alias-sanitise-for-import-cross-file
|
|
(testing "source-is-alias caught across files (root->mid in file 1, mid->leaf in file 2)"
|
|
(let [alias-owners (atom {})
|
|
ignored-props (atom [])]
|
|
;; File 1: root claims mid as alias → registers "mid" -> "root"
|
|
(gp-exporter/sanitize-page-aliases-for-import!
|
|
[{:block/name "root" :block/alias [{:block/name "mid"}]}]
|
|
alias-owners ignored-props)
|
|
;; File 2: mid tries to declare leaf as its alias — but mid is already an alias
|
|
(let [result (gp-exporter/sanitize-page-aliases-for-import!
|
|
[{:block/name "mid" :block/alias [{:block/name "leaf"}]}]
|
|
alias-owners ignored-props)]
|
|
(is (nil? (:block/alias (first result)))
|
|
"mid's alias declaration dropped: mid is already an alias")
|
|
(is (some #(= :alias/source-is-alias (:reason %)) @ignored-props)
|
|
"source-is-alias reason recorded"))))
|
|
(testing "alias-owns-aliases caught across files (mid->leaf in file 1, root->mid in file 2)"
|
|
(let [alias-owners (atom {})
|
|
ignored-props (atom [])]
|
|
;; File 1: mid claims leaf as alias → registers "leaf" -> "mid"; mid is now an owner
|
|
(gp-exporter/sanitize-page-aliases-for-import!
|
|
[{:block/name "mid" :block/alias [{:block/name "leaf"}]}]
|
|
alias-owners ignored-props)
|
|
;; File 2: root tries to use mid as alias — but mid already owns aliases
|
|
(let [result (gp-exporter/sanitize-page-aliases-for-import!
|
|
[{:block/name "root" :block/alias [{:block/name "mid"}]}]
|
|
alias-owners ignored-props)]
|
|
(is (nil? (:block/alias (first result)))
|
|
"root's alias pointing to mid dropped: mid already owns aliases")
|
|
(is (some #(= :alias/alias-owns-aliases (:reason %)) @ignored-props)
|
|
"alias-owns-aliases reason recorded")))))
|