diff --git a/deps/outliner/src/logseq/outliner/op.cljs b/deps/outliner/src/logseq/outliner/op.cljs index 613bb46d69..5178c0f1cf 100644 --- a/deps/outliner/src/logseq/outliner/op.cljs +++ b/deps/outliner/src/logseq/outliner/op.cljs @@ -22,6 +22,10 @@ [:catn [:op :keyword] [:args [:tuple ::blocks ::id ::option]]]] + [:apply-template + [:catn + [:op :keyword] + [:args [:tuple ::id ::id ::option]]]] [:delete-blocks [:catn [:op :keyword] @@ -203,6 +207,45 @@ (js/console.error "Unexpected Import EDN error:" e) (reset! *result {:error (str "Unexpected Import EDN error: " (pr-str (ex-message e)))})))))) +(defn- apply-insert-blocks-op! + [conn *result [blocks target-block-id opts]] + (when-let [target-block (d/entity @conn target-block-id)] + (let [result (outliner-core/insert-blocks! conn blocks target-block opts)] + (reset! *result result)))) + +(defn- template-children-blocks + [db template-id] + (when-let [template (d/entity db template-id)] + (let [template-blocks (some->> (ldb/get-block-and-children db (:block/uuid template) + {:include-property-block? true}) + rest)] + (when (seq template-blocks) + (cons (assoc (first template-blocks) + :logseq.property/used-template (:db/id template)) + (rest template-blocks)))))) + +(defn- apply-template-op! + [conn *result [template-id target-block-id opts]] + (when-let [target (d/entity @conn target-block-id)] + (let [blocks (template-children-blocks @conn template-id)] + (when (seq blocks) + (let [sibling? (:sibling? opts) + sibling?' (cond + (some? sibling?) + sibling? + + (seq (:block/_parent target)) + false + + :else + true) + result (outliner-core/insert-blocks! conn blocks target + (assoc opts + :sibling? sibling?' + :insert-template? true + :outliner-op :insert-template-blocks))] + (reset! *result result)))))) + (defn- ^:large-vars/cleanup-todo apply-op! [conn opts' *result [op args]] (case op @@ -211,10 +254,10 @@ (apply outliner-core/save-block! conn args) :insert-blocks - (let [[blocks target-block-id opts] args] - (when-let [target-block (d/entity @conn target-block-id)] - (let [result (outliner-core/insert-blocks! conn blocks target-block opts)] - (reset! *result result)))) + (apply-insert-blocks-op! conn *result args) + + :apply-template + (apply-template-op! conn *result args) :delete-blocks (let [[block-ids opts] args diff --git a/deps/outliner/src/logseq/outliner/op/construct.cljc b/deps/outliner/src/logseq/outliner/op/construct.cljc index 3b7309a6f1..59028dfe22 100644 --- a/deps/outliner/src/logseq/outliner/op/construct.cljc +++ b/deps/outliner/src/logseq/outliner/op/construct.cljc @@ -13,6 +13,7 @@ (def ^:private semantic-outliner-ops #{:save-block :insert-blocks + :apply-template :move-blocks :move-blocks-up-down :indent-outdent-blocks @@ -46,7 +47,8 @@ :block.temp/ast-title :block.temp/ast-body :block.temp/load-status - :block.temp/has-children?}) + :block.temp/has-children? + :logseq.property/created-by-ref}) (def rebase-refs-key :db-sync.rebase/refs) (def canonical-transact-op [[:transact nil]]) @@ -262,6 +264,41 @@ distinct vec)) +(defn- canonicalize-insert-blocks-op + [db tx-data args] + (let [[blocks target-id opts] args + created-uuids (created-block-uuids-from-tx-data tx-data) + blocks* (mapv #(sanitize-insert-block-payload db %) blocks) + target-ref (stable-entity-ref db target-id) + blocks* (cond + (and (:replace-empty-target? opts) + (not (:keep-uuid? opts)) + (seq blocks*)) + (let [[fst-block & rst-blocks] blocks* + created-rst-uuids created-uuids] + (into [fst-block] + (if (and (seq created-rst-uuids) + (= (count rst-blocks) (count created-rst-uuids))) + (map (fn [block uuid] + (assoc block :block/uuid uuid)) + rst-blocks + created-rst-uuids) + rst-blocks))) + + (and (not (:keep-uuid? opts)) + (= (count blocks*) (count created-uuids))) + (mapv (fn [block uuid] + (assoc block :block/uuid uuid)) + blocks* + created-uuids) + + :else + blocks*)] + [blocks* + target-ref + (assoc (dissoc (or opts {}) :outliner-op) + :keep-uuid? true)])) + (defn- canonical-move-op-for-block [db block-id opts] (when-let [[target-id sibling?] (resolve-move-target db [block-id])] @@ -294,36 +331,22 @@ [:save-block [(sanitize-block-payload db block) opts]]) :insert-blocks - (let [[blocks target-id opts] args - created-uuids (created-block-uuids-from-tx-data tx-data) - blocks' (mapv #(sanitize-insert-block-payload db %) blocks) + [:insert-blocks + (canonicalize-insert-blocks-op db tx-data args)] + + :apply-template + (let [[template-id target-id opts] args + template-ref (stable-entity-ref db template-id) target-ref (stable-entity-ref db target-id) - blocks' (cond - (and (:replace-empty-target? opts) - (seq blocks')) - (let [[fst-block & rst-blocks] blocks'] - (into [fst-block] - (if (and (not (:keep-uuid? opts)) - (= (count rst-blocks) (count created-uuids))) - (map (fn [block uuid] - (assoc block :block/uuid uuid)) - rst-blocks - created-uuids) - rst-blocks))) - - (and (not (:keep-uuid? opts)) - (= (count blocks') (count created-uuids))) - (mapv (fn [block uuid] - (assoc block :block/uuid uuid)) - blocks' - created-uuids) - - :else - blocks')] - [:insert-blocks [blocks' - target-ref - (assoc (dissoc (or opts {}) :outliner-op) - :keep-uuid? true)]]) + opts' (assoc (dissoc opts + :template-blocks + :template-id + :outliner-op) + :keep-uuid? true)] + (when-not (and template-ref target-ref) + (throw (ex-info "Invalid apply-template args" + {:args args}))) + [:apply-template [template-ref target-ref opts']]) :move-blocks-up-down (let [[ids up?] args] @@ -672,8 +695,45 @@ ;; Use restore semantics instead of save-block to retract recycle markers. [:restore-recycled [page-uuid]])))) +(defn- insert-like-delete-ids + [db-before db-after blocks] + (->> blocks + (keep (fn [block] + (when-let [u (:block/uuid block)] + [:block/uuid u]))) + (filter (fn [eid] + (and + (nil? (d/entity db-before eid)) + (d/entity db-after eid)))) + vec)) + +(defn- restore-target-insert-op + [db-before db-after target-id opts] + (when (:replace-empty-target? opts) + (when-let [target-ref (stable-entity-ref db-before target-id)] + (when (d/entity db-after target-ref) + (when-let [target (d/entity db-before target-ref)] + (build-inverse-save-block db-before target opts)))))) + +(defn- build-inverse-insert-like + [db-before db-after args] + (let [[blocks target-id opts] args + delete-ids (insert-like-delete-ids db-before db-after blocks) + restore-op (restore-target-insert-op db-before db-after target-id opts)] + (prn :debug :delete-ids delete-ids + :restore-op restore-op) + (cond-> [] + (seq delete-ids) + (conj [:delete-blocks [delete-ids {}]]) + + restore-op + (conj restore-op) + + :always + seq))) + (defn- build-strict-inverse-outliner-ops - [db-before forward-ops] + [db-before db-after forward-ops] (when (seq forward-ops) (let [inverse-entries (mapv (fn [[op args]] @@ -684,28 +744,10 @@ (build-inverse-save-block db-before block opts)) :insert-blocks - (let [[blocks _target-id opts] args] - (if (:replace-empty-target? opts) - (let [[fst-block & rst-blocks] blocks - delete-ids (->> rst-blocks - (keep (fn [block] - (when-let [u (:block/uuid block)] - [:block/uuid u]))) - vec) - restore-target-op (when fst-block - (build-inverse-save-block db-before fst-block nil))] - (concat - (when (seq delete-ids) - [[:delete-blocks [delete-ids {}]]]) - (when restore-target-op - [restore-target-op]))) - (let [ids (->> blocks - (keep (fn [block] - (when-let [u (:block/uuid block)] - [:block/uuid u]))) - vec)] - (when (seq ids) - [[:delete-blocks [ids {}]]])))) + (build-inverse-insert-like db-before db-after args) + + :apply-template + (build-inverse-insert-like db-before db-after args) :move-blocks (let [[ids _target-id _opts] args] @@ -798,7 +840,7 @@ (defn- has-replace-empty-target-insert-op? [forward-ops] (some (fn [[op [_blocks _target-id opts]]] - (and (= :insert-blocks op) + (and (contains? #{:insert-blocks :apply-template} op) (:replace-empty-target? opts))) forward-ops)) @@ -837,7 +879,7 @@ [inverse-outliner-ops forward-outliner-ops] (let [forward-insert-ops* (atom (->> forward-outliner-ops reverse - (filter #(= :insert-blocks (first %))) + (filter #(contains? #{:insert-blocks :apply-template} (first %))) vec))] (mapv (fn [[op args :as inverse-op]] (if (and (= :delete-blocks op) @@ -895,7 +937,7 @@ (some (fn [[op]] (= :transact op)) forward-outliner-ops)) canonical-transact-op forward-outliner-ops)) - built-inverse-outliner-ops (some-> (build-strict-inverse-outliner-ops db-before forward-outliner-ops) + built-inverse-outliner-ops (some-> (build-strict-inverse-outliner-ops db-before db-after forward-outliner-ops) seq vec) explicit-inverse-outliner-ops (some-> (canonicalize-explicit-outliner-ops db-after tx-data (:db-sync/inverse-outliner-ops tx-meta)) @@ -925,8 +967,13 @@ (defn build-history-action-metadata [{:keys [db-before db-after tx-data tx-meta] :as data}] (let [{:keys [forward-outliner-ops inverse-outliner-ops]} - (derive-history-outliner-ops db-before db-after tx-data tx-meta)] - (when (and (contains? semantic-outliner-ops (:outliner-op tx-meta)) + (derive-history-outliner-ops db-before db-after tx-data tx-meta) + semantic-ops-explicit? + (or (seq (:outliner-ops tx-meta)) + (seq (:db-sync/forward-outliner-ops tx-meta)) + (seq (:db-sync/inverse-outliner-ops tx-meta)))] + (when (and semantic-ops-explicit? + (contains? semantic-outliner-ops (:outliner-op tx-meta)) (not= :restore-recycled (:outliner-op tx-meta)) (or (empty? forward-outliner-ops) diff --git a/src/main/frontend/handler/editor.cljs b/src/main/frontend/handler/editor.cljs index 29cedef606..f9f6e2066d 100644 --- a/src/main/frontend/handler/editor.cljs +++ b/src/main/frontend/handler/editor.cljs @@ -1982,49 +1982,26 @@ (insert-template! element-id db-id {})) ([element-id db-id {:keys [target] :as opts}] (let [repo (state/get-current-repo)] - (p/let [block (db-async/ (first blocks') - (assoc :logseq.property/used-template (:db/id block))) - (rest blocks'))) - blocks sorted-blocks] + format (get block :block/format :markdown)] (when element-id (insert-command! element-id "" format {:end-pattern commands/command-trigger})) - (let [sibling? (:sibling? opts) - sibling?' (cond - (some? sibling?) - sibling? + (try + (p/let [result (ui-outliner-tx/transact! + {:outliner-op :apply-template + :created-from-journal-template? journal?} + (when-not (string/blank? (state/get-edit-content)) + (save-current-block!)) + (outliner-op/apply-template! db-id target opts))] + (when result (edit-last-block-after-inserted! result))) - (db/has-children? (:block/uuid target)) - false - - :else - true)] - (when (seq blocks) - (try - (p/let [result (ui-outliner-tx/transact! - {:outliner-op :insert-blocks - :created-from-journal-template? journal?} - (when-not (string/blank? (state/get-edit-content)) - (save-current-block!)) - (outliner-op/insert-blocks! blocks target - (assoc opts - :sibling? sibling?' - :insert-template? true)))] - (when result (edit-last-block-after-inserted! result))) - - (catch :default ^js/Error e - (notification/show! - (util/format "Template insert error: %s" (.-message e)) - :error))))))))))) + (catch :default ^js/Error e + (notification/show! + (util/format "Template insert error: %s" (.-message e)) + :error))))))))) (defn template-on-chosen-handler [element-id] diff --git a/src/main/frontend/modules/outliner/op.cljs b/src/main/frontend/modules/outliner/op.cljs index 3460220aaf..1e0cd1155a 100644 --- a/src/main/frontend/modules/outliner/op.cljs +++ b/src/main/frontend/modules/outliner/op.cljs @@ -35,6 +35,12 @@ (let [id (:db/id target-block)] [:insert-blocks [blocks id opts]]))) +(defn apply-template! + [template-id target-block opts] + (op-transact! + (let [id (:db/id target-block)] + [:apply-template [template-id id opts]]))) + (defn delete-blocks! [blocks opts] (op-transact! diff --git a/src/main/frontend/worker/sync/apply_txs.cljs b/src/main/frontend/worker/sync/apply_txs.cljs index 1ce2c70952..aab40b3b2a 100644 --- a/src/main/frontend/worker/sync/apply_txs.cljs +++ b/src/main/frontend/worker/sync/apply_txs.cljs @@ -22,6 +22,7 @@ [logseq.db.frontend.property.type :as db-property-type] [logseq.db.sqlite.util :as sqlite-util] [logseq.outliner.core :as outliner-core] + [logseq.outliner.op :as outliner-op] [logseq.outliner.op.construct :as op-construct] [logseq.outliner.page :as outliner-page] [logseq.outliner.property :as outliner-property] @@ -685,6 +686,21 @@ target-block (assoc (or opts {}) :persist-op? false))) + :apply-template + (let [[template-id target-id opts] args + template-id' (replay-entity-id-value @conn template-id) + target-id' (replay-entity-id-value @conn target-id)] + (when-not (and (int? template-id') (int? target-id')) + (invalid-rebase-op! op {:args args + :reason :missing-template-or-target-block})) + (outliner-op/apply-ops! + conn + [[:apply-template [template-id' + target-id' + (assoc (or opts {}) :persist-op? false)]]] + {:persist-op? false + :gen-undo-ops? false})) + :move-blocks (let [[ids target-id opts] args blocks (keep #(d/entity @conn %) ids)] diff --git a/src/test/frontend/worker/undo_redo_test.cljs b/src/test/frontend/worker/undo_redo_test.cljs index cffb21da1f..d698976dc4 100644 --- a/src/test/frontend/worker/undo_redo_test.cljs +++ b/src/test/frontend/worker/undo_redo_test.cljs @@ -123,6 +123,13 @@ results (recur (conj results result)))))) +(defn- latest-undo-history-data + [] + (let [undo-op (last (get @worker-undo-redo/*undo-ops test-repo))] + (some #(when (= ::worker-undo-redo/db-transact (first %)) + (second %)) + undo-op))) + (deftest undo-missing-history-action-row-clears-history-test (testing "worker undo treats missing tx-id action row as unavailable and clears history" (worker-undo-redo/clear-history! test-repo) @@ -369,6 +376,180 @@ (is (some? inserted-b)) (is (= "b" (:block/title inserted-b))))))) +(deftest undo-history-canonicalizes-template-replace-empty-target-to-apply-template-test + (testing "template replace-empty-target history uses :apply-template and inverse deletes + restores empty target" + (worker-undo-redo/clear-history! test-repo) + (let [conn (worker-state/get-datascript-conn test-repo) + {:keys [page-uuid]} (seed-page-parent-child!) + page-id (:db/id (d/entity @conn [:block/uuid page-uuid])) + template-root-uuid (random-uuid) + template-a-uuid (random-uuid) + template-b-uuid (random-uuid) + empty-target-uuid (random-uuid)] + (outliner-op/apply-ops! + conn + [[:insert-blocks [[{:block/uuid template-root-uuid + :block/title "template 1" + :block/tags #{:logseq.class/Template}} + {:block/uuid template-a-uuid + :block/title "a" + :block/parent [:block/uuid template-root-uuid]} + {:block/uuid template-b-uuid + :block/title "b" + :block/parent [:block/uuid template-a-uuid]}] + page-id + {:sibling? false + :keep-uuid? true}]]] + (local-tx-meta {:client-id "test-client"})) + (outliner-op/apply-ops! + conn + [[:insert-blocks [[{:block/uuid empty-target-uuid + :block/title ""}] + page-id + {:sibling? false + :keep-uuid? true}]]] + (local-tx-meta {:client-id "test-client"})) + (let [template-root (d/entity @conn [:block/uuid template-root-uuid]) + empty-target (d/entity @conn [:block/uuid empty-target-uuid]) + template-blocks (->> (ldb/get-block-and-children @conn template-root-uuid + {:include-property-block? true}) + rest) + blocks-to-insert (cons (assoc (first template-blocks) + :logseq.property/used-template (:db/id template-root)) + (rest template-blocks))] + (outliner-op/apply-ops! + conn + [[:insert-blocks [blocks-to-insert + (:db/id empty-target) + {:sibling? true + :replace-empty-target? true + :insert-template? true}]]] + (local-tx-meta {:client-id "test-client"}))) + (let [data (latest-undo-history-data) + inverse-ops (:db-sync/inverse-outliner-ops data) + delete-op (some #(when (= :delete-blocks (first %)) %) inverse-ops) + restore-empty-op (some #(when (= :insert-blocks (first %)) %) inverse-ops) + delete-ids (set (get-in delete-op [1 0]))] + (is (= :apply-template (ffirst (:db-sync/forward-outliner-ops data)))) + (is (contains? delete-ids [:block/uuid empty-target-uuid])) + (is (= :insert-blocks (first restore-empty-op))) + (is (= empty-target-uuid + (get-in restore-empty-op [1 0 0 :block/uuid]))) + (is (= "" + (get-in restore-empty-op [1 0 0 :block/title]))))))) + +(deftest undo-history-replace-empty-target-insert-restores-empty-target-with-insert-op-test + (testing "replace-empty-target insert inverse should delete inserted blocks and reinsert original empty target" + (worker-undo-redo/clear-history! test-repo) + (let [conn (worker-state/get-datascript-conn test-repo) + {:keys [page-uuid]} (seed-page-parent-child!) + page-id (:db/id (d/entity @conn [:block/uuid page-uuid])) + empty-target-uuid (random-uuid) + inserted-root-uuid (random-uuid) + inserted-child-uuid (random-uuid)] + (outliner-op/apply-ops! + conn + [[:insert-blocks [[{:block/uuid empty-target-uuid + :block/title ""}] + page-id + {:sibling? false + :keep-uuid? true}]]] + (local-tx-meta {:client-id "test-client"})) + (let [empty-target (d/entity @conn [:block/uuid empty-target-uuid])] + (outliner-op/apply-ops! + conn + [[:insert-blocks [[{:block/uuid inserted-root-uuid + :block/title "insert root"} + {:block/uuid inserted-child-uuid + :block/title "insert child" + :block/parent [:block/uuid inserted-root-uuid]}] + (:db/id empty-target) + {:sibling? true + :replace-empty-target? true}]]] + (local-tx-meta {:client-id "test-client"}))) + (let [data (latest-undo-history-data) + inverse-ops (:db-sync/inverse-outliner-ops data) + delete-op (some #(when (= :delete-blocks (first %)) %) inverse-ops) + restore-empty-op (some #(when (= :insert-blocks (first %)) %) inverse-ops) + delete-ids (set (get-in delete-op [1 0]))] + (is (= :insert-blocks (ffirst (:db-sync/forward-outliner-ops data)))) + (is (contains? delete-ids [:block/uuid empty-target-uuid])) + (is (not (some #(= :save-block (first %)) inverse-ops))) + (is (= :insert-blocks (first restore-empty-op))) + (is (= empty-target-uuid + (get-in restore-empty-op [1 0 0 :block/uuid]))) + (is (= "" + (get-in restore-empty-op [1 0 0 :block/title]))))))) + +(deftest apply-template-op-replays-via-undo-redo-test + (testing ":apply-template op can be applied and replayed via undo/redo" + (worker-undo-redo/clear-history! test-repo) + (let [conn (worker-state/get-datascript-conn test-repo) + {:keys [page-uuid]} (seed-page-parent-child!) + page-id (:db/id (d/entity @conn [:block/uuid page-uuid])) + template-root-uuid (random-uuid) + template-a-uuid (random-uuid) + template-b-uuid (random-uuid) + empty-target-uuid (random-uuid)] + (outliner-op/apply-ops! + conn + [[:insert-blocks [[{:block/uuid template-root-uuid + :block/title "template 1" + :block/tags #{:logseq.class/Template}} + {:block/uuid template-a-uuid + :block/title "a" + :block/parent [:block/uuid template-root-uuid]} + {:block/uuid template-b-uuid + :block/title "b" + :block/parent [:block/uuid template-a-uuid]}] + page-id + {:sibling? false + :keep-uuid? true}]]] + (local-tx-meta {:client-id "test-client"})) + (outliner-op/apply-ops! + conn + [[:insert-blocks [[{:block/uuid empty-target-uuid + :block/title ""}] + page-id + {:sibling? false + :keep-uuid? true}]]] + (local-tx-meta {:client-id "test-client"})) + (let [template-root (d/entity @conn [:block/uuid template-root-uuid]) + empty-target (d/entity @conn [:block/uuid empty-target-uuid]) + template-blocks (->> (ldb/get-block-and-children @conn template-root-uuid + {:include-property-block? true}) + rest) + blocks-to-insert (cons (assoc (first template-blocks) + :logseq.property/used-template (:db/id template-root)) + (rest template-blocks))] + (outliner-op/apply-ops! + conn + [[:apply-template [(:db/id template-root) + (:db/id empty-target) + {:sibling? true + :replace-empty-target? true + :template-blocks blocks-to-insert}]]] + (local-tx-meta {:client-id "test-client"}))) + + (let [data (latest-undo-history-data)] + (is (= :apply-template (ffirst (:db-sync/forward-outliner-ops data))))) + + (is (seq (undo-all!))) + (is (seq (redo-all!))) + + (let [inserted-a-id (d/q '[:find ?b . + :in $ ?template-uuid + :where + [?template :block/uuid ?template-uuid] + [?b :logseq.property/used-template ?template] + [?b :block/title "a"]] + @conn + template-root-uuid) + inserted-a (when inserted-a-id (d/entity @conn inserted-a-id)) + inserted-b (some->> inserted-a :block/_parent (filter #(= "b" (:block/title %))) first)] + (is (some? inserted-a)) + (is (some? inserted-b)))))) + (deftest undo-history-records-forward-ops-for-save-block-test (testing "worker save-block history keeps semantic forward ops for redo replay" (worker-undo-redo/clear-history! test-repo)