exporter_test.cljs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. (ns ^:node-only logseq.graph-parser.exporter-test
  2. (:require [cljs.test :refer [testing is]]
  3. [logseq.graph-parser.test.helper :as test-helper :include-macros true :refer [deftest-async]]
  4. [datascript.core :as d]
  5. [clojure.string :as string]
  6. ["path" :as node-path]
  7. ["fs" :as fs]
  8. [logseq.common.graph :as common-graph]
  9. [promesa.core :as p]
  10. [logseq.db.frontend.schema :as db-schema]
  11. [logseq.db.frontend.validate :as db-validate]
  12. [logseq.db.sqlite.create-graph :as sqlite-create-graph]
  13. [logseq.graph-parser.exporter :as gp-exporter]
  14. [logseq.db.frontend.malli-schema :as db-malli-schema]
  15. [logseq.db.frontend.property :as db-property]
  16. [logseq.db.frontend.property.type :as db-property-type]
  17. [logseq.common.config :as common-config]
  18. [logseq.db :as ldb]))
  19. ;; Helpers
  20. ;; =======
  21. ;; some have been copied from db-import script
  22. (defn- find-block-by-content [db content]
  23. (if (instance? js/RegExp content)
  24. (->> content
  25. (d/q '[:find [(pull ?b [*]) ...]
  26. :in $ ?pattern
  27. :where [?b :block/content ?content] [(re-find ?pattern ?content)]]
  28. db)
  29. first)
  30. (->> content
  31. (d/q '[:find [(pull ?b [*]) ...]
  32. :in $ ?content
  33. :where [?b :block/content ?content]]
  34. db)
  35. first)))
  36. (defn- find-page-by-name [db name]
  37. (->> name
  38. (d/q '[:find [(pull ?b [*]) ...]
  39. :in $ ?name
  40. :where [?b :block/original-name ?name]]
  41. db)
  42. first))
  43. (defn- build-graph-files
  44. "Given a file graph directory, return all files including assets and adds relative paths
  45. on ::rpath since paths are absolute by default and exporter needs relative paths for
  46. some operations"
  47. [dir*]
  48. (let [dir (node-path/resolve dir*)]
  49. (->> (common-graph/get-files dir)
  50. (concat (when (fs/existsSync (node-path/join dir* "assets"))
  51. (common-graph/readdir (node-path/join dir* "assets"))))
  52. (mapv #(hash-map :path %
  53. ::rpath (node-path/relative dir* %))))))
  54. (defn- <read-file
  55. [file]
  56. (p/let [s (fs/readFileSync (:path file))]
  57. (str s)))
  58. (defn- notify-user [m]
  59. (println (:msg m))
  60. (println "Ex-data:" (pr-str (dissoc (:ex-data m) :error)))
  61. (println "Stacktrace:")
  62. (if-let [stack (some-> (get-in m [:ex-data :error]) ex-data :sci.impl/callstack deref)]
  63. (println (string/join
  64. "\n"
  65. (map
  66. #(str (:file %)
  67. (when (:line %) (str ":" (:line %)))
  68. (when (:sci.impl/f-meta %)
  69. (str " calls #'" (get-in % [:sci.impl/f-meta :ns]) "/" (get-in % [:sci.impl/f-meta :name]))))
  70. (reverse stack))))
  71. (println (some-> (get-in m [:ex-data :error]) .-stack))))
  72. (def default-export-options
  73. {;; common options
  74. :rpath-key ::rpath
  75. :notify-user notify-user
  76. :<read-file <read-file
  77. ;; :set-ui-state prn
  78. ;; config file options
  79. ;; TODO: Add actual default
  80. :default-config {}})
  81. ;; Copied from db-import script and tweaked for an in-memory import
  82. (defn- import-file-graph-to-db
  83. "Import a file graph dir just like UI does. However, unlike the UI the
  84. exporter receives file maps containing keys :path and ::rpath since :path
  85. are full paths"
  86. [file-graph-dir conn {:keys [assets] :as options}]
  87. (let [*files (build-graph-files file-graph-dir)
  88. config-file (first (filter #(string/ends-with? (:path %) "logseq/config.edn") *files))
  89. _ (assert config-file "No 'logseq/config.edn' found for file graph dir")
  90. options' (-> (merge options
  91. default-export-options
  92. ;; asset file options
  93. {:<copy-asset #(swap! assets conj %)})
  94. (dissoc :assets))]
  95. (gp-exporter/export-file-graph conn conn config-file *files options')))
  96. (defn- import-files-to-db
  97. "Import specific doc files for dev purposes"
  98. [files conn options]
  99. (p/let [doc-options (gp-exporter/build-doc-options {:macros {}} (merge options default-export-options))
  100. files' (mapv #(hash-map :path %) files)
  101. _ (gp-exporter/export-doc-files conn files' <read-file doc-options)]
  102. {:import-state (:import-state doc-options)}))
  103. (defn- readable-properties
  104. [db query-ent]
  105. (->> (db-property/properties query-ent)
  106. (map (fn [[k v]]
  107. [k
  108. (if-let [built-in-type (get-in db-property/built-in-properties [k :schema :type])]
  109. (if (= :block/tags k)
  110. (mapv #(:db/ident (d/entity db (:db/id %))) v)
  111. (if (db-property-type/ref-property-types built-in-type)
  112. (db-property/ref->property-value-contents db v)
  113. v))
  114. (db-property/ref->property-value-contents db v))]))
  115. (into {})))
  116. ;; Tests
  117. ;; =====
  118. (deftest-async export-basic-graph
  119. ;; This graph will contain basic examples of different features to import
  120. (p/let [file-graph-dir "test/resources/exporter-test-graph"
  121. conn (d/create-conn db-schema/schema-for-db-based-graph)
  122. _ (d/transact! conn (sqlite-create-graph/build-db-initial-data "{}"))
  123. assets (atom [])
  124. {:keys [import-state]} (import-file-graph-to-db file-graph-dir conn {:assets assets})]
  125. (testing "whole graph"
  126. (is (nil? (:errors (db-validate/validate-db! @conn)))
  127. "Created graph has no validation errors")
  128. ;; Counts
  129. ;; Includes journals as property values e.g. :logseq.task/deadline
  130. (is (= 16 (count (d/q '[:find ?b :where [?b :block/type "journal"]] @conn))))
  131. ;; Don't count pages like url.md that have properties but no content
  132. (is (= 4
  133. (count (->> (d/q '[:find [(pull ?b [:block/original-name :block/type]) ...]
  134. :where [?b :block/original-name] [_ :block/page ?b]] @conn)
  135. (filter #(= ["page"] (:block/type %))))))
  136. "Correct number of pages with block content")
  137. (is (= 0 (count @(:ignored-properties import-state))) "No ignored properties")
  138. (is (= 1 (count @assets))))
  139. (testing "logseq files"
  140. (is (= ".foo {}\n"
  141. (ffirst (d/q '[:find ?content :where [?b :file/path "logseq/custom.css"] [?b :file/content ?content]] @conn))))
  142. (is (= "logseq.api.show_msg('hello good sir!');\n"
  143. (ffirst (d/q '[:find ?content :where [?b :file/path "logseq/custom.js"] [?b :file/content ?content]] @conn)))))
  144. (testing "favorites"
  145. (is (= #{"Interstellar" "some page"}
  146. (->>
  147. (ldb/get-page-blocks @conn
  148. (:db/id (ldb/get-page @conn common-config/favorites-page-name))
  149. {:pull-keys '[* {:block/link [:block/original-name]}]})
  150. (map #(get-in % [:block/link :block/original-name]))
  151. set))))
  152. (testing "user properties"
  153. (is (= 15
  154. (->> @conn
  155. (d/q '[:find [(pull ?b [:db/ident]) ...]
  156. :where [?b :block/type "property"]])
  157. (remove #(db-malli-schema/internal-ident? (:db/ident %)))
  158. count))
  159. "Correct number of user properties")
  160. (is (= #{{:db/ident :user.property/prop-bool :block/schema {:type :checkbox}}
  161. {:db/ident :user.property/prop-string :block/schema {:type :default}}
  162. {:db/ident :user.property/prop-num :block/schema {:type :number}}
  163. {:db/ident :user.property/sameas :block/schema {:type :url}}
  164. {:db/ident :user.property/rangeincludes :block/schema {:type :page}}
  165. {:db/ident :user.property/startedat :block/schema {:type :date}}}
  166. (->> @conn
  167. (d/q '[:find [(pull ?b [:db/ident :block/schema]) ...]
  168. :where [?b :block/type "property"]])
  169. (filter #(contains? #{:prop-bool :prop-string :prop-num :rangeincludes :sameas :startedat}
  170. (keyword (name (:db/ident %)))))
  171. set))
  172. "Main property types have correct inferred :type")
  173. (is (= :default
  174. (get-in (d/entity @conn :user.property/description) [:block/schema :type]))
  175. "Property value consisting of text and refs is inferred as :default")
  176. (is (= :url
  177. (get-in (d/entity @conn :user.property/url) [:block/schema :type]))
  178. "Property value with a macro correctly inferred as :url")
  179. (is (= {:user.property/prop-bool true
  180. :user.property/prop-num 5
  181. :user.property/prop-string "woot"}
  182. (update-vals (db-property/properties (find-block-by-content @conn "b1"))
  183. #(db-property/ref->property-value-content @conn %)))
  184. "Basic block has correct properties")
  185. (is (= #{"prop-num" "prop-string" "prop-bool"}
  186. (->> (d/entity @conn (:db/id (find-block-by-content @conn "b1")))
  187. :block/refs
  188. (map :block/original-name)
  189. set))
  190. "Block with properties has correct refs")
  191. (is (= {:user.property/prop-num2 10}
  192. (readable-properties @conn (find-page-by-name @conn "new page")))
  193. "New page has correct properties")
  194. (is (= {:user.property/prop-bool true
  195. :user.property/prop-num 5
  196. :user.property/prop-string "yeehaw"}
  197. (readable-properties @conn (find-page-by-name @conn "some page")))
  198. "Existing page has correct properties")
  199. (is (= {:user.property/rating 5.5}
  200. (readable-properties @conn (find-block-by-content @conn ":rating float")))
  201. "Block with float property imports as a float"))
  202. (testing "built-in properties"
  203. (is (= [(:db/id (find-block-by-content @conn "original block"))]
  204. (mapv :db/id (:block/refs (find-block-by-content @conn #"ref to"))))
  205. "block with a block-ref has correct :block/refs")
  206. (let [b (find-block-by-content @conn #"MEETING TITLE")]
  207. (is (= {}
  208. (and b (readable-properties @conn b)))
  209. ":template properties are ignored to not invalidate its property types"))
  210. (is (= {:logseq.task/deadline "Nov 26th, 2022"}
  211. (readable-properties @conn (find-block-by-content @conn "only deadline")))
  212. "deadline block has correct journal as property value")
  213. (is (= {:logseq.task/deadline "Nov 25th, 2022"}
  214. (readable-properties @conn (find-block-by-content @conn "only scheduled")))
  215. "scheduled block converted to correct deadline")
  216. (is (= {:logseq.task/priority "High"}
  217. (readable-properties @conn (find-block-by-content @conn "high priority")))
  218. "priority block has correct property")
  219. (is (= {:logseq.task/status "Doing" :logseq.task/priority "Medium" :block/tags [:logseq.class/task]}
  220. (readable-properties @conn (find-block-by-content @conn "status test")))
  221. "status block has correct task properties and class")
  222. (is (= #{:logseq.task/status :block/tags}
  223. (set (keys (readable-properties @conn (find-block-by-content @conn "old todo block")))))
  224. "old task properties like 'todo' are ignored")
  225. (is (= {:logseq.property/order-list-type "number"}
  226. (readable-properties @conn (find-block-by-content @conn "list one")))
  227. "numered block has correct property")
  228. (is (= {:logseq.property/query-sort-by :user.property/prop-num
  229. :logseq.property/query-properties [:block :page :user.property/prop-string :user.property/prop-num]
  230. :logseq.property/query-table true}
  231. (readable-properties @conn (find-block-by-content @conn "{{query (property :prop-string)}}")))
  232. "query block has correct query properties"))
  233. (testing "db attributes"
  234. (is (= true
  235. (:block/collapsed? (find-block-by-content @conn "collapsed block")))
  236. "Collapsed blocks are imported"))
  237. (testing "property :type changes"
  238. (is (= :page
  239. (get-in (d/entity @conn :user.property/finishedat) [:block/schema :type]))
  240. ":date property to :page value changes to :page")
  241. (is (= :page
  242. (get-in (d/entity @conn :user.property/participants) [:block/schema :type]))
  243. ":page property to :date value remains :page")
  244. (is (= :default
  245. (get-in (d/entity @conn :user.property/duration) [:block/schema :type]))
  246. ":number property to :default value changes to :default")
  247. (is (= :default
  248. (get-in (d/entity @conn :user.property/description) [:block/schema :type]))
  249. ":default property to :page (or any non :default value) remains :default")
  250. (is (= "[[Jakob]]"
  251. (:user.property/description (readable-properties @conn (find-block-by-content @conn #":default to :page"))))
  252. ":page property value correctly saved as :default with full text"))
  253. (testing "replacing refs in :block/content"
  254. (is (= 2
  255. (->> (find-block-by-content @conn #"replace with same start string")
  256. :block/content
  257. (re-seq #"\[\[~\^\S+\]\]")
  258. distinct
  259. count))
  260. "A block with ref names that start with same string has 2 distinct refs")
  261. (is (= 1
  262. (->> (find-block-by-content @conn #"replace case insensitive")
  263. :block/content
  264. (re-seq #"\[\[~\^\S+\]\]")
  265. distinct
  266. count))
  267. "A block with different case of same ref names has 1 distinct ref"))
  268. (testing "tags without tag options"
  269. (let [block (find-block-by-content @conn #"Inception")
  270. tag-page (find-page-by-name @conn "Movie")
  271. tagged-page (find-page-by-name @conn "Interstellar")]
  272. (is (string/starts-with? (str (:block/content block)) "Inception [[")
  273. "tagged block tag converts tag to page ref")
  274. (is (= [(:db/id tag-page)] (map :db/id (:block/refs block)))
  275. "tagged block has correct refs")
  276. (is (and tag-page (not (contains? (set (:block/type tag-page)) "class")))
  277. "tag page is not a class")
  278. (is (= {:logseq.property/page-tags #{"Movie"}}
  279. (readable-properties @conn tagged-page))
  280. "tagged page has tags imported as page-tags property by default")))))
  281. (deftest-async export-file-with-tag-classes-option
  282. (p/let [file-graph-dir "test/resources/exporter-test-graph"
  283. files (mapv #(node-path/join file-graph-dir %) ["journals/2024_02_07.md" "pages/Interstellar.md"])
  284. conn (d/create-conn db-schema/schema-for-db-based-graph)
  285. _ (d/transact! conn (sqlite-create-graph/build-db-initial-data "{}"))
  286. _ (import-files-to-db files conn {:tag-classes ["movie"]})]
  287. (let [block (find-block-by-content @conn #"Inception")
  288. tag-page (find-page-by-name @conn "Movie")
  289. another-tag-page (find-page-by-name @conn "p0")]
  290. (is (= (:block/content block) "Inception")
  291. "tagged block with configured tag strips tag from content")
  292. (is (= [:user.class/Movie]
  293. (:block/tags (readable-properties @conn block)))
  294. "tagged block has configured tag imported as a class")
  295. (is (= ["class" "page"] (:block/type tag-page))
  296. "configured tag page in :tag-classes is a class")
  297. (is (and another-tag-page (not (contains? (set (:block/type another-tag-page)) "class")))
  298. "unconfigured tag page is not a class")
  299. (is (= {:block/tags [:user.class/Movie]}
  300. (readable-properties @conn (find-page-by-name @conn "Interstellar")))
  301. "tagged page has configured tag imported as a class"))))
  302. (deftest-async export-file-with-property-classes-option
  303. (p/let [file-graph-dir "test/resources/exporter-test-graph"
  304. files (mapv #(node-path/join file-graph-dir %) ["journals/2024_02_23.md" "pages/url.md"])
  305. conn (d/create-conn db-schema/schema-for-db-based-graph)
  306. _ (d/transact! conn (sqlite-create-graph/build-db-initial-data "{}"))
  307. _ (import-files-to-db files conn {:property-classes ["type"]})]
  308. (let [block (find-block-by-content @conn #"The Creator")
  309. tag-page (find-page-by-name @conn "Movie")]
  310. (is (= (:block/content block) "The Creator")
  311. "tagged block with configured tag strips tag from content")
  312. (is (= [:user.class/Movie]
  313. (:block/tags (readable-properties @conn block)))
  314. "tagged block has configured tag imported as a class")
  315. (is (= (:user.property/testtagclass block) (:block/tags block))
  316. "tagged block can have another property that references the same class it is tagged with,
  317. without creating a duplicate class")
  318. (is (= ["class" "page"] (:block/type tag-page))
  319. "configured tag page derived from :property-classes is a class")
  320. (is (nil? (find-page-by-name @conn "type"))
  321. "No page exists for configured property")
  322. (is (= [:user.class/Property]
  323. (:block/tags (readable-properties @conn (find-page-by-name @conn "url"))))
  324. "tagged page has configured tag imported as a class"))))
  325. (deftest-async export-file-with-ignored-properties
  326. (p/let [file-graph-dir "test/resources/exporter-test-graph"
  327. files (mapv #(node-path/join file-graph-dir %) ["ignored/icon-page.md"])
  328. conn (d/create-conn db-schema/schema-for-db-based-graph)
  329. _ (d/transact! conn (sqlite-create-graph/build-db-initial-data "{}"))
  330. {:keys [import-state]} (import-files-to-db files conn {})]
  331. (is (= 2
  332. (count (filter #(= :icon (:property %)) @(:ignored-properties import-state))))
  333. "icon properties are visibly ignored in order to not fail import")))