Initial import of LogDarioSeq baseline

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>
This commit is contained in:
2026-08-25 15:32:15 +02:00
commit 40631362b3
2016 changed files with 543898 additions and 0 deletions

View File

@@ -0,0 +1,327 @@
(ns logseq.outliner.core-test
(:require [cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[logseq.db :as ldb]
[logseq.db.test.helper :as db-test]
[logseq.outliner.core :as outliner-core]
[logseq.common.config :as common-config]))
(deftest insert-blocks-does-not-trust-stale-right-order
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target"}
{:block/title "original right"}]}])
target (db-test/find-block-by-content @conn "target")
stale-right-order (:block/order (db-test/find-block-by-content @conn "original right"))]
(outliner-core/insert-blocks!
conn
[{:block/uuid (random-uuid)
:block/title "concurrent right"}]
target
{:sibling? true
:keep-uuid? true})
(let [target (db-test/find-block-by-content @conn "target")
current-right (ldb/get-right-sibling target)
{:keys [blocks]} (outliner-core/insert-blocks
@conn
[{:block/uuid (random-uuid)
:block/title "inserted"}]
target
{:sibling? true
:keep-uuid? true
:end-order-state [:known stale-right-order]})
inserted (first blocks)]
(is (= "concurrent right" (:block/title current-right)))
(is (neg? (compare (:block/order inserted) (:block/order current-right)))
"A stale renderer boundary must not place the new block after the current right sibling."))))
(deftest insert-blocks-finds-right-order-on-1k-sibling-page
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks (mapv (fn [idx]
{:block/title (str "block " idx)})
(range 1000))}])
target (db-test/find-block-by-content @conn "block 0")
insert! #(outliner-core/insert-blocks
@conn
[{:block/uuid (random-uuid)
:block/title "inserted"}]
target
{:sibling? true
:keep-uuid? true})
_ (insert!)
started-at (.now js/performance)
{:keys [blocks]} (insert!)
elapsed-ms (- (.now js/performance) started-at)
right (ldb/get-right-sibling target)]
(is (neg? (compare (:block/order (first blocks)) (:block/order right))))
(is (< elapsed-ms 100)
(str "1k sibling insertion order lookup took " elapsed-ms "ms"))))
(deftest blocks-with-level-handles-10k-deep-tree
(let [blocks (mapv (fn [id]
{:db/id id
:block/uuid (str id)
:block/parent {:db/id (dec id)}})
(range 1 10001))
started-at (.now js/performance)
result (outliner-core/blocks-with-level blocks)
elapsed-ms (- (.now js/performance) started-at)]
(is (= 1 (:block/level (first result))))
(is (= 10000 (:block/level (last result))))
(is (< elapsed-ms 250) (str "10k level calculation took " elapsed-ms "ms"))))
(deftest existing-inline-class-uses-its-canonical-uuid
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"} :blocks []}])
class-uuid (random-uuid)
parsed-uuid (random-uuid)
ref {:block/type "page"
:block/name "existing"
:block/title "Existing"
:block/uuid parsed-uuid}
_ (d/transact! conn [{:db/ident :user.class/existing
:block/name "existing"
:block/title "Existing"
:block/uuid class-uuid
:block/tags :logseq.class/Tag}])
{:keys [block page-txs]}
(#'outliner-core/resolve-page-refs
@conn
{:block/title (str "#[[" parsed-uuid "]]")
:block/refs [ref]
:block/tags [ref]})]
(is (empty? page-txs))
(is (= (str "#[[" class-uuid "]]") (:block/title block)))
(is (= class-uuid (-> block :block/refs first :block/uuid)))
(is (= class-uuid (-> block :block/tags first :block/uuid)))))
(deftest test-delete-block-with-default-property
(testing "Delete block with default property hard retracts the block subtree"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:default "test block"}}]}])
block (db-test/find-block-by-content @conn "b1")]
(outliner-core/delete-blocks! conn [block] {})
(is (nil? (db-test/find-block-by-content @conn "b1"))))))
(deftest test-delete-page-with-outliner-core
(testing "Deleting pages through outliner-core/delete-blocks detaches page position only"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}
{:page {:block/title "page2"}
:blocks [{:block/title "b3"}
{:block/title "b4"}]}])
page2 (ldb/get-page @conn "page2")
_ (d/transact! conn [{:db/id (:db/id page2)
:block/order "a1"
:block/parent (:db/id (ldb/get-page @conn "page1"))}])
b3 (db-test/find-block-by-content @conn "b3")
b4 (db-test/find-block-by-content @conn "b4")]
(outliner-core/delete-blocks! conn [b3 b4 page2] {})
(is (some? (db-test/find-block-by-content @conn "b3")))
(is (some? (db-test/find-block-by-content @conn "b4")))
(let [page2' (ldb/get-page @conn "page2")]
(is (= "page2" (:block/title page2')))
(is (nil? (:block/parent page2')))
(is (nil? (:block/order page2')))))))
(deftest delete-blocks-hard-retracts-subtree
(let [user-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}])
parent (db-test/find-block-by-content @conn "parent")]
(d/transact! conn [{:block/uuid user-uuid
:block/title "Alice"}])
(outliner-core/delete-blocks! conn [parent] {:deleted-by-uuid user-uuid})
(let [parent' (db-test/find-block-by-content @conn "parent")
child' (db-test/find-block-by-content @conn "child")]
(is (nil? parent'))
(is (nil? child')))))
(deftest delete-blocks-removes-range-comments-when-all-targets-are-deleted
(let [now (js/Date.now)
comments-uuid (random-uuid)
comment-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target 1"}
{:block/title "target 2"}]}])
page (ldb/get-page @conn "page1")
target-1 (db-test/find-block-by-content @conn "target 1")
target-2 (db-test/find-block-by-content @conn "target 2")]
(d/transact! conn [{:db/ident :logseq.class/Comments
:block/uuid (random-uuid)
:block/title "Comments"}
{:db/id -1
:block/uuid comments-uuid
:block/title "Comments"
:block/page (:db/id page)
:block/parent (:db/id page)
:block/order "a3"
:block/created-at now
:block/updated-at now
:block/tags #{:logseq.class/Comments}
:logseq.property.comments/blocks #{(:db/id target-1)
(:db/id target-2)}}
{:block/uuid comment-uuid
:block/title "comment"
:block/page (:db/id page)
:block/parent -1
:block/order "a0"
:block/created-at now
:block/updated-at now}])
(outliner-core/delete-blocks! conn [target-1 target-2] {})
(is (nil? (d/entity @conn [:block/uuid comments-uuid])))
(is (nil? (d/entity @conn [:block/uuid comment-uuid])))))
(deftest delete-blocks-keeps-range-comments-when-some-targets-remain
(let [now (js/Date.now)
comments-uuid (random-uuid)
comment-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target 1"}
{:block/title "target 2"}]}])
page (ldb/get-page @conn "page1")
target-1 (db-test/find-block-by-content @conn "target 1")
target-2 (db-test/find-block-by-content @conn "target 2")]
(d/transact! conn [{:db/ident :logseq.class/Comments
:block/uuid (random-uuid)
:block/title "Comments"}
{:db/id -1
:block/uuid comments-uuid
:block/title "Comments"
:block/page (:db/id page)
:block/parent (:db/id page)
:block/order "a3"
:block/created-at now
:block/updated-at now
:block/tags #{:logseq.class/Comments}
:logseq.property.comments/blocks #{(:db/id target-1)
(:db/id target-2)}}
{:block/uuid comment-uuid
:block/title "comment"
:block/page (:db/id page)
:block/parent -1
:block/order "a0"
:block/created-at now
:block/updated-at now}])
(outliner-core/delete-blocks! conn [target-1] {})
(is (some? (d/entity @conn [:block/uuid comments-uuid])))
(is (some? (d/entity @conn [:block/uuid comment-uuid])))
(is (some? (d/entity @conn (:db/id target-2))))))
(deftest delete-blocks-rejects-built-in-entities
(let [conn (db-test/create-conn)]
(testing "built-in page is rejected"
(let [recycle-page (ldb/get-page @conn common-config/recycle-page-name)]
(is (true? (:logseq.property/built-in? recycle-page)))
(is (thrown-with-msg? js/Error #"Built-in nodes can't be deleted"
(db-test/silence-stderr (outliner-core/delete-blocks! conn [recycle-page] {}))))))
(testing "built-in idents that are not a class or property like empty-placeholder are rejected"
(let [placeholder (d/entity @conn :logseq.property/empty-placeholder)]
(is (some? (:block/uuid placeholder)))
(is (thrown-with-msg? js/Error #"Built-in nodes can't be deleted"
(db-test/silence-stderr (outliner-core/delete-blocks! conn [placeholder] {}))))))
(testing "file entity is rejected"
(let [file (->> (d/datoms @conn :avet :file/path)
first
:e
(d/entity @conn))]
(is (some? (:file/path file)))
(is (thrown-with-msg? js/Error #"Built-in nodes can't be deleted"
(db-test/silence-stderr (outliner-core/delete-blocks! conn [file] {}))))))
(testing "KV entity is rejected"
(let [kv (d/entity @conn :logseq.kv/db-type)]
(is (some? (:db/id kv)))
(is (thrown-with-msg? js/Error #"Built-in nodes can't be deleted"
(db-test/silence-stderr (outliner-core/delete-blocks! conn [kv] {}))))))))
(deftest save-block-rejects-built-in-entity
(let [conn (db-test/create-conn)
placeholder (d/entity @conn :logseq.property/description)]
(is (thrown-with-msg? js/Error #"Built-in.*can't be modified"
(db-test/silence-stderr
(outliner-core/save-block! conn {:db/id (:db/id placeholder) :block/title "hacked"}))))))
(deftest move-blocks-rejects-built-in-entity
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target"}]}])
placeholder (d/entity @conn :logseq.property/description)
target (db-test/find-block-by-content @conn "target")]
(is (thrown-with-msg? js/Error #"Built-in.*can't be modified"
(db-test/silence-stderr
(outliner-core/move-blocks! conn [placeholder] target {:sibling? true}))))))
(deftest move-blocks-protects-comment-blocks
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target"}
{:block/title "tagged comment"}
{:block/title "normal parent"
:build/children [{:block/title "ordinary"}]}
{:block/title "Comments"}]}])
page (ldb/get-page @conn "page1")
target (db-test/find-block-by-content @conn "target")
tagged-comment (db-test/find-block-by-content @conn "tagged comment")
ordinary (db-test/find-block-by-content @conn "ordinary")
comments-area (db-test/find-block-by-content @conn "Comments")
original-comment-parent-id (:db/id (:block/parent tagged-comment))]
(d/transact! conn [{:db/id -1
:db/ident :logseq.class/Tag
:block/uuid (random-uuid)
:block/title "Tag"
:block/tags #{-1}}
{:db/ident :logseq.class/Comments
:block/uuid (random-uuid)
:block/title "Comments"
:block/tags #{-1}}
{:db/ident :logseq.class/Comment
:block/uuid (random-uuid)
:block/title "Comment"
:block/tags #{-1}}
[:db/add (:db/id comments-area) :block/tags :logseq.class/Comments]
[:db/add (:db/id tagged-comment) :block/tags :logseq.class/Comment]])
(testing "#Comment blocks cannot be moved after creation"
(outliner-core/move-blocks! conn [tagged-comment] target {:sibling? false})
(is (= original-comment-parent-id
(:db/id (:block/parent (d/entity @conn (:db/id tagged-comment)))))))
(testing "#Comments blocks cannot be moved as children"
(outliner-core/move-blocks! conn [comments-area] target {:sibling? false})
(let [comments-area' (d/entity @conn (:db/id comments-area))]
(is (= (:db/id page)
(:db/id (:block/parent comments-area'))))))
(testing "#Comments blocks can be moved as siblings"
(outliner-core/move-blocks! conn [comments-area] target {:sibling? true})
(let [page' (d/entity @conn (:db/id page))
children (->> (:block/_parent page')
ldb/sort-by-order
(mapv :block/title))]
(is (= ["target" "Comments" "tagged comment" "normal parent"] children))))
(testing "ordinary blocks can be moved as siblings of #Comments blocks"
(outliner-core/move-blocks! conn [ordinary] comments-area {:sibling? true})
(let [ordinary' (d/entity @conn (:db/id ordinary))]
(is (= (:db/id page)
(:db/id (:block/parent ordinary'))))))
(testing "ordinary blocks cannot be moved as children of #Comments blocks"
(outliner-core/move-blocks! conn [ordinary] comments-area {:sibling? false})
(is (= (:db/id page)
(:db/id (:block/parent (d/entity @conn (:db/id ordinary)))))))
(testing "ordinary blocks cannot be moved to #Comment blocks"
(outliner-core/move-blocks! conn [ordinary] tagged-comment {:sibling? false})
(is (= (:db/id page)
(:db/id (:block/parent (d/entity @conn (:db/id ordinary)))))))))

View File

@@ -0,0 +1,530 @@
(ns logseq.outliner.op-construct-test
(:require [cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[logseq.common.util.date-time :as date-time-util]
[logseq.common.uuid :as common-uuid]
[logseq.db :as ldb]
[logseq.db.frontend.property :as db-property]
[logseq.db.test.helper :as db-test]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.op.construct :as op-construct]))
(defn- run-direct-outdent
[conn block]
(let [{:keys [tx-data]}
(#'outliner-core/indent-outdent-blocks
conn [block] false
:parent-original nil
:logical-outdenting? nil)
tx-report (d/with @conn tx-data {})]
{:tx-data (:tx-data tx-report)
:db-after (:db-after tx-report)}))
(deftest derive-history-outliner-ops-canonicalizes-create-page-and-builds-delete-inverse-test
(testing "create-page forward op keeps created uuid and reverse op deletes that page"
(let [conn (db-test/create-conn-with-blocks {:pages-and-blocks []})
page-uuid (random-uuid)
tx-data [{:e 1 :a :block/title :v "Created Page" :added true}
{:e 1 :a :block/uuid :v page-uuid :added true}]
tx-meta {:outliner-op :create-page
:outliner-ops [[:create-page ["Created Page"
{:redirect? false
:split-namespace? true
:tags ()}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn tx-data tx-meta)]
(is (= :create-page (ffirst forward-outliner-ops)))
(is (= page-uuid (get-in forward-outliner-ops [0 1 1 :uuid])))
(is (= [[:delete-page [page-uuid {}]]]
inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-handles-replace-empty-target-insert-inverse-test
(testing "replace-empty-target insert keeps source uuid and inverse deletes target placeholder"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title ""}]}]})
empty-target (db-test/find-block-by-content @conn "")
parent-uuid (random-uuid)
child-uuid (random-uuid)
tx-meta {:outliner-op :insert-blocks
:outliner-ops [[:insert-blocks [[{:block/uuid parent-uuid
:block/title "paste parent"}
{:block/uuid child-uuid
:block/title "paste child"
:block/parent [:block/uuid parent-uuid]}]
(:db/id empty-target)
{:sibling? true
:replace-empty-target? true
:outliner-op :paste}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= parent-uuid
(get-in forward-outliner-ops [0 1 0 0 :block/uuid])))
(is (= true (get-in forward-outliner-ops [0 1 2 :keep-uuid?])))
(is (some #(and (= :delete-blocks (first %))
(= [(:block/uuid empty-target)]
(vec (get-in % [1 0]))))
(remove nil? inverse-outliner-ops)))
(is (not-any? #(= :save-block (first %))
(remove nil? inverse-outliner-ops))))))
(deftest derive-history-outliner-ops-builds-upsert-property-inverse-delete-page-test
(testing "upsert-property with qualified keyword builds delete-page inverse"
(let [conn (db-test/create-conn-with-blocks {:pages-and-blocks []})
property-id :user.property/custom-prop
tx-meta {:outliner-op :upsert-property
:outliner-ops [[:upsert-property [property-id
{:logseq.property/type :default}
{:property-name "custom-prop"}]]]}
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)
expected-page-uuid (common-uuid/gen-uuid :db-ident-block-uuid property-id)]
(is (= [[:delete-page [expected-page-uuid {}]]]
inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-upsert-property-update-builds-schema-restore-inverse-test
(testing "upsert-property on existing property builds inverse upsert-property restore op"
(let [conn (db-test/create-conn-with-blocks
{:properties {:p-many {:logseq.property/type :default}}
:pages-and-blocks []})
property-id :user.property/p-many
_ (d/transact! conn [[:db/add property-id :logseq.property/classes :logseq.class/Root]])
before-property (d/entity @conn property-id)
expected-schema (-> (db-property/get-property-schema (into {} before-property))
(update :logseq.property/classes
(fn [classes]
(some->> classes
(map (fn [class]
(if-let [class-uuid (:block/uuid class)]
[:block/uuid class-uuid]
(:db/ident class))))
set))))
tx-meta {:outliner-op :upsert-property
:outliner-ops [[:upsert-property [property-id
{:logseq.property/type :node
:db/cardinality :many}
{}]]]}
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= [[:upsert-property [property-id expected-schema {:property-name "p-many"}]]]
inverse-outliner-ops))
(is (every? (fn [class-ref]
(or (keyword? class-ref)
(and (vector? class-ref)
(= :block/uuid (first class-ref))
(uuid? (second class-ref)))))
(get-in inverse-outliner-ops [0 1 1 :logseq.property/classes]))))))
(deftest derive-history-outliner-ops-delete-blocks-inverse-avoids-self-target-test
(testing "delete-blocks inverse falls back to parent target when left sibling resolves to self"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}]})
child (db-test/find-block-by-content @conn "child")
child-id (:db/id child)
child-uuid (:block/uuid child)
parent-uuid (some-> child :block/parent :block/uuid)
tx-meta {:outliner-op :delete-blocks
:outliner-ops [[:delete-blocks [[child-id] {}]]]}]
;; Simulate stale sibling lookup returning the same entity as the deleted root.
(with-redefs [ldb/get-left-sibling (fn [_] child)]
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)
insert-op (first inverse-outliner-ops)]
(is (= :insert-blocks (first insert-op)))
(is (= parent-uuid
(get-in insert-op [1 1])))
(is (= false (get-in insert-op [1 2 :sibling?])))
(is (not= child-uuid
(get-in insert-op [1 1]))))))))
(deftest compound-history-inverses-run-in-reverse-dependency-order-test
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "target"}
{:block/title "source"
:build/children [{:block/title "nested"}]}]}]})
target (db-test/find-block-by-content @conn "target")
source (db-test/find-block-by-content @conn "source")
nested (db-test/find-block-by-content @conn "nested")
tx-meta {:outliner-op :delete-blocks
:outliner-ops [[:move-blocks [[(:db/id nested)]
(:db/id target)
{:sibling? false}]]
[:delete-blocks [[(:db/id source)] {}]]
[:save-block [(assoc (into {} target) :block/title "combined") nil]]]}
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= [:save-block :insert-blocks :move-blocks]
(mapv first inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-delete-blocks-with-stale-id-keeps-id-test
(testing "delete-blocks derive-history keeps unresolved numeric ids in forward ops"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}]})
child (db-test/find-block-by-content @conn "child")
child-uuid (:block/uuid child)
stale-id 99999999
tx-meta {:outliner-op :delete-blocks
:outliner-ops [[:delete-blocks [[(:db/id child) stale-id] {}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= [[:delete-blocks [[child-uuid stale-id] {}]]]
forward-outliner-ops)))))
(deftest derive-history-outliner-ops-delete-blocks-prefers-retracted-tx-data-ids-test
(testing "delete-blocks derive-history should prefer tx-data retractEntity ids over stale selection ids"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}]})
child (db-test/find-block-by-content @conn "child")
child-id (:db/id child)
child-uuid (:block/uuid child)
stale-id 99999999
tx-report (d/with @conn [[:db/retractEntity child-id]] {})
tx-meta {:outliner-op :delete-blocks
:outliner-ops [[:delete-blocks [[child-id stale-id] {}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn (:db-after tx-report) (:tx-data tx-report) tx-meta)]
(is (= [[:delete-blocks [[child-uuid] {}]]]
forward-outliner-ops)))))
(deftest derive-history-outliner-ops-move-blocks-resolves-target-id-from-tx-data-test
(testing "move-blocks should resolve stale numeric target id using tx-data block uuid"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "child"}]}]})
child (db-test/find-block-by-content @conn "child")
stale-target-id 9999999
target-uuid (random-uuid)
tx-data [{:e stale-target-id :a :block/uuid :v target-uuid :added false}]
tx-meta {:outliner-op :move-blocks
:outliner-ops [[:move-blocks [[(:db/id child)] stale-target-id {:sibling? true}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn tx-data tx-meta)]
(is (= target-uuid
(get-in forward-outliner-ops [0 1 1]))))))
(deftest derive-history-outliner-ops-save-block-resolves-retracted-ref-id-from-db-before-test
(testing "save-block should canonicalize ref attrs using db-before when target ref is retracted in current tx"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "child"}]}]})
child (db-test/find-block-by-content @conn "child")
child-uuid (:block/uuid child)
tag-uuid (random-uuid)
_ (d/transact! conn [{:db/id -1
:block/uuid tag-uuid
:block/title "Tag"
:block/name "tag"
:block/tags :logseq.class/Tag}])
tag-id (:db/id (d/entity @conn [:block/uuid tag-uuid]))
tx-report (d/with @conn [[:db/retractEntity tag-id]] {})
tx-meta {:outliner-op :save-block
:outliner-ops [[:save-block [{:block/uuid child-uuid
:block/tags #{tag-id}}
{}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn (:db-after tx-report) (:tx-data tx-report) tx-meta)]
(is (= #{[:block/uuid tag-uuid]}
(get-in forward-outliner-ops [0 1 0 :block/tags]))))))
(deftest derive-history-outliner-ops-insert-blocks-resolves-retracted-ref-id-from-tx-data-test
(testing "insert-blocks should canonicalize block ref attrs when ref entity is retracted in current tx"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "target"}]}]})
target (db-test/find-block-by-content @conn "target")
target-id (:db/id target)
tag-uuid (random-uuid)
_ (d/transact! conn [{:db/id -1
:block/uuid tag-uuid
:block/title "Tag"
:block/name "tag"
:block/tags :logseq.class/Tag}])
tag-id (:db/id (d/entity @conn [:block/uuid tag-uuid]))
tx-report (d/with @conn [[:db/retractEntity tag-id]] {})
tx-meta {:outliner-op :insert-blocks
:outliner-ops [[:insert-blocks [[{:block/uuid (random-uuid)
:block/title "new child"
:block/tags #{tag-id}}]
target-id
{:sibling? true}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn (:db-after tx-report) (:tx-data tx-report) tx-meta)]
(is (= #{[:block/uuid tag-uuid]}
(get-in forward-outliner-ops [0 1 0 0 :block/tags]))))))
(deftest derive-history-outliner-ops-apply-template-undo-canonicalizes-template-block-refs-test
(testing "apply-template undo explicit inverse should canonicalize nested template-block ref attrs"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "template"}
{:block/title "target"}]}]})
template (db-test/find-block-by-content @conn "template")
target (db-test/find-block-by-content @conn "target")
template-uuid (:block/uuid template)
target-uuid (:block/uuid target)
inserted-uuid (random-uuid)
tag-uuid (random-uuid)
_ (d/transact! conn [{:db/id -1
:block/uuid tag-uuid
:block/title "Tag"
:block/name "tag"
:block/tags :logseq.class/Tag}])
tag-id (:db/id (d/entity @conn [:block/uuid tag-uuid]))
tx-report (d/with @conn [[:db/retractEntity tag-id]] {})
tx-meta {:outliner-op :apply-template
:undo? true
:db-sync/inverse-outliner-ops
[[:apply-template [template-uuid
target-uuid
{:template-blocks [{:block/uuid inserted-uuid
:block/title "inserted"
:block/tags #{tag-id}}]
:sibling? true}]]]}
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn (:db-after tx-report) (:tx-data tx-report) tx-meta)]
(is (= #{[:block/uuid tag-uuid]}
(get-in inverse-outliner-ops [0 1 2 :template-blocks 0 :block/tags]))))))
(deftest derive-history-outliner-ops-apply-template-captures-template-blocks-when-missing-in-op-test
(testing "apply-template forward op should capture concrete template-blocks even if original op omitted them"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "template"
:build/children [{:block/title "template child 1"}
{:block/title "template child 2"}]}
{:block/title "target"}]}]})
template (db-test/find-block-by-content @conn "template")
template-child-1 (db-test/find-block-by-content @conn "template child 1")
template-child-2 (db-test/find-block-by-content @conn "template child 2")
target (db-test/find-block-by-content @conn "target")
inserted-child-1-uuid (random-uuid)
inserted-child-2-uuid (random-uuid)
tx-data [{:e 900001 :a :block/uuid :v inserted-child-1-uuid :added true}
{:e 900002 :a :block/uuid :v inserted-child-2-uuid :added true}]
_ (d/transact! conn [[:db/add (:db/id template-child-1) :block/refs (:db/id template-child-2)]])
tx-meta {:outliner-op :apply-template
:outliner-ops [[:apply-template [(:db/id template)
(:db/id target)
{:sibling? true}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn tx-data tx-meta)]
(is (= :apply-template (ffirst forward-outliner-ops)))
(is (= true (get-in forward-outliner-ops [0 1 2 :keep-uuid?])))
(is (= [inserted-child-1-uuid inserted-child-2-uuid]
(mapv :block/uuid (get-in forward-outliner-ops [0 1 2 :template-blocks]))))
(is (= #{[:block/uuid inserted-child-2-uuid]}
(get-in forward-outliner-ops [0 1 2 :template-blocks 0 :block/refs]))))))
(deftest derive-history-outliner-ops-builds-delete-page-inverse-for-class-property-and-today-page-test
(testing "delete-page inverse restores hard-retracted class/property/today pages with stable db/ident"
(let [today (date-time-util/ms->journal-day (js/Date.))
conn (db-test/create-conn-with-blocks
{:classes {:Movie {}}
:properties {:rating {:logseq.property/type :number}}
:pages-and-blocks [{:page {:build/journal today}
:blocks [{:block/title "today child"}]}]})
class-page (ldb/get-page @conn "Movie")
property-page (d/entity @conn :user.property/rating)
today-page (db-test/find-journal-by-journal-day @conn today)
today-child (db-test/find-block-by-content @conn "today child")
class-inverse (:inverse-outliner-ops
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid class-page) {}]]]}))
property-inverse (:inverse-outliner-ops
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid property-page) {}]]]}))
today-inverse (:inverse-outliner-ops
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid today-page) {}]]]}))]
(is (some #(= :create-page (first %)) class-inverse))
(is (some #(= :save-block (first %)) class-inverse))
(is (= (:db/ident class-page)
(get-in (some #(when (= :save-block (first %)) %) class-inverse)
[1 0 :db/ident])))
(is (some #(= :upsert-property (first %)) property-inverse))
(is (some #(= :save-block (first %)) property-inverse))
(is (= (:db/ident property-page)
(get-in (some #(when (= :save-block (first %)) %) property-inverse)
[1 0 :db/ident])))
(is (not-any? #(= :restore-recycled (first %)) today-inverse))
(let [today-insert-op (some #(when (= :insert-blocks (first %)) %) today-inverse)]
(is (some? today-insert-op))
(is (= (:block/uuid today-page)
(get-in today-insert-op [1 1])))
(is (= (:block/uuid today-child)
(get-in today-insert-op [1 0 0 :block/uuid])))))))
(deftest derive-history-outliner-ops-builds-inverse-for-all-supported-ops-test
(let [conn (db-test/create-conn-with-blocks
{:classes {:c1 {:build/class-properties [:p1]}}
:properties {:p1 {:logseq.property/type :default}}
:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child-a"}
{:block/title "child-b"}]}
{:block/title "prop-block-1"
:build/properties {:p1 "before-1"}}
{:block/title "prop-block-2"}]}]})
page (db-test/find-page-by-title @conn "page")
parent (db-test/find-block-by-content @conn "parent")
child-a (db-test/find-block-by-content @conn "child-a")
child-b (db-test/find-block-by-content @conn "child-b")]
(testing ":save-block"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :save-block
:outliner-ops [[:save-block [{:block/uuid (:block/uuid child-a)
:block/title "changed"} {}]]]})]
(is (= :save-block (ffirst inverse-outliner-ops)))
(is (= (:block/uuid child-a)
(get-in inverse-outliner-ops [0 1 0 :block/uuid])))))
(testing ":insert-blocks"
(let [inserted-uuid (random-uuid)
tx-data [{:e 999999 :a :block/uuid :v inserted-uuid :added true}]
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn tx-data {:outliner-op :insert-blocks
:outliner-ops [[:insert-blocks [[{:block/uuid inserted-uuid
:block/title "new"}]
(:db/id parent)
{:sibling? false}]]]})]
(is (= :delete-blocks (ffirst inverse-outliner-ops)))
(is (= [inserted-uuid]
(vec (get-in inverse-outliner-ops [0 1 0]))))))
(testing ":move-blocks"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :move-blocks
:outliner-ops [[:move-blocks [[(:db/id child-b)]
(:db/id parent)
{:sibling? false}]]]})]
(is (= :move-blocks (ffirst inverse-outliner-ops)))
(is (= [(:block/uuid child-b)]
(get-in inverse-outliner-ops [0 1 0])))))
(testing ":delete-blocks"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-blocks
:outliner-ops [[:delete-blocks [[(:db/id child-b)] {}]]]})]
(is (= :insert-blocks (ffirst inverse-outliner-ops)))
(is (= (:block/uuid child-b)
(get-in inverse-outliner-ops [0 1 0 0 :block/uuid])))))
(testing ":create-page"
(let [page-uuid (random-uuid)
tx-data [{:e 1 :a :block/title :v "P2" :added true}
{:e 1 :a :block/uuid :v page-uuid :added true}]
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn tx-data {:outliner-op :create-page
:outliner-ops [[:create-page ["P2" {:redirect? false}]]]})]
(is (= [[:delete-page [page-uuid {}]]]
inverse-outliner-ops))))
(testing ":delete-page"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid page) {}]]]})]
(is (= [[:restore-recycled [(:block/uuid page)]]]
inverse-outliner-ops))))
(testing ":upsert-property"
(let [property-ident :user.property/test-inverse
expected-page-uuid (common-uuid/gen-uuid :db-ident-block-uuid property-ident)
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :upsert-property
:outliner-ops [[:upsert-property [property-ident
{:logseq.property/type :default}
{:property-name "test-inverse"}]]]})]
(is (= [[:delete-page [expected-page-uuid {}]]]
inverse-outliner-ops))))))
(deftest build-history-action-metadata-direct-outdent-builds-indent-outdent-forward-and-inverse-test
(testing "direct outdent keeps canonical indent-outdent forward and inverse ops"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child-1"}
{:block/title "child-2"}
{:block/title "child-3"}]}]}]})
child-3 (db-test/find-block-by-content @conn "child-3")
{:keys [tx-data db-after]} (run-direct-outdent conn child-3)
tx-meta {:outliner-op :move-blocks
:outliner-ops [[:indent-outdent-blocks [[(:db/id child-3)]
false
{:parent-original nil
:logical-outdenting? nil}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn db-after tx-data tx-meta)]
(is (= [[:indent-outdent-blocks [[(:block/uuid child-3)]
false
{:parent-original nil
:logical-outdenting? nil}]]]
forward-outliner-ops))
(is (= [[:indent-outdent-blocks [[(:block/uuid child-3)]
true
{:parent-original nil
:logical-outdenting? nil}]]]
inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-direct-outdent-with-extra-moved-blocks-keeps-semantic-ops-test
(testing "direct outdent keeps semantic indent-outdent op and inverse"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child-1"}
{:block/title "child-2"}
{:block/title "child-3"}]}]}]})
child-2 (db-test/find-block-by-content @conn "child-2")
{:keys [tx-data db-after]} (run-direct-outdent conn child-2)
tx-meta {:outliner-op :move-blocks
:outliner-ops [[:indent-outdent-blocks [[(:db/id child-2)]
false
{:parent-original nil
:logical-outdenting? nil}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn db-after tx-data tx-meta)]
(is (= [[:indent-outdent-blocks [[(:block/uuid child-2)]
false
{:parent-original nil
:logical-outdenting? nil}]]]
forward-outliner-ops))
(is (= [[:indent-outdent-blocks [[(:block/uuid child-2)]
true
{:parent-original nil
:logical-outdenting? nil}]]]
inverse-outliner-ops)))))

View File

@@ -0,0 +1,251 @@
(ns logseq.outliner.op-test
(:require [clojure.string :as string]
[cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[logseq.common.util.page-ref :as page-ref]
[logseq.db :as ldb]
[logseq.db.test.helper :as db-test]
[logseq.outliner.op :as outliner-op]
[logseq.outliner.property :as outliner-property]))
(deftest toggle-reaction-op
(testing "toggles reactions via outliner ops"
(let [user-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Block"}]}])
now 1234]
(ldb/transact! conn
[{:block/uuid user-uuid
:block/name "user"
:block/title "user"
:block/created-at now
:block/updated-at now
:block/tags #{:logseq.class/Page}}]
{})
(let [block (db-test/find-block-by-content @conn "Block")
target-uuid (:block/uuid block)]
(outliner-op/apply-ops! conn
[[:toggle-reaction [target-uuid "+1" user-uuid]]]
{})
(let [block-entity (d/entity @conn [:block/uuid target-uuid])
reactions (:logseq.property.reaction/_target block-entity)
reaction (first reactions)]
(is (= 1 (count reactions)))
(is (uuid? (:block/uuid reaction)))
(is (= "+1" (:logseq.property.reaction/emoji-id reaction)))
(is (= (:db/id (d/entity @conn [:block/uuid user-uuid]))
(:db/id (:logseq.property/created-by-ref reaction)))))
(outliner-op/apply-ops! conn
[[:toggle-reaction [target-uuid "+1" user-uuid]]]
{})
(let [block-entity (d/entity @conn [:block/uuid target-uuid])]
(is (empty? (:logseq.property.reaction/_target block-entity))))))))
(deftest collapse-expand-blocks-op
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Parent"}]}])
block (db-test/find-block-by-content @conn "Parent")
block-id (:block/uuid block)]
(outliner-op/apply-ops!
conn
[[:collapse-expand-blocks [[{:block/uuid block-id
:block/collapsed? true}]
{}]]]
{})
(is (true? (:block/collapsed? (d/entity @conn [:block/uuid block-id]))))
(outliner-op/apply-ops!
conn
[[:collapse-expand-blocks [[{:block/uuid block-id
:block/collapsed? false}]
{}]]]
{})
(is (false? (:block/collapsed? (d/entity @conn [:block/uuid block-id]))))))
(deftest resolve-indent-outdent-parent-original-test
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Original"}]}])
original (db-test/find-block-by-content @conn "Original")
opts (#'outliner-op/resolve-indent-outdent-opts
@conn
{:parent-original {:block/uuid (:block/uuid original)}})]
(is (= (:db/id original) (get-in opts [:parent-original :db/id])))))
(deftest apply-ops-plugin-property-sequence-test
(testing "plugin property ops remain visible after a single apply-ops! batch"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Block"}]}])
block (db-test/find-block-by-content @conn "Block")
block-uuid (:block/uuid block)]
(outliner-op/apply-ops!
conn
[[:upsert-property [:plugin.property._test_plugin/x1 {:logseq.property/type :checkbox
:db/cardinality :db.cardinality/one}
{:property-name :x1}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x1 true]]
[:upsert-property [:plugin.property._test_plugin/x2 {:logseq.property/type :url
:db/cardinality :db.cardinality/one}
{:property-name :x2}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x2 "https://logseq.com"]]
[:upsert-property [:plugin.property._test_plugin/x3 {:logseq.property/type :number
:db/cardinality :db.cardinality/one}
{:property-name :x3}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x3 1]]
[:upsert-property [:plugin.property._test_plugin/x4 {:logseq.property/type :number
:db/cardinality :db.cardinality/many}
{:property-name :x4}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x4 1]]
[:upsert-property [:plugin.property._test_plugin/x5 {:logseq.property/type :json
:db/cardinality :db.cardinality/one}
{:property-name :x5}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x5 "{\"foo\":\"bar\"}"]]
[:upsert-property [:plugin.property._test_plugin/x6 {:logseq.property/type :page
:db/cardinality :db.cardinality/one}
{:property-name :x6}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x6 "Page x"]]
[:upsert-property [:plugin.property._test_plugin/x7 {:logseq.property/type :page
:db/cardinality :db.cardinality/many}
{:property-name :x7}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x7 "Page y"]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x7 "Page z"]]
[:upsert-property [:plugin.property._test_plugin/x8 {:logseq.property/type :default
:db/cardinality :db.cardinality/one}
{:property-name :x8}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x8 "some content"]]]
{})
(let [block' (d/entity @conn [:block/uuid block-uuid])]
(is (true? (:plugin.property._test_plugin/x1 block')))
(is (= "https://logseq.com"
(:block/title (:plugin.property._test_plugin/x2 block'))))
(is (= 1
(:logseq.property/value (:plugin.property._test_plugin/x3 block'))))
(is (= #{1}
(set (map :logseq.property/value (:plugin.property._test_plugin/x4 block')))))
(is (= "{\"foo\":\"bar\"}" (:plugin.property._test_plugin/x5 block')))
(is (= "page x"
(:block/name (:plugin.property._test_plugin/x6 block'))))
(is (= #{"page y" "page z"}
(set (map :block/name (:plugin.property._test_plugin/x7 block')))))
(is (= "some content"
(:block/title (:plugin.property._test_plugin/x8 block'))))))))
(deftest remove-block-property-op-rejects-lookup-ref-block-id-test
(testing "remove-block-property rejects lookup-ref block ids"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Block"}]}])
block (db-test/find-block-by-content @conn "Block")
block-uuid (:block/uuid block)]
(outliner-property/set-block-property! conn
[:block/uuid block-uuid]
:logseq.property/order-list-type
"number")
(is (some? (:logseq.property/order-list-type
(d/entity @conn [:block/uuid block-uuid]))))
(is (thrown? js/Error
(outliner-op/apply-ops!
conn
[[:remove-block-property [[:block/uuid block-uuid]
:logseq.property/order-list-type]]]
{}))))))
(deftest direct-plugin-many-page-property-appends-values-test
(testing "direct property operations keep both page values"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Block"}]}])
block (db-test/find-block-by-content @conn "Block")
block-id (:db/id block)
property-id :plugin.property._test_plugin/x7]
(outliner-property/upsert-property! conn property-id
{:logseq.property/type :page
:db/cardinality :db.cardinality/many}
{:property-name :x7})
(outliner-property/set-block-property! conn block-id property-id "Page y")
(outliner-property/set-block-property! conn block-id property-id "Page z")
(is (= #{"page y" "page z"}
(set (map :block/name
(:plugin.property._test_plugin/x7 (d/entity @conn block-id)))))))))
(deftest apply-template-op-resolves-dynamic-variables-test
(testing "apply-template resolves dynamic variables in block title and property values"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "Target Page"}
:blocks [{:block/title "target block"}]}
{:page {:block/title "Templates"}
:blocks [{:block/title "template root"
:build/children [{:block/title "page is <% current page %>"}
{:block/title "time block"
:build/properties {:log-time "<%time%>"}}]}]}]
:properties {:log-time {:logseq.property/type :default}}})
target-page (ldb/get-page @conn "Target Page")
template-root (db-test/find-block-by-content @conn "template root")
target-block (db-test/find-block-by-content @conn "target block")
template-blocks (->> (ldb/get-block-and-children @conn (:block/uuid template-root)
{:include-property-block? true})
rest)
blocks-to-insert (cons (assoc (into {} (first template-blocks))
:db/id (:db/id (first template-blocks))
:logseq.property/used-template (:db/id template-root))
(map (fn [block]
(assoc (into {} block) :db/id (:db/id block)))
(rest template-blocks)))
_ (outliner-op/apply-ops! conn
[[:apply-template [(:block/uuid template-root)
(:block/uuid target-block)
{:template-blocks blocks-to-insert}]]]
{})
page-var-block (db-test/find-block-by-content
@conn
(str "page is " (page-ref/->page-ref (:block/uuid target-page))))
time-block (some->> (d/q '[:find [?b ...]
:in $ ?title ?page-title
:where
[?b :block/title ?title]
[?b :block/page ?page]
[?page :block/title ?page-title]]
@conn "time block" "Target Page")
first
(d/entity @conn))
time-value (some (fn [[property-id value]]
(when (= "log-time" (name property-id))
value))
(db-test/readable-properties time-block))]
(is (some? page-var-block))
(is (string? time-value))
(is (not (string/blank? time-value)))
(is (not (string/includes? time-value "<%"))))))
(deftest apply-ops-requires-uuid-block-ids-and-keyword-property-ids-test
(testing "ops reject integer eids and accept UUID/keyword identifiers"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Block"}]}])
block (db-test/find-block-by-content @conn "Block")
block-id (:db/id block)
block-uuid (:block/uuid block)
property-kw :plugin.property._test_plugin/normalized-prop]
(outliner-property/upsert-property! conn property-kw
{:logseq.property/type :checkbox
:db/cardinality :db.cardinality/one}
{:property-name :normalized-prop})
(let [property-id (:db/id (d/entity @conn property-kw))]
(outliner-op/apply-ops!
conn
[[:set-block-property [block-uuid property-kw true]]]
{})
(is (true? (property-kw (d/entity @conn [:block/uuid block-uuid]))))
(is (thrown? js/Error
(outliner-op/apply-ops!
conn
[[:set-block-property [block-id property-kw true]]]
{})))
(is (thrown? js/Error
(outliner-op/apply-ops!
conn
[[:set-block-property [block-uuid property-id true]]]
{})))))))

View File

@@ -0,0 +1,206 @@
(ns logseq.outliner.page-test
(:require [cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[logseq.common.config :as common-config]
[logseq.common.util :as common-util]
[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.order :as db-order]
[logseq.db.frontend.db :as db-db]
[logseq.db.test.helper :as db-test]
[logseq.outliner.page :as outliner-page]))
(deftest create-class
(let [conn (db-test/create-conn)
_ (outliner-page/create! conn "movie" {:class? true})
_ (outliner-page/create! conn "Movie" {:class? true})
movie-class (ldb/get-case-page @conn "movie")
Movie-class (ldb/get-case-page @conn "Movie")]
(is (ldb/class? movie-class) "Creates a class")
(is (ldb/class? Movie-class) "Creates another class with a different case sensitive name")
(is (not= movie-class Movie-class) "The two classes are not the same")))
(deftest create-namespace-pages
(let [conn (db-test/create-conn-with-blocks
{:properties {:user.property/property1 {:logseq.property/type :default}}
:classes {:class1 {}}
:pages-and-blocks [{:page {:block/title "page1"}}]})]
(testing "Basic valid workflows"
(let [[_ child-uuid] (outliner-page/create! conn "foo/bar/baz" {:split-namespace? true})
child-page (d/entity @conn [:block/uuid child-uuid])
;; Create a 2nd child page using existing parent pages
[_ child-uuid2] (outliner-page/create! conn "foo/bar/baz2" {:split-namespace? true})
child-page2 (d/entity @conn [:block/uuid child-uuid2])
;; Create a child page for a class
[_ child-uuid3] (outliner-page/create! conn "c1/c2" {:split-namespace? true :class? true})
child-page3 (d/entity @conn [:block/uuid child-uuid3])
library (ldb/get-built-in-page @conn common-config/library-page-name)
bar (ldb/get-page @conn "bar")]
(is (= ["foo"] (map :block/title (:block/_parent library)))
"Namespace (non-class) pages are added to the Library page")
(is (= ["baz" "baz2"] (map :block/title (:block/_parent bar)))
"Child pages are created under the same parent")
(is (= ["foo" "bar"] (map :block/title [(:block/parent (:block/parent child-page))
(:block/parent child-page)]))
"Child page with new parent has correct parents")
(is (= (map :block/uuid (db-db/get-page-parents child-page))
(map :block/uuid (db-db/get-page-parents child-page2)))
"Child page with existing parents has correct parents")
(is (= #{"Root Tag" "c1"} (set (map :block/title (ldb/get-classes-parents [child-page3]))))
"Child class with new parent has correct parents")
(outliner-page/create! conn "foo/class1/baz3" {:split-namespace? true})
(is (= #{"Tag" "Page"}
(set (d/q '[:find [?tag-title ...]
:where
[?b :block/title "class1"]
[?b :block/tags ?t]
[?t :block/title ?tag-title]] @conn)))
"Using an existing class page in a multi-parent namespace doesn't allow a page to have a class parent and instead creates a new page")))
(testing "Child pages with same name and different parents"
(let [_ (outliner-page/create! conn "vim/keys" {:split-namespace? true})
_ (outliner-page/create! conn "emacs/keys" {:split-namespace? true})]
(is (= #{"vim" "emacs"}
(->> (d/q '[:find [(pull ?b [{:block/parent [:block/title]}]) ...] :where [?b :block/title "keys"]] @conn)
(map #(get-in % [:block/parent :block/title]))
set))
"Two child pages with same name exist and have different parents")))
(testing "Invalid workflows"
(is (thrown-with-msg?
js/Error
#"Cannot create"
(outliner-page/create! conn "class1/page" {:split-namespace? true}))
"Page can't have a class parent")
(is (thrown-with-msg?
js/Error
#"Cannot create"
(outliner-page/create! conn "property1/page" {:split-namespace? true}))
"Page can't have a property parent")
(is (thrown-with-msg?
js/Error
#"Cannot create"
(outliner-page/create! conn "property1/class" {:split-namespace? true :class? true}))
"Class can't have a property parent"))))
(deftest create-page
(let [conn (db-test/create-conn)
[_ page-uuid] (outliner-page/create! conn "fooz" {})]
(is (= "fooz" (:block/title (d/entity @conn [:block/uuid page-uuid])))
"Page created correctly")
(is (thrown-with-msg?
js/Error
#"can't include \"/"
(outliner-page/create! conn "foo/bar" {}))
"Page can't have '/'n title")
(is (thrown-with-msg?
js/Error
#"can't include \"#\""
(outliner-page/create! conn "foo#bar" {}))
"Page can't have '#' in title")
(is (thrown-with-msg?
js/Error
#"can't include \"#\""
(outliner-page/create! conn "#tagstyle" {}))
"Page can't have leading '#' in title")))
(deftest delete-page
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "D1"}
:blocks [{:block/title "b1"}]}])
d1 (ldb/get-page @conn "D1")
b1 (db-test/find-block-by-content @conn "b1")]
(ldb/transact! conn [{:db/id (:db/id b1)
:block/title (str "b1 " (page-ref/->page-ref (:block/uuid d1)))
:block/refs #{(:db/id d1)}}])
(is (contains? (set (map :db/id (:block/refs (d/entity @conn (:db/id b1)))))
(:db/id d1)))
(outliner-page/delete! conn (:block/uuid d1))
(let [d1' (d/entity @conn (:db/id d1))
b1' (d/entity @conn (:db/id b1))
recycle-page (ldb/get-built-in-page @conn "Recycle")]
(is (some? d1'))
(is (some? b1'))
(is (= (:block/uuid recycle-page) (:block/uuid (:block/parent d1'))))
(is (integer? (:logseq.property/deleted-at d1')))
(is (nil? (:block/raw-title b1')))
(is (contains? (set (map :db/id (:block/refs b1')))
(:db/id d1)))
(is (= (:block/uuid d1') (:block/uuid (:block/page b1')))))))
(deftest delete-class-page-hard-retracts-page-tree
(let [conn (db-test/create-conn-with-blocks {:classes {:Movie {}}})
class-page (ldb/get-page @conn "Movie")
child-uuid (random-uuid)
_ (d/transact! conn [{:block/uuid child-uuid
:block/title "class child"
:block/page (:db/id class-page)
:block/parent (:db/id class-page)
:block/order (db-order/gen-key)}])]
(outliner-page/delete! conn (:block/uuid class-page))
(is (nil? (d/entity @conn [:block/uuid (:block/uuid class-page)])))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))))
(deftest delete-property-page-hard-retracts-page-tree
(let [conn (db-test/create-conn-with-blocks {:properties {:rating {:logseq.property/type :number}}})
property-page (d/entity @conn :user.property/rating)
child-uuid (random-uuid)
_ (d/transact! conn [{:block/uuid child-uuid
:block/title "property child"
:block/page (:db/id property-page)
:block/parent (:db/id property-page)
:block/order (db-order/gen-key)}])]
(outliner-page/delete! conn (:block/uuid property-page))
(is (nil? (d/entity @conn :user.property/rating)))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))))
(deftest create-journal
(let [conn (db-test/create-conn)
[_ page-uuid] (outliner-page/create! conn "Dec 16th, 2024" {})]
(is (= "Dec 16th, 2024" (:block/title (d/entity @conn [:block/uuid page-uuid])))
"Journal created correctly")
(is (= [:logseq.class/Journal]
(->> (d/entity @conn [:block/uuid page-uuid])
:block/tags
(map #(:db/ident (d/entity @conn (:db/id %))))))
"New journal only has Journal tag")))
(deftest create-journal-keeps-default-block-name-with-custom-title-format
(let [conn (db-test/create-conn)
_ (d/transact! conn [[:db/add :logseq.class/Journal :logseq.property.journal/title-format "yyyy-MM-dd EEEE"]])
[_ page-uuid] (outliner-page/create! conn "Dec 16th, 2024" {})
page (d/entity @conn [:block/uuid page-uuid])
default-name (-> (:block/journal-day page)
(date-time-util/int->journal-title date-time-util/default-journal-title-formatter)
common-util/page-name-sanity-lc)]
(is (= "2024-12-16 Monday" (:block/title page))
"Journal title follows configured formatter")
(is (= default-name (:block/name page))
"Journal block/name remains the default formatter, independent of title format")))
(deftest create-slash-formatted-journal-does-not-create-namespace-pages
(let [conn (db-test/create-conn)
_ (d/transact! conn [[:db/add :logseq.class/Journal :logseq.property.journal/title-format "yyyy/MM/dd"]])
[_ page-uuid] (outliner-page/create! conn "May 18th, 2026" {:split-namespace? true
:journal? true})
page (d/entity @conn [:block/uuid page-uuid])]
(is (= "2026/05/18" (:block/title page))
"Journal title follows slash title format")
(is (= (common-uuid/gen-uuid :journal-page-uuid 20260518) (:block/uuid page))
"Journal page has the standard journal uuid")
(is (nil? (ldb/get-page @conn "2026"))
"Journal title is not split into a year namespace page")
(is (nil? (ldb/get-page @conn "05"))
"Journal title is not split into a month namespace page")
(is (nil? (ldb/get-page @conn "18"))
"Journal title is not split into a day namespace page")))

View File

@@ -0,0 +1,47 @@
(ns logseq.outliner.pipeline-test
(:require [cljs.test :refer [deftest is]]
[datascript.core :as d]
[logseq.common.util.page-ref :as page-ref]
[logseq.db.test.helper :as db-test]
[logseq.outliner.pipeline :as outliner-pipeline]))
(deftest block-content-refs
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"} :blocks [{:block/title "b1"}]}])
block (db-test/find-block-by-content @conn "b1")]
(assert block)
(is (= [(:db/id block)]
(outliner-pipeline/block-content-refs @conn
{:block/title (str "ref to " (page-ref/->page-ref (:block/uuid block)))})))))
(deftest db-rebuild-block-refs-for-query-block
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "Todo query"
:build/tags [:logseq.class/Query]
:build/properties
{:logseq.property/query
{:build/property-value :block
:block/title "(task Todo)"}}}]}]})
block (db-test/find-block-by-content @conn "Todo query")
refs (set (outliner-pipeline/db-rebuild-block-refs @conn block))
query-property-id (:db/id (d/entity @conn :logseq.property/query))
query-class-id (:db/id (d/entity @conn :logseq.class/Query))]
(is (some? query-property-id)
"Sanity: :logseq.property/query entity exists")
(is (contains? refs query-class-id)
"#Query class tag is included in :block/refs")
(is (not (contains? refs query-property-id))
"#Query block does not reference logseq.property/query through :block/refs")))
(deftest db-rebuild-block-refs-removes-recursive-self-ref
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "self"}]}])
block (db-test/find-block-by-content @conn "self")
block' (assoc (d/pull @conn '[:db/id :block/uuid :block/title] (:db/id block))
:block/title
(str "self " (page-ref/->page-ref (:block/uuid block))))]
(is (empty? (outliner-pipeline/db-rebuild-block-refs @conn block'))
"A block should not rebuild a recursive ref to itself")))

View File

@@ -0,0 +1,694 @@
(ns logseq.outliner.property-test
(:require [cljs.test :refer [deftest is testing are]]
[datascript.core :as d]
[logseq.db :as ldb]
[logseq.db.frontend.property :as db-property]
[logseq.db.test.helper :as db-test]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.property :as outliner-property]))
(deftest upsert-property!
(testing "Creates a property"
(let [conn (db-test/create-conn-with-blocks [])
_ (outliner-property/upsert-property! conn nil {:logseq.property/type :number} {:property-name "num"})]
(is (= :number
(:logseq.property/type (d/entity @conn :user.property/num)))
"Creates property with property-name")))
(testing "Updates a property"
(let [conn (db-test/create-conn-with-blocks {:properties {:num {:logseq.property/type :number}}})
old-updated-at (:block/updated-at (d/entity @conn :user.property/num))]
(testing "and change its cardinality"
(outliner-property/upsert-property! conn :user.property/num {:db/cardinality :many} {})
(is (db-property/many? (d/entity @conn :user.property/num)))
(is (> (:block/updated-at (d/entity @conn :user.property/num))
old-updated-at)))
(testing "and change its type from a ref to a non-ref type"
(outliner-property/upsert-property! conn :user.property/num {:logseq.property/type :checkbox} {})
(is (= :checkbox (:logseq.property/type (d/entity @conn :user.property/num))))
(is (= nil (:db/valueType (d/entity @conn :user.property/num)))))))
(testing "Multiple properties that generate the same initial :db/ident"
(let [conn (db-test/create-conn-with-blocks [])]
(outliner-property/upsert-property! conn nil {:logseq.property/type :default} {:property-name "p1"})
(outliner-property/upsert-property! conn nil {} {:property-name "p1"})
(outliner-property/upsert-property! conn nil {} {:property-name "p1"})
(is (= {:block/name "p1" :block/title "p1" :logseq.property/type :default}
(select-keys (d/entity @conn :user.property/p1) [:block/name :block/title :logseq.property/type]))
"Existing db/ident does not get modified")
(is (= "p1"
(:block/title (d/entity @conn :user.property/p1-1)))
"2nd property gets unique ident")
(is (= "p1"
(:block/title (d/entity @conn :user.property/p1-2)))
"3rd property gets unique ident"))))
(deftest upsert-property-rejects-type-change-with-existing-data
(testing "Changing type is rejected when property has values"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:status "active"}}]}])]
(is (thrown-with-msg?
js/Error #"Disallowed type change with existing data"
(outliner-property/upsert-property! conn :user.property/status {:logseq.property/type :number} {})))))
(testing "Changing type is allowed when property has no values"
(let [conn (db-test/create-conn-with-blocks {:properties {:empty-prop {:logseq.property/type :default}}})]
(outliner-property/upsert-property! conn :user.property/empty-prop {:logseq.property/type :number} {})
(is (= :number (:logseq.property/type (d/entity @conn :user.property/empty-prop)))))))
(deftest convert-property-input-string
(testing "Convert property input string according to its schema type"
(let [test-uuid (random-uuid)]
(are [x y]
(= (let [[schema-type value] x]
(outliner-property/convert-property-input-string nil {:logseq.property/type schema-type} value)) y)
[:number "1"] 1
[:number "1.2"] 1.2
[:url test-uuid] test-uuid
[:date test-uuid] test-uuid
[:any test-uuid] test-uuid
[nil test-uuid] test-uuid))))
(deftest create-property-text-block!
(testing "Create a new :default property value"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:default "foo"}}
{:block/title "b2"}]}])
block (db-test/find-block-by-content @conn "b2")
;; Use same args as outliner.op
_ (outliner-property/create-property-text-block! conn (:db/id block) :user.property/default "" {})
new-property-value (:user.property/default (db-test/find-block-by-content @conn "b2"))]
(is (some? (:db/id new-property-value)) "New property value created")
(is (= "" (db-property/property-value-content new-property-value))
"Property value has correct content")
(is (= :user.property/default
(get-in (d/entity @conn (:db/id new-property-value)) [:logseq.property/created-from-property :db/ident]))
"Has correct created-from-property")))
(testing "Create cases for a new :one :number property value"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:num 2}}
{:block/title "b2"}]}])
block (db-test/find-block-by-content @conn "b2")
;; Use same args as outliner.op
_ (outliner-property/create-property-text-block! conn (:db/id block) :user.property/num "3" {})
new-property-value (:user.property/num (db-test/find-block-by-content @conn "b2"))]
(is (some? (:db/id new-property-value)) "New property value created")
(is (= 3 (db-property/property-value-content new-property-value))
"Property value has correct content")
(is (= :user.property/num
(get-in (d/entity @conn (:db/id new-property-value)) [:logseq.property/created-from-property :db/ident]))
"Has correct created-from-property")
(is (thrown-with-msg?
js/Error
#"Can't convert"
(outliner-property/create-property-text-block! conn (:db/id block) :user.property/num "Not a number" {}))
"Wrong value isn't transacted")))
(testing "Create new :many :number property values"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:num-many #{2}}}
{:block/title "b2"}]}])
block (db-test/find-block-by-content @conn "b2")
;; Use same args as outliner.op
_ (outliner-property/create-property-text-block! conn (:db/id block) :user.property/num-many "3" {})
_ (outliner-property/create-property-text-block! conn (:db/id block) :user.property/num-many "4" {})
_ (outliner-property/create-property-text-block! conn (:db/id block) :user.property/num-many "5" {})
new-property-values (:user.property/num-many (db-test/find-block-by-content @conn "b2"))]
(is (seq new-property-values) "New property values created")
(is (= #{3 4 5} (set (map db-property/property-value-content new-property-values)))
"Property value has correct content"))))
(deftest set-block-property-basic-cases
(testing "Set a :number value with existing value"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:num 2}}
{:block/title "b2"}]}])
property-value (:user.property/num (db-test/find-block-by-content @conn "b1"))
_ (assert (:db/id property-value))
block-uuid (:block/uuid (db-test/find-block-by-content @conn "b2"))
;; Use same args as outliner.op
_ (outliner-property/set-block-property! conn [:block/uuid block-uuid] :user.property/num (:db/id property-value))]
(is (= (:db/id property-value)
(:db/id (:user.property/num (db-test/find-block-by-content @conn "b2")))))
(let [empty-placeholder-id (:db/id (d/entity @conn :logseq.property/empty-placeholder))]
(outliner-property/set-block-property! conn [:block/uuid block-uuid] :user.property/num empty-placeholder-id)
(is (= empty-placeholder-id
(:logseq.property/value (:user.property/num (d/entity @conn [:block/uuid block-uuid]))))))))
(testing "Update a :number value with existing value"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:num 2}}
{:block/title "b2" :build/properties {:num 3}}]}])
property-value (:user.property/num (db-test/find-block-by-content @conn "b1"))
_ (assert (:db/id property-value))
block-uuid (:block/uuid (db-test/find-block-by-content @conn "b2"))
;; Use same args as outliner.op
_ (outliner-property/set-block-property! conn [:block/uuid block-uuid] :user.property/num (:db/id property-value))]
(is (= (:db/id property-value)
(:db/id (:user.property/num (db-test/find-block-by-content @conn "b2"))))))))
(deftest set-block-property-with-non-ref-values
(testing "Setting :default with same property value reuses existing entity"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:logseq.property/order-list-type "number"}}
{:block/title "b2"}]}])
property-value (:logseq.property/order-list-type (db-test/find-block-by-content @conn "b1"))
block-uuid (:block/uuid (db-test/find-block-by-content @conn "b2"))
;; Use same args as outliner.op
_ (outliner-property/set-block-property! conn [:block/uuid block-uuid] :logseq.property/order-list-type "number")]
(is (some? (:db/id (:logseq.property/order-list-type (db-test/find-block-by-content @conn "b2"))))
"New block has property set")
(is (= (:db/id property-value)
(:db/id (:logseq.property/order-list-type (db-test/find-block-by-content @conn "b2")))))))
(testing "Setting :checkbox with same property value reuses existing entity"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:checkbox true}}
{:block/title "b2"}]}])
property-value (:user.property/checkbox (db-test/find-block-by-content @conn "b1"))
block-uuid (:block/uuid (db-test/find-block-by-content @conn "b2"))
;; Use same args as outliner.op
_ (outliner-property/set-block-property! conn [:block/uuid block-uuid] :user.property/checkbox true)]
(is (true? (:user.property/checkbox (db-test/find-block-by-content @conn "b2")))
"New block has property set")
(is (= property-value (:user.property/checkbox (db-test/find-block-by-content @conn "b2")))))))
(deftest remove-block-property!
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:default "foo"}}]}])
block (db-test/find-block-by-content @conn "b1")
_ (assert (:user.property/default block))
;; Use same args as outliner.op
_ (outliner-property/remove-block-property! conn [:block/uuid (:block/uuid block)] :user.property/default)
updated-block (db-test/find-block-by-content @conn "b1")]
(is (some? updated-block))
(is (nil? (:user.property/default updated-block)) "Block property is deleted")))
(deftest batch-set-property!
(testing "Set built-in property values for multiple blocks"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "item 1"}
{:block/title "item 2"}]}])
block-ids (map #(-> (db-test/find-block-by-content @conn %) :block/uuid) ["item 1" "item 2"])
_ (outliner-property/batch-set-property! conn block-ids :logseq.property/order-list-type "number")
updated-blocks (map #(db-test/find-block-by-content @conn %) ["item 1" "item 2"])]
(is (= ["number" "number"]
(map #(db-property/property-value-content (:logseq.property/order-list-type %))
updated-blocks))
"Property values are batch set")))
(testing "Set custom default-many property values from string vector"
(let [conn (db-test/create-conn-with-blocks
{:properties {:user.property/reproducible-steps {:logseq.property/type :default
:db/cardinality :db.cardinality/many
:logseq.property/public? true}}
:pages-and-blocks [{:page {:block/title "page1"}
:blocks [{:block/title "target"}]}]})
block-id (:block/uuid (db-test/find-block-by-content @conn "target"))]
(outliner-property/batch-set-property! conn [block-id] :user.property/reproducible-steps ["Step 1" "Step 2" "Step 3"])
(is (= #{"Step 1" "Step 2" "Step 3"}
(->> (:user.property/reproducible-steps (d/entity @conn [:block/uuid block-id]))
(map db-property/property-value-content)
set))
"String vector values are persisted as many property values")))
(testing "Set custom default-many property values from id vector"
(let [conn (db-test/create-conn-with-blocks
{:properties {:user.property/reproducible-steps {:logseq.property/type :default
:db/cardinality :db.cardinality/many
:logseq.property/public? true}}
:pages-and-blocks [{:page {:block/title "page1"}
:blocks [{:block/title "source"
:build/properties {:user.property/reproducible-steps #{"Step 1" "Step 2" "Step 3"}}}
{:block/title "target"}]}]})
source-values (->> (:user.property/reproducible-steps (db-test/find-block-by-content @conn "source"))
(map :db/id)
vec)
target-id (:block/uuid (db-test/find-block-by-content @conn "target"))]
(outliner-property/batch-set-property! conn [target-id] :user.property/reproducible-steps source-values)
(is (= #{"Step 1" "Step 2" "Step 3"}
(->> (:user.property/reproducible-steps (d/entity @conn [:block/uuid target-id]))
(map db-property/property-value-content)
set))
"Id vector values are persisted as many property values")))
(testing "Invalid many values throw and don't partially persist"
(let [conn (db-test/create-conn-with-blocks
{:properties {:user.property/reproducible-steps {:logseq.property/type :default
:db/cardinality :db.cardinality/many
:logseq.property/public? true}}
:pages-and-blocks [{:page {:block/title "page1"}
:blocks [{:block/title "target"}]}]})
block-id (:block/uuid (db-test/find-block-by-content @conn "target"))]
(is (thrown-with-msg?
js/Error
#"Schema validation failed"
(outliner-property/batch-set-property! conn [block-id] :user.property/reproducible-steps [999999])))
(is (nil? (:user.property/reproducible-steps (d/entity @conn [:block/uuid block-id])))
"No partial values are persisted on failure"))))
(deftest status-property-setting-classes
(let [conn (db-test/create-conn-with-blocks
{:classes {:Cat {}
:Project {:build/class-properties [:logseq.property/status]}}
:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title ""}
{:block/title "cat task" :build/tags [:Cat]}
{:block/title "project task" :build/tags [:Project]}]}]})
page1 (:block/uuid (db-test/find-page-by-title @conn "page1"))
[empty-task cat-task project]
(map #(:block/uuid (db-test/find-block-by-content @conn %)) ["" "cat task" "project task"])]
(outliner-property/batch-set-property! conn [empty-task] :logseq.property/status :logseq.property/status.doing)
(is (= [:logseq.class/Task]
(mapv :db/ident (:block/tags (d/entity @conn [:block/uuid empty-task]))))
"Adds Task to block when it is not tagged")
(outliner-property/batch-set-property! conn [page1] :logseq.property/status :logseq.property/status.doing)
(is (= #{:logseq.class/Task :logseq.class/Page}
(set (map :db/ident (:block/tags (d/entity @conn [:block/uuid page1])))))
"Adds Task to page without tag")
(outliner-property/batch-set-property! conn [cat-task] :logseq.property/status :logseq.property/status.doing)
(is (= #{:logseq.class/Task :user.class/Cat}
(set (map :db/ident (:block/tags (d/entity @conn [:block/uuid cat-task])))))
"Adds Task to tagged block when its classes do not provide status")
(outliner-property/batch-set-property! conn [project] :logseq.property/status :logseq.property/status.doing)
(is (= [:user.class/Project]
(mapv :db/ident (:block/tags (d/entity @conn [:block/uuid project]))))
"Doesn't add Task to block when its class provides status")))
(deftest batch-set-property-rejects-private-built-in-entity
(let [conn (db-test/create-conn)
placeholder (d/entity @conn :logseq.property/empty-placeholder)
prop (d/entity @conn :logseq.property/description)]
(is (thrown-with-msg? js/Error #"Built-in.*can't be modified"
(db-test/silence-stderr
(outliner-property/batch-set-property! conn [(:db/id placeholder)] (:db/ident prop) "hacked"))))))
(deftest batch-remove-property!
(let [conn (db-test/create-conn-with-blocks
{:classes {:C1 {}}
:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "item 1" :build/properties {:logseq.property/order-list-type "number"}}
{:block/title "item 2" :build/properties {:logseq.property/order-list-type "number"}}]}]})
block-ids (map #(-> (db-test/find-block-by-content @conn %) :block/uuid) ["item 1" "item 2"])
_ (outliner-property/batch-remove-property! conn block-ids :logseq.property/order-list-type)
updated-blocks (map #(db-test/find-block-by-content @conn %) ["item 1" "item 2"])]
(is (= [nil nil]
(map :logseq.property/order-list-type updated-blocks))
"Property values are batch removed")
(is (thrown-with-msg?
js/Error
#"Can't remove private"
(outliner-property/batch-remove-property! conn [(:db/id (db-test/find-page-by-title @conn "page1"))] :block/tags)))
(is (thrown-with-msg?
js/Error
#"Can't remove required"
(outliner-property/batch-remove-property! conn [(:db/id (d/entity @conn :user.class/C1))] :logseq.property.class/extends)))))
(deftest batch-remove-property-rejects-private-built-in-entity
(let [conn (db-test/create-conn)
placeholder (d/entity @conn :logseq.property/empty-placeholder)
prop (d/entity @conn :logseq.property/description)]
(is (thrown-with-msg? js/Error #"Built-in.*can't be modified"
(db-test/silence-stderr
(outliner-property/batch-remove-property! conn [(:db/id placeholder)] (:db/ident prop)))))))
(deftest add-existing-values-to-closed-values!
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:num 1}}
{:block/title "b2" :build/properties {:num 2}}]}])
values (map (fn [d] (:block/uuid (d/entity @conn (:v d)))) (d/datoms @conn :avet :user.property/num))
_ (outliner-property/add-existing-values-to-closed-values! conn :user.property/num values)]
(is (= [1 2]
(map db-property/closed-value-content (:block/_closed-value-property (d/entity @conn :user.property/num)))))))
(deftest add-existing-generated-value-to-closed-values-reparents-to-property
(let [conn (db-test/create-conn-with-blocks
{:properties {:user.property/reproducible-steps {:logseq.property/type :default
:db/cardinality :db.cardinality/many
:logseq.property/public? true}}
:pages-and-blocks [{:page {:block/title "page1"}
:blocks [{:block/title "target"}]}]})
property (d/entity @conn :user.property/reproducible-steps)
block (db-test/find-block-by-content @conn "target")
block-uuid (:block/uuid block)]
(outliner-property/batch-set-property! conn [block-uuid] :user.property/reproducible-steps ["Step 1"])
(let [generated-value (first (:user.property/reproducible-steps (d/entity @conn [:block/uuid block-uuid])))
value-uuid (:block/uuid generated-value)]
(is (= (:db/id block) (:db/id (:block/parent generated-value))))
(outliner-property/add-existing-values-to-closed-values! conn :user.property/reproducible-steps [value-uuid])
(let [closed-value (d/entity @conn [:block/uuid value-uuid])]
(is (= #{(:db/id property)}
(set (map :db/id (:block/closed-value-property closed-value)))))
(is (= (:db/id property) (:db/id (:block/parent closed-value))))
(is (= (:db/id property) (:db/id (:block/page closed-value))))
(is (= "Step 1" (db-property/closed-value-content closed-value)))
(outliner-core/delete-blocks! conn [(d/entity @conn [:block/uuid block-uuid])] {})
(is (some? (d/entity @conn [:block/uuid value-uuid])))))))
(deftest upsert-closed-value!
(let [conn (db-test/create-conn-with-blocks
{:properties {:num {:build/closed-values [{:uuid (random-uuid) :value 2}]
:logseq.property/type :number}}})]
(testing "Add non-number choice shouldn't work"
(is
(thrown-with-msg?
js/Error
#"Can't convert"
(outliner-property/upsert-closed-value! conn :user.property/num {:value "not a number"}))))
(testing "Can't add existing choice"
(is
(thrown-with-msg?
js/Error
#"Closed value choice already exists"
(outliner-property/upsert-closed-value! conn :user.property/num {:value 2}))))
(testing "Add choice successfully"
(let [_ (outliner-property/upsert-closed-value! conn :user.property/num {:value 3})
b (first (d/q '[:find [(pull ?b [*]) ...] :where [?b :logseq.property/value 3]] @conn))]
(is (ldb/closed-value? (d/entity @conn (:db/id b))))
(is (= [2 3]
(map db-property/closed-value-content (:block/_closed-value-property (d/entity @conn :user.property/num)))))))
(testing "Update choice successfully"
(let [b (first (d/q '[:find [(pull ?b [*]) ...] :where [?b :logseq.property/value 2]] @conn))
_ (outliner-property/upsert-closed-value! conn :user.property/num {:id (:block/uuid b)
:value 4
:description "choice 4"})
updated-b (d/entity @conn [:block/uuid (:block/uuid b)])]
(is (= 4 (db-property/closed-value-content updated-b)))
(is (= "choice 4" (db-property/property-value-content (:logseq.property/description updated-b))))))))
(deftest delete-closed-value!
(let [closed-value-uuid (random-uuid)
used-closed-value-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
{:properties {:default {:build/closed-values [{:uuid closed-value-uuid :value "foo"}
{:uuid used-closed-value-uuid :value "bar"}]
:logseq.property/type :default}}
:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :user.property/default [:block/uuid used-closed-value-uuid]}]}]})
_ (assert (:user.property/default (db-test/find-block-by-content @conn "b1")))
property-id (:db/id (d/entity @conn :user.property/default))
closed-value-id (:db/id (d/entity @conn [:block/uuid closed-value-uuid]))
child-uuid (random-uuid)
_ (d/transact! conn [{:block/uuid child-uuid
:block/title "closed value child"
:block/page property-id
:block/parent closed-value-id
:block/order "a0"}])
_ (outliner-property/delete-closed-value! conn property-id [:block/uuid closed-value-uuid])]
(is (nil? (d/entity @conn [:block/uuid closed-value-uuid])))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))
(is (= [used-closed-value-uuid]
(mapv :block/uuid (:block/_closed-value-property (d/entity @conn :user.property/default)))))))
(deftest class-add-property!
(let [conn (db-test/create-conn-with-blocks
{:classes {:c1 {}}
:properties {:p1 {:logseq.property/type :default}
:p2 {:logseq.property/type :default}}})
_ (outliner-property/class-add-property! conn :user.class/c1 :user.property/p1)
_ (outliner-property/class-add-property! conn :user.class/c1 :user.property/p2)]
(is (= [:user.property/p1 :user.property/p2]
(map :db/ident (:logseq.property.class/properties (d/entity @conn :user.class/c1)))))))
(deftest class-remove-property!
(let [conn (db-test/create-conn-with-blocks
{:classes {:c1 {:build/class-properties [:p1 :p2]}}})
_ (outliner-property/class-remove-property! conn :user.class/c1 :user.property/p1)]
(is (= [:user.property/p2]
(map :db/ident (:logseq.property.class/properties (d/entity @conn :user.class/c1)))))))
(deftest get-block-classes-properties
(let [conn (db-test/create-conn-with-blocks
{:classes {:c1 {:build/class-properties [:p1]}
:c2 {:build/class-properties [:p2 :p3]}}
:pages-and-blocks
[{:page {:block/title "p1"}
:blocks [{:block/title "o1"
:build/tags [:c1 :c2]}]}]})
block (db-test/find-block-by-content @conn "o1")]
(is (= [:user.property/p1 :user.property/p2 :user.property/p3]
(map :db/ident (:classes-properties (outliner-property/get-block-classes-properties @conn (:db/id block))))))))
(deftest property-with-other-position-default-bottom-rules
(testing "explicit non-properties ui-position remains other-position"
(is (true?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :number
:logseq.property/ui-position :block-left}))))
(testing "number property without explicit ui-position defaults to bottom position"
(is (true?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :number}))))
(testing "default property without closed values stays in normal property rows"
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :default
:property/closed-values []}))))
(testing "default property with closed values is positioned in bottom row"
(is (true?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :default
:property/closed-values [{:db/id 1}]}))))
(testing "url property without explicit ui-position stays in normal property rows"
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :url}))))
(testing "explicit left/right ui-position remains in positioned rows"
(is (true?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :url
:logseq.property/ui-position :block-left}))))
(testing "explicit properties ui-position keeps property in normal property rows"
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :number
:logseq.property/ui-position :properties}))))
(testing "many node property without explicit ui-position defaults to bottom position"
(is (true?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :node
:db/cardinality :db.cardinality/many}))))
(testing "bidirectional config property stays in normal property rows"
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :logseq.property.class/enable-bidirectional?
:logseq.property/type :checkbox}))))
(testing "tag properties and schema-related properties stay in normal property rows"
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :block/tags
:logseq.property/type :class})))
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :logseq.property.class/properties
:logseq.property/type :property})))
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :logseq.property/public?
:logseq.property/type :checkbox}))))
(testing "a schema property is part of the shared schema set"
(is (contains? db-property/schema-properties :logseq.property/public?))))
(deftest get-block-positioned-properties-filters-non-public
(let [conn (db-test/create-conn-with-blocks [])
journal-class (d/entity @conn :logseq.class/Journal)
positioned-properties (outliner-property/get-block-positioned-properties @conn (:db/id journal-class) :block-below)]
(is (every? #(not (false? (:logseq.property/public? %))) positioned-properties))
(is (not-any? #(= :logseq.property.journal/title-format (:db/ident %))
positioned-properties))))
(deftest get-block-positioned-properties-keeps-empty-inherited-tag-properties
(let [conn (db-test/create-conn-with-blocks
{:properties {:authors {:logseq.property/type :node
:db/cardinality :db.cardinality/many}}
:classes {:Paper {:build/class-properties [:authors]}}
:pages-and-blocks [{:page {:block/title "page1"}
:blocks [{:block/title "paper1"
:build/tags [:Paper]}]}]})
paper1 (db-test/find-block-by-content @conn "paper1")
positioned-properties (outliner-property/get-block-positioned-properties @conn (:db/id paper1) :block-below)
positioned-idents (set (map :db/ident positioned-properties))]
(is (contains? positioned-idents :user.property/authors))))
(deftest extends-cycle
(testing "Fail when creating a cycle of extends"
(let [conn (db-test/create-conn-with-blocks
{:classes {:Class1 {}
:Class2 {}
:Class3 {}}})
db @conn
class1 (d/entity db :user.class/Class1)
class2 (d/entity db :user.class/Class2)
class3 (d/entity db :user.class/Class3)]
(outliner-property/set-block-property! conn (:db/id class1) :logseq.property.class/extends (:db/id class2))
(outliner-property/set-block-property! conn (:db/id class2) :logseq.property.class/extends (:db/id class3))
(is (thrown-with-msg?
js/Error
#"Extends cycle"
(outliner-property/set-block-property! conn (:db/id class3) :logseq.property.class/extends (:db/id class1)))
"Extends cycle"))))
(deftest extends-redundant-direct-parent-cleanup
(testing "Clean redundant direct parents from descendants when a class gets a new parent"
(let [conn (db-test/create-conn-with-blocks
{:classes {:A {}
:B {}
:C {:build/class-extends [:B]}
:D {:build/class-extends [:A :C]}}})
b (d/entity @conn :user.class/B)]
(outliner-property/set-block-property! conn
(:db/id b)
:logseq.property.class/extends
(:db/id (d/entity @conn :user.class/A)))
(is (= [:user.class/C]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/D))))
"D keeps only the nearest direct parent"))))
(deftest extends-redundant-cleanup-with-lookup-ref-parent
(testing "Treat lookup refs as a single parent reference"
(let [conn (db-test/create-conn-with-blocks
{:classes {:A {}
:B {}
:C {:build/class-extends [:B]}
:D {:build/class-extends [:A :C]}}})]
(outliner-property/set-block-property! conn
(:db/id (d/entity @conn :user.class/B))
:logseq.property.class/extends
[:db/ident :user.class/A])
(is (= [:user.class/A]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/B))))
"B uses the lookup ref as one parent")
(is (= [:user.class/C]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/D))))
"D removes the direct parent inherited through C"))))
(deftest extends-redundant-cleanup-with-keyword-vector-parents
(testing "Treat a vector of class idents as multiple parents"
(let [conn (db-test/create-conn-with-blocks
{:classes {:A {}
:B {}
:C {:build/class-extends [:A]}
:D {:build/class-extends [:A :B]}}})]
(outliner-property/batch-set-property! conn
[(:db/id (d/entity @conn :user.class/B))]
:logseq.property.class/extends
[:user.class/A :user.class/C])
(is (= [:user.class/C]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/B))))
"B keeps only the nearest parent")
(is (= [:user.class/B]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/D))))
"D removes the direct parent inherited through B"))))
(deftest extends-redundant-direct-parent-cleanup-for-root-reset
(testing "Clean descendant Root parents when an ancestor is reset to Root"
(let [conn (db-test/create-conn-with-blocks
{:classes {:B {}
:C {:build/class-extends [:B]}
:D {:build/class-extends [:logseq.class/Root :C]}}})]
(outliner-property/set-block-property! conn
(:db/id (d/entity @conn :user.class/B))
:logseq.property.class/extends
:logseq.class/Root)
(is (= [:user.class/C]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/D))))
"D removes the direct Root parent inherited through C"))))
(deftest delete-property-value!
(let [conn (db-test/create-conn-with-blocks
{:classes {:C1 {}
:C2 {}
:C3 {:build/class-extends [:C1 :C2]}}})]
(outliner-property/delete-property-value! conn :user.class/C3 :logseq.property.class/extends
(:db/id (d/entity @conn :user.class/C2)))
(is (= [:user.class/C1]
(:logseq.property.class/extends (db-test/readable-properties (d/entity @conn :user.class/C3))))
"Specific property value is deleted")
(outliner-property/delete-property-value! conn :user.class/C3 :logseq.property.class/extends
(:db/id (d/entity @conn :user.class/C1)))
(is (= [:logseq.class/Root]
(:logseq.property.class/extends (db-test/readable-properties (d/entity @conn :user.class/C3))))
"Extends property is restored back to Root")))

View File

@@ -0,0 +1,214 @@
(ns logseq.outliner.recycle-test
(:require [cljs.test :refer [deftest is]]
[datascript.core :as d]
[logseq.common.util :as common-util]
[logseq.db :as ldb]
[logseq.db.test.helper :as db-test]
[logseq.outliner.op :as outliner-op]
[logseq.outliner.recycle :as recycle]))
(deftest restore-recycled-page-removes-recycle-parent
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}])
page (ldb/get-page @conn "page1")]
(recycle/recycle-page-tx-data @conn page {})
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page})
(recycle/restore! conn (:block/uuid page))
(let [page' (ldb/get-page @conn "page1")]
(is (nil? (:block/parent page')))
(is (nil? (:logseq.property/deleted-at page')))
(is (nil? (:logseq.property.recycle/original-parent page'))))))
(deftest apply-ops-restore-recycled-page-removes-recycle-parent
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}])
page (ldb/get-page @conn "page1")
page-uuid (:block/uuid page)]
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid page-uuid]))))
(outliner-op/apply-ops! conn [[:restore-recycled [page-uuid]]] {})
(let [page' (ldb/get-page @conn "page1")]
(is (nil? (:block/parent page')))
(is (nil? (:logseq.property/deleted-at page')))
(is (nil? (:logseq.property.recycle/original-parent page'))))))
(deftest permanently-delete-recycled-page-removes-page-and-descendants
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}])
page (ldb/get-page @conn "page1")
block (db-test/find-block-by-content @conn "b1")
page-uuid (:block/uuid page)
block-uuid (:block/uuid block)]
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid page-uuid]))))
(is (true? (recycle/permanently-delete! conn page-uuid)))
(is (nil? (d/entity @conn [:block/uuid page-uuid])))
(is (nil? (d/entity @conn [:block/uuid block-uuid])))))
(deftest permanently-delete-recycled-page-removes-blocks-parented-by-page
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}}
{:page {:block/title "page2"}}])
page1 (ldb/get-page @conn "page1")
page2 (ldb/get-page @conn "page2")
block-uuid (random-uuid)
now (common-util/time-ms)]
(d/transact! conn [{:block/uuid block-uuid
:block/title "parented by page1"
:block/created-at now
:block/updated-at now
:block/parent (:db/id page1)
:block/page (:db/id page2)
:block/order "a0"}])
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page1 {}) {:outliner-op :delete-page})
(is (true? (ldb/recycled? (d/entity @conn (:db/id page1)))))
(is (true? (recycle/permanently-delete! conn (:block/uuid page1))))
(is (nil? (d/entity @conn [:block/uuid block-uuid])))))
(deftest permanently-delete-recycled-converted-page-removes-property-value-blocks
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "outer"}
:blocks [{:block/title "target"
:build/properties {:default "value"}}]}])
target (db-test/find-block-by-content @conn "target")
target-id (:db/id target)
target-uuid (:block/uuid target)
value-id (d/q '[:find ?value .
:in $ ?target
:where
[?value :block/parent ?target]
[?value :logseq.property/created-from-property]]
@conn target-id)]
(d/transact! conn [[:db/retract target-id :block/page]
[:db/add target-id :block/name "target"]
[:db/add target-id :block/tags :logseq.class/Page]])
(let [page (d/entity @conn target-id)]
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page}))
(is (true? (recycle/permanently-delete! conn target-uuid)))
(is (nil? (d/entity @conn target-id)))
(is (nil? (d/entity @conn value-id)))))
(deftest gc-recycled-converted-page-removes-property-value-blocks
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "outer"}
:blocks [{:block/title "target"
:build/properties {:default "value"}}
{:block/title "unrelated"}]}])
outer (ldb/get-page @conn "outer")
target (db-test/find-block-by-content @conn "target")
unrelated (db-test/find-block-by-content @conn "unrelated")
target-id (:db/id target)
value-id (d/q '[:find ?value .
:in $ ?target
:where
[?value :block/parent ?target]
[?value :logseq.property/created-from-property]]
@conn target-id)]
(d/transact! conn [[:db/retract target-id :block/page]
[:db/add target-id :block/name "target"]
[:db/add target-id :block/tags :logseq.class/Page]])
(let [page (d/entity @conn target-id)]
(ldb/transact! conn
(recycle/recycle-page-tx-data @conn page {:now-ms 0})
{:outliner-op :delete-page}))
(is (true? (recycle/gc! conn {:now-ms (* 31 24 3600 1000)})))
(is (nil? (d/entity @conn target-id)))
(is (nil? (d/entity @conn value-id)))
(is (some? (d/entity @conn (:db/id outer))))
(is (some? (d/entity @conn (:db/id unrelated))))))
(deftest gc-keeps-unexpired-recycled-page
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}}])
page (ldb/get-page @conn "page1")
page-id (:db/id page)]
(ldb/transact! conn
(recycle/recycle-page-tx-data @conn page {:now-ms 0})
{:outliner-op :delete-page})
(is (nil? (recycle/gc! conn {:now-ms (* 29 24 3600 1000)})))
(is (some? (d/entity @conn page-id)))))
(deftest apply-ops-permanently-delete-recycled-page-removes-page-and-descendants
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}])
page (ldb/get-page @conn "page1")
block (db-test/find-block-by-content @conn "b1")
page-uuid (:block/uuid page)
block-uuid (:block/uuid block)]
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid page-uuid]))))
(outliner-op/apply-ops! conn [[:recycle-delete-permanently [page-uuid]]] {})
(is (nil? (d/entity @conn [:block/uuid page-uuid])))
(is (nil? (d/entity @conn [:block/uuid block-uuid])))))
(deftest permanently-delete-recycled-block-removes-subtree-only
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}])
page (ldb/get-page @conn "page1")
parent (db-test/find-block-by-content @conn "parent")
child (db-test/find-block-by-content @conn "child")
parent-uuid (:block/uuid parent)
child-uuid (:block/uuid child)]
(ldb/transact! conn (recycle/recycle-blocks-tx-data @conn [parent] {}) {:outliner-op :delete-blocks})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid parent-uuid]))))
(is (true? (recycle/permanently-delete! conn parent-uuid)))
(is (some? (d/entity @conn [:block/uuid (:block/uuid page)])))
(is (nil? (d/entity @conn [:block/uuid parent-uuid])))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))))
(deftest apply-ops-permanently-delete-recycled-block-removes-subtree-only
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}])
parent (db-test/find-block-by-content @conn "parent")
child (db-test/find-block-by-content @conn "child")
parent-uuid (:block/uuid parent)
child-uuid (:block/uuid child)]
(ldb/transact! conn (recycle/recycle-blocks-tx-data @conn [parent] {}) {:outliner-op :delete-blocks})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid parent-uuid]))))
(outliner-op/apply-ops! conn [[:recycle-delete-permanently [parent-uuid]]] {})
(is (nil? (d/entity @conn [:block/uuid parent-uuid])))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))))
(deftest permanently-delete-recycled-block-removes-corresponding-view-history
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target"}]}])
target (db-test/find-block-by-content @conn "target")
target-uuid (:block/uuid target)
view-uuid (random-uuid)
target-history-uuid (random-uuid)
view-history-uuid (random-uuid)
now (common-util/time-ms)
_ (d/transact! conn [{:block/uuid view-uuid
:block/title "target view"
:block/created-at now
:block/updated-at now
:logseq.property/view-for (:db/id target)
:logseq.property.view/type :logseq.property.view/type.table
:logseq.property.view/feature-type :linked-references}
{:block/uuid target-history-uuid
:block/created-at now
:block/updated-at now
:logseq.property.history/block (:db/id target)
:logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status))
:logseq.property.history/scalar-value "Todo"}
{:block/uuid view-history-uuid
:block/created-at now
:block/updated-at now
:logseq.property.history/block [:block/uuid view-uuid]
:logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status))
:logseq.property.history/scalar-value "List"}])]
(ldb/transact! conn (recycle/recycle-blocks-tx-data @conn [target] {}) {:outliner-op :delete-blocks})
(is (true? (recycle/permanently-delete! conn target-uuid)))
(is (nil? (d/entity @conn [:block/uuid target-uuid])))
(is (nil? (d/entity @conn [:block/uuid view-uuid])))
(is (nil? (d/entity @conn [:block/uuid target-history-uuid])))
(is (nil? (d/entity @conn [:block/uuid view-history-uuid])))))

View File

@@ -0,0 +1,18 @@
(ns logseq.outliner.tree-test
(:require [cljs.test :refer [deftest is]]
[logseq.outliner.tree :as otree]))
(deftest blocks->vec-tree-data-preserves-caller-field-policy
(let [root {:db/id 1}
child {:db/id 2
:block/uuid (random-uuid)
:block/order "a0"
:block/parent {:db/id 1}
:block/tx-id 9}
default-result (otree/blocks->vec-tree-data [child] root {:include-root? false})
renderer-result (otree/blocks->vec-tree-data [child] root {:include-root? false
:keep-block-tx-id? true})]
(is (vector? default-result))
(is (= [1] (mapv :block/level default-result)))
(is (nil? (:block/tx-id (first default-result))))
(is (= 9 (:block/tx-id (first renderer-result))))))

View File

@@ -0,0 +1,308 @@
(ns logseq.outliner.validate-test
(:require [cljs.test :refer [are deftest is testing]]
[datascript.core :as d]
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.frontend.entity-util :as entity-util]
[logseq.db.test.helper :as db-test]
[logseq.outliner.validate :as outliner-validate]))
(deftest validate-block-title-unique-for-properties
(let [conn (db-test/create-conn-with-blocks
{:properties {:color {:logseq.property/type :default}
:color2 {:logseq.property/type :default}}})]
(is (nil?
(outliner-validate/validate-unique-by-name-and-tags
@conn
(:block/title (d/entity @conn :logseq.property/background-color))
(d/entity @conn :user.property/color)))
"Allow user property to have same name as built-in property")
(is (thrown-with-msg?
js/Error
#"Duplicate property"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"color"
(d/entity @conn :user.property/color2)))
"Disallow duplicate user property")))
(deftest validate-block-title-unique-for-tags
(let [conn (db-test/create-conn-with-blocks
{:classes {:Class1 {}
:Class2 {:logseq.property.class/extends :logseq.class/Task}}})]
(is (thrown-with-msg?
js/Error
#"Duplicate class"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"Class1"
(d/entity @conn :user.class/Class2)))
"Disallow duplicate class names, regardless of extends")
(is (thrown-with-msg?
js/Error
#"Duplicate class"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"Card"
(d/entity @conn :user.class/Class1)))
"Disallow duplicate class names even if it's built-in")))
(deftest validate-block-title-unique-for-namespaced-pages
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "Library"
:block/uuid #uuid "d246c71a-3e71-42f0-928f-afe607ee5ce0"
:build/keep-uuid? true
:build/properties {:logseq.property/built-in? true}}}
{:page {:block/title "n1"
:block/uuid #uuid "3aa1e950-5a9b-4efc-81d4-b6d89a504591"
:build/keep-uuid? true
:block/parent [:block/uuid #uuid "d246c71a-3e71-42f0-928f-afe607ee5ce0"]}}
{:page {:block/title "n2"
:block/parent [:block/uuid #uuid "3aa1e950-5a9b-4efc-81d4-b6d89a504591"]}}
{:page {:block/title "n3"
:block/parent [:block/uuid #uuid "3aa1e950-5a9b-4efc-81d4-b6d89a504591"]}}]
:build-existing-tx? true})]
(is (thrown-with-msg?
js/Error
#"Duplicate page"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"n2"
(db-test/find-page-by-title @conn "n3")))
"Disallow duplicate namespace child")
(is (nil?
(outliner-validate/validate-unique-by-name-and-tags
@conn
"n4"
(db-test/find-page-by-title @conn "n3")))
"Allow namespace child if unique")))
(deftest validate-block-title-unique-for-pages
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}}
{:page {:block/title "another page"}}
{:page {:block/title "Apple" :build/tags [:Company]}}
{:page {:block/title "Another Company" :build/tags [:Company]}}
{:page {:block/title "Banana" :build/tags [:Fruit]}}])]
(is (thrown-with-msg?
js/Error
#"Duplicate page"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"Apple"
(db-test/find-page-by-title @conn "Another Company")))
"Disallow duplicate page with tag")
(is (nil?
(outliner-validate/validate-unique-by-name-and-tags
@conn
"Apple"
(db-test/find-page-by-title @conn "Banana")))
"Allow page with same name for different tag")
(is (thrown-with-msg?
js/Error
#"Duplicate page"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"page1"
(db-test/find-page-by-title @conn "another page")))
"Disallow duplicate page without tag")
(is (nil?
(outliner-validate/validate-unique-by-name-and-tags
@conn
"Apple"
(db-test/find-page-by-title @conn "Fruit")))
"Allow class to have same name as a page")))
(deftest validate-extends-property
(let [conn (db-test/create-conn-with-blocks
{:properties {:prop1 {:logseq.property/type :default}}
:classes {:Class1 {} :Class2 {}}
:pages-and-blocks
[{:page {:block/title "page1"}}
{:page {:block/title "page2"}}]})
page1 (db-test/find-page-by-title @conn "page1")
class1 (db-test/find-page-by-title @conn "Class1")
class2 (db-test/find-page-by-title @conn "Class2")
property (db-test/find-page-by-title @conn "prop1")
db @conn]
(testing "valid parent and child combinations"
(is (nil? (outliner-validate/validate-extends-property db class1 [class2]))
"parent class to child class is valid"))
(testing "invalid parent and child combinations"
(are [parent child]
(thrown-with-msg?
js/Error
#"Can't extend"
(outliner-validate/validate-extends-property db parent [child]))
class1 page1
page1 class1
property class1))
(testing "built-in tag can't have parent changed"
(is (thrown-with-msg?
js/Error
#"Can't change.*built-in"
(outliner-validate/validate-extends-property db
(entity-plus/entity-memoized @conn :logseq.class/Task)
[(entity-plus/entity-memoized @conn :logseq.class/Cards)]))))))
(deftest validate-tags-property
(let [class-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
{:classes {:SomeTag {:block/uuid class-uuid :build/keep-uuid? true}}
:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "block"
:build/children [{:block/title "block - invalid location"}]}
{:block/title "block / invalid title"}]}
{:page {:block/uuid class-uuid}
:blocks [{:block/title "class block"}]}]
:build-existing-tx? true})
block (db-test/find-block-by-content @conn "block")
block-invalid-title (db-test/find-block-by-content @conn #"invalid title")
block-invalid-location (db-test/find-block-by-content @conn #"invalid location")]
(is (thrown-with-msg?
js/Error
#"Can't add tag.*Tag"
(outliner-validate/validate-tags-property @conn [:logseq.class/Tag] :user.class/SomeTag))
"built-in tag must not be tagged by the user")
(is (thrown-with-msg?
js/Error
#"Can't add tag.*Heading"
(outliner-validate/validate-tags-property @conn [:logseq.property/heading] :user.class/SomeTag))
"built-in property must not be tagged by the user")
(is (thrown-with-msg?
js/Error
#"Can't add tag.*Contents"
(outliner-validate/validate-tags-property @conn [(:db/id (db-test/find-page-by-title @conn "Contents"))] :user.class/SomeTag))
"built-in page must not be tagged by the user")
(is (thrown-with-msg?
js/Error
#"Can't set tag.*Tag"
(outliner-validate/validate-tags-property @conn [(:db/id block)] :logseq.class/Tag))
"Nodes can't be tagged with built-in private tags")
(is (thrown-with-msg?
js/Error
#"Can't set tag.*Priority"
(outliner-validate/validate-tags-property @conn [(:db/id block)] :logseq.property/priority))
"Nodes can't be tagged with built-in non tags")
(is (nil? (outliner-validate/validate-tags-property @conn [(:db/id block)] :logseq.class/Page))
"Blocks can be tagged with #Page")
(is (thrown-with-msg?
js/Error
#"Page name can't.*/"
(outliner-validate/validate-tags-property @conn [(:db/id block-invalid-title)] :logseq.class/Page))
"Block with invalid title can't be tagged with #Page")
(is (thrown-with-msg?
js/Error
#"Can't convert this block to page"
(outliner-validate/validate-tags-property @conn [(:db/id block-invalid-location)] :logseq.class/Page))
"Block with invalid location can't be tagged with #Page")))
(deftest validate-tags-property-deletion
(let [conn (db-test/create-conn-with-blocks
{:classes {:SomeTag {}}
:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "block" :build/tags [:logseq.class/Page]}]}]})
page (db-test/find-page-by-title @conn "page1")
page-with-parent (db-test/find-block-by-content @conn "block")]
(is (thrown-with-msg?
js/Error
#"Can't remove tag.*Task"
(outliner-validate/validate-tags-property-deletion @conn [(:db/id (d/entity @conn :logseq.class/Task))] :logseq.class/Tag))
"built-in class must not have tag deleted by the user")
(is (thrown-with-msg?
js/Error
#"Can't remove tag.*Tag"
(outliner-validate/validate-tags-property-deletion @conn [(:db/id (d/entity @conn :user.class/SomeTag))] :logseq.class/Tag))
"Node can't have private tag deleted by user")
(is (nil? (outliner-validate/validate-tags-property-deletion @conn [(:db/id page-with-parent)] :logseq.class/Page))
"Page with parent can remove #Page")
(is (thrown-with-msg?
js/Error
#"This page cannot be converted"
(outliner-validate/validate-tags-property-deletion @conn [(:db/id page)] :logseq.class/Page))
"Page without parent can't remove #Page")))
(deftest validate-editing-built-in-property
(let [conn (db-test/create-conn-with-blocks
{:properties {:myprop {:logseq.property/type :default}}})
user-prop (d/entity @conn :user.property/myprop)
built-in-prop (d/entity @conn :block/tags)]
(testing "user property can edit any attribute"
(is (nil? (outliner-validate/validate-editing-built-in-property
user-prop {:db/cardinality :db.cardinality/many}))))
(testing "built-in property cannot edit disallowed attribute"
(is (thrown-with-msg?
js/Error
#"not editable"
(outliner-validate/validate-editing-built-in-property
built-in-prop {:block/title "renamed"}))))
(testing "built-in property can edit allowed attribute"
(is (nil? (outliner-validate/validate-editing-built-in-property
built-in-prop {:logseq.property/hide-empty-value true}))))))
;; Try as many of the validations against a new graph to confirm
;; that validations make sense and are valid for a new graph
(deftest new-graph-should-be-valid
(let [conn (db-test/create-conn)]
(testing "Validate pages"
(let [pages (->> (d/q '[:find [?b ...] :where
[?b :block/title]
[?b :block/tags]] @conn)
(map (fn [id]
(d/entity @conn id))))
page-errors (atom {})]
(doseq [page pages]
(try
(outliner-validate/validate-unique-by-name-and-tags @conn (:block/title page) page)
(outliner-validate/validate-page-title (:block/title page) {:node page})
(outliner-validate/validate-page-title-characters (:block/title page) {:node page})
(when (entity-util/property? page) (outliner-validate/validate-property-title (:block/title page)))
(when (entity-util/class? page)
(doseq [parent (:logseq.property.class/extends page)]
(outliner-validate/validate-extends-property @conn parent [page] {:built-in? false})))
(catch :default e
(if (= :notification (:type (ex-data e)))
(swap! page-errors update (select-keys page [:block/title :db/ident :block/uuid]) (fnil conj []) e)
(throw e)))))
(is (= {} @page-errors)
"Default pages shouldn't have any validation errors")))
(testing "Validate property relationships"
(let [parent-child-pairs (d/q '[:find ?parent ?child
:where [?child :logseq.property.class/extends ?parent]] @conn)]
(doseq [[parent-id child-id] parent-child-pairs]
(let [parent (d/entity @conn parent-id)
child (d/entity @conn child-id)]
(is (nil? (#'outliner-validate/validate-extends-property-have-correct-type parent [child]))
(str "Parent and child page is valid: " (pr-str (:block/title parent)) " " (pr-str (:block/title child))))))))))