From 174cdfd8656cf4a2b2e14badc6c214e24aa46d50 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 17 Mar 2026 16:08:08 +0800 Subject: [PATCH] feat: recycle --- deps/common/src/logseq/common/config.cljs | 1 + deps/db/src/logseq/db.cljs | 1 + .../db/src/logseq/db/common/initial_data.cljs | 5 +- deps/db/src/logseq/db/common/view.cljs | 3 +- .../src/logseq/db/frontend/entity_util.cljs | 34 ++- .../src/logseq/db/frontend/malli_schema.cljs | 3 +- deps/db/src/logseq/db/frontend/property.cljs | 20 ++ deps/db/src/logseq/db/frontend/schema.cljs | 2 +- .../db/src/logseq/db/sqlite/create_graph.cljs | 12 +- deps/outliner/src/logseq/outliner/core.cljs | 16 +- deps/outliner/src/logseq/outliner/op.cljs | 12 +- deps/outliner/src/logseq/outliner/page.cljs | 50 ++-- .../src/logseq/outliner/property.cljs | 4 +- .../outliner/src/logseq/outliner/recycle.cljs | 256 ++++++++++++++++++ .../test/logseq/outliner/core_test.cljs | 49 +++- .../test/logseq/outliner/page_test.cljs | 10 +- .../test/logseq/outliner/property_test.cljs | 6 +- .../test/logseq/outliner/recycle_test.cljs | 75 +++++ src/main/frontend/components/header.cljs | 6 + src/main/frontend/components/page.cljs | 19 +- src/main/frontend/components/query.cljs | 6 +- src/main/frontend/components/recycle.cljs | 112 ++++++++ src/main/frontend/handler/block.cljs | 43 +-- src/main/frontend/handler/editor.cljs | 192 +++++++------ src/main/frontend/handler/page.cljs | 21 ++ src/main/frontend/handler/route.cljs | 1 + src/main/frontend/modules/outliner/op.cljs | 20 +- src/main/frontend/state.cljs | 10 +- src/main/frontend/undo_redo.cljs | 218 ++++----------- src/main/frontend/worker/db/migrate.cljs | 7 +- src/main/frontend/worker/db_worker.cljs | 34 ++- src/main/frontend/worker/sync.cljs | 26 +- src/main/frontend/worker/undo_redo.cljs | 67 +++-- src/test/frontend/handler/editor_test.cljs | 26 +- .../frontend/modules/outliner/core_test.cljs | 6 +- src/test/frontend/undo_redo_test.cljs | 183 ++++++++++++- src/test/frontend/worker/search_test.cljs | 7 + 37 files changed, 1153 insertions(+), 410 deletions(-) create mode 100644 deps/outliner/src/logseq/outliner/recycle.cljs create mode 100644 deps/outliner/test/logseq/outliner/recycle_test.cljs create mode 100644 src/main/frontend/components/recycle.cljs diff --git a/deps/common/src/logseq/common/config.cljs b/deps/common/src/logseq/common/config.cljs index f1b297a624..9a22e6532e 100644 --- a/deps/common/src/logseq/common/config.cljs +++ b/deps/common/src/logseq/common/config.cljs @@ -40,6 +40,7 @@ (defonce views-page-name "$$$views") (defonce library-page-name "Library") (defonce quick-add-page-name "Quick add") +(defonce recycle-page-name "Recycle") (defn local-relative-asset? [s] diff --git a/deps/db/src/logseq/db.cljs b/deps/db/src/logseq/db.cljs index fbedf656eb..28ebac1064 100644 --- a/deps/db/src/logseq/db.cljs +++ b/deps/db/src/logseq/db.cljs @@ -218,6 +218,7 @@ (def closed-value? entity-util/closed-value?) (def journal? entity-util/journal?) (def hidden? entity-util/hidden?) +(def recycled? entity-util/recycled?) (def object? entity-util/object?) (def asset? entity-util/asset?) (def public-built-in-property? db-property/public-built-in-property?) diff --git a/deps/db/src/logseq/db/common/initial_data.cljs b/deps/db/src/logseq/db/common/initial_data.cljs index c2b61249ca..fa1b8db2ee 100644 --- a/deps/db/src/logseq/db/common/initial_data.cljs +++ b/deps/db/src/logseq/db/common/initial_data.cljs @@ -343,9 +343,10 @@ user-datoms (get-all-user-datoms db) pages-datoms (let [contents-id (get-first-page-by-title db "Contents") capture-page-id (:db/id (db-db/get-built-in-page db common-config/quick-add-page-name)) - views-id (get-first-page-by-title db common-config/views-page-name)] + views-id (get-first-page-by-title db common-config/views-page-name) + recycle-id (get-first-page-by-title db "Recycle")] (mapcat #(d/datoms db :eavt %) - (remove nil? [contents-id capture-page-id views-id]))) + (remove nil? [contents-id capture-page-id views-id recycle-id]))) data (->> (concat idents structured-datoms user-datoms diff --git a/deps/db/src/logseq/db/common/view.cljs b/deps/db/src/logseq/db/common/view.cljs index f63b115f14..f398b139cd 100644 --- a/deps/db/src/logseq/db/common/view.cljs +++ b/deps/db/src/logseq/db/common/view.cljs @@ -295,7 +295,8 @@ exclude-ids (get-exclude-page-ids db)] (keep (fn [d] (let [e (entity-plus/unsafe->Entity db (:e d))] - (when-not (exclude-ids (:db/id e)) + (when-not (or (exclude-ids (:db/id e)) + (entity-util/hidden? e)) (cond-> e refs-count? (assoc :block.temp/refs-count (common-initial-data/get-block-refs-count db (:e d))))))) diff --git a/deps/db/src/logseq/db/frontend/entity_util.cljs b/deps/db/src/logseq/db/frontend/entity_util.cljs index 5368e34830..6300286e54 100644 --- a/deps/db/src/logseq/db/frontend/entity_util.cljs +++ b/deps/db/src/logseq/db/frontend/entity_util.cljs @@ -57,12 +57,34 @@ (defn hidden? [page] - (boolean - (when page - (if (string? page) - (string/starts-with? page "$$$") - (when (or (map? page) (de/entity? page)) - (:logseq.property/hide? page)))))) + (letfn [(hidden-parent? [entity seen] + (when (and entity + (:db/id entity) + (not (contains? seen (:db/id entity)))) + (or (:logseq.property/hide? entity) + (:logseq.property/deleted-at entity) + (hidden-parent? (:block/parent entity) (conj seen (:db/id entity))))))] + (boolean + (when page + (if (string? page) + (string/starts-with? page "$$$") + (when (or (map? page) (de/entity? page)) + (or (:logseq.property/hide? page) + (:logseq.property/deleted-at page) + (hidden-parent? (:block/parent page) #{})))))))) + +(defn recycled? + [entity] + (letfn [(recycled-parent? [parent seen] + (when (and parent + (:db/id parent) + (not (contains? seen (:db/id parent)))) + (or (:logseq.property/deleted-at parent) + (recycled-parent? (:block/parent parent) (conj seen (:db/id parent))))))] + (boolean + (when (or (map? entity) (de/entity? entity)) + (or (:logseq.property/deleted-at entity) + (recycled-parent? (:block/parent entity) #{})))))) (defn object? [node] diff --git a/deps/db/src/logseq/db/frontend/malli_schema.cljs b/deps/db/src/logseq/db/frontend/malli_schema.cljs index 2efcf516b1..4d528e0afe 100644 --- a/deps/db/src/logseq/db/frontend/malli_schema.cljs +++ b/deps/db/src/logseq/db/frontend/malli_schema.cljs @@ -561,7 +561,8 @@ :property (entity-util/class? d) :class - (entity-util/hidden? d) + (and (entity-util/page? d) + (true? (:logseq.property/hide? d))) :hidden ;; TODO: Remove deprecated (whiteboard? d) diff --git a/deps/db/src/logseq/db/frontend/property.cljs b/deps/db/src/logseq/db/frontend/property.cljs index 29f285ff0d..e627d9424c 100644 --- a/deps/db/src/logseq/db/frontend/property.cljs +++ b/deps/db/src/logseq/db/frontend/property.cljs @@ -619,6 +619,26 @@ :schema {:type :entity :hide? true} :queryable? true} + :logseq.property/deleted-at {:title "Deleted at" + :schema {:type :datetime + :hide? true + :public? false}} + :logseq.property/deleted-by-ref {:title "Deleted by" + :schema {:type :entity + :hide? true + :public? false}} + :logseq.property.recycle/original-parent {:title "Recycle original parent" + :schema {:type :entity + :hide? true + :public? false}} + :logseq.property.recycle/original-page {:title "Recycle original page" + :schema {:type :entity + :hide? true + :public? false}} + :logseq.property.recycle/original-order {:title "Recycle original order" + :schema {:type :string + :hide? true + :public? false}} :logseq.property.reaction/emoji-id {:title "Reaction emoji" :schema {:type :string :public? false diff --git a/deps/db/src/logseq/db/frontend/schema.cljs b/deps/db/src/logseq/db/frontend/schema.cljs index c6b2582114..67ca58a7eb 100644 --- a/deps/db/src/logseq/db/frontend/schema.cljs +++ b/deps/db/src/logseq/db/frontend/schema.cljs @@ -30,7 +30,7 @@ (map (juxt :major :minor) [(parse-schema-version x) (parse-schema-version y)]))) -(def version (parse-schema-version "65.23")) +(def version (parse-schema-version "65.24")) (defn major-version "Return a number. diff --git a/deps/db/src/logseq/db/sqlite/create_graph.cljs b/deps/db/src/logseq/db/sqlite/create_graph.cljs index e32098cd00..c5b69e094a 100644 --- a/deps/db/src/logseq/db/sqlite/create_graph.cljs +++ b/deps/db/src/logseq/db/sqlite/create_graph.cljs @@ -182,6 +182,16 @@ :logseq.property/hide? true :logseq.property/built-in? true})])) +(defn- build-recycle-page + [] + [(sqlite-util/block-with-timestamps + {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "Recycle") + :block/name (common-util/page-name-sanity-lc "Recycle") + :block/title "Recycle" + :block/tags [:logseq.class/Page] + :logseq.property/hide? true + :logseq.property/built-in? true})]) + (defn- build-favorites-page [] [(sqlite-util/block-with-timestamps @@ -247,7 +257,7 @@ default-classes (build-initial-classes db-ident->properties) default-pages (->> (map sqlite-util/build-new-page built-in-pages-names) (map mark-block-as-built-in)) - hidden-pages (concat (build-initial-views) (build-favorites-page)) + hidden-pages (concat (build-initial-views) (build-favorites-page) (build-recycle-page)) ;; These classes bootstrap our tags and properties as they depend on each other e.g. ;; Root <-> Tag, classes-tx depends on logseq.property.class/extends, properties-tx depends on Property bootstrap-class? (fn [c] (contains? #{:logseq.class/Root :logseq.class/Property :logseq.class/Tag :logseq.class/Template} (:db/ident c))) diff --git a/deps/outliner/src/logseq/outliner/core.cljs b/deps/outliner/src/logseq/outliner/core.cljs index ac21ff78bc..3163ac37c0 100644 --- a/deps/outliner/src/logseq/outliner/core.cljs +++ b/deps/outliner/src/logseq/outliner/core.cljs @@ -15,6 +15,7 @@ [logseq.db.sqlite.create-graph :as sqlite-create-graph] [logseq.outliner.datascript :as ds] [logseq.outliner.pipeline :as outliner-pipeline] + [logseq.outliner.recycle :as outliner-recycle] [logseq.outliner.transaction :as outliner-tx] [logseq.outliner.tree :as otree] [logseq.outliner.validate :as outliner-validate] @@ -796,11 +797,13 @@ (defn ^:api ^:large-vars/cleanup-todo delete-blocks "Delete blocks from the tree." - [db blocks] + [db blocks opts] (let [top-level-blocks (filter-top-level-blocks db blocks) non-consecutive? (and (> (count top-level-blocks) 1) (seq (ldb/get-non-consecutive-blocks db top-level-blocks))) top-level-blocks* (get-top-level-blocks top-level-blocks non-consecutive?) - top-level-blocks (remove :logseq.property/built-in? top-level-blocks*) + top-level-blocks (->> top-level-blocks* + (remove :logseq.property/built-in?) + (remove ldb/page?)) txs-state (ds/new-outliner-txs-state) block-ids (map (fn [b] [:block/uuid (:block/uuid b)]) top-level-blocks) start-block (first top-level-blocks) @@ -827,9 +830,8 @@ (when (seq tx-data) (swap! txs-state concat tx-data))) :else - (doseq [id block-ids] - (let [node (d/entity db id)] - (otree/-del node txs-state db)))))) + (swap! txs-state concat + (outliner-recycle/recycle-blocks-tx-data db top-level-blocks opts))))) {:tx-data @txs-state})) (defn- move-to-original-position? @@ -1067,8 +1069,8 @@ opts (assoc opts :outliner-op :insert-blocks))))) -(let [f (fn [conn blocks _opts] - (delete-blocks @conn blocks))] +(let [f (fn [conn blocks opts] + (delete-blocks @conn blocks opts))] (defn delete-blocks! [conn blocks opts] (op-transact! :delete-blocks f conn blocks opts))) diff --git a/deps/outliner/src/logseq/outliner/op.cljs b/deps/outliner/src/logseq/outliner/op.cljs index 3c07d022ee..f7c8597d50 100644 --- a/deps/outliner/src/logseq/outliner/op.cljs +++ b/deps/outliner/src/logseq/outliner/op.cljs @@ -120,7 +120,7 @@ [:delete-page [:catn [:op :keyword] - [:args [:tuple ::uuid]]]] + [:args [:tuple ::uuid ::option]]]] [:toggle-reaction [:catn @@ -303,11 +303,9 @@ :transact-opts {:conn conn} :local-tx? true) *result (atom nil)] - (if (next ops) - (outliner-tx/transact! - opts' - (doseq [op-entry ops] - (apply-op! conn opts' *result op-entry))) - (apply-op! conn opts' *result (first ops))) + (outliner-tx/transact! + opts' + (doseq [op-entry ops] + (apply-op! conn opts' *result op-entry))) @*result)) diff --git a/deps/outliner/src/logseq/outliner/page.cljs b/deps/outliner/src/logseq/outliner/page.cljs index 36aeaf50f8..b46abfa953 100644 --- a/deps/outliner/src/logseq/outliner/page.cljs +++ b/deps/outliner/src/logseq/outliner/page.cljs @@ -18,6 +18,7 @@ [logseq.db.frontend.property.build :as db-property-build] [logseq.graph-parser.block :as gp-block] [logseq.graph-parser.text :as text] + [logseq.outliner.recycle :as outliner-recycle] [logseq.outliner.validate :as outliner-validate])) (defn- db-refs->page @@ -46,49 +47,30 @@ (defn delete! "Deletes a page. Returns true if able to delete page. If unable to delete, calls error-handler fn and returns false" - [conn page-uuid & {:keys [persist-op? rename? error-handler] + [conn page-uuid & {:keys [persist-op? rename? error-handler deleted-by-uuid now-ms] :or {persist-op? true error-handler (fn [{:keys [msg]}] (js/console.error msg))}}] (assert (uuid? page-uuid) (str ::delete! " wrong page-uuid: " (if page-uuid page-uuid "nil"))) (when page-uuid (when-let [page (d/entity @conn [:block/uuid page-uuid])] - (let [blocks (:block/_page page) - truncate-blocks-tx-data (mapv - (fn [block] - [:db/retractEntity [:block/uuid (:block/uuid block)]]) - blocks)] - ;; TODO: maybe we should add $$$favorites to built-in pages? - (if (or (ldb/built-in? page) (ldb/hidden? page)) - (do - (error-handler {:msg "Built-in page cannot be deleted"}) - false) - (let [delete-property-tx (when (ldb/property? page) - (concat - (let [datoms (d/datoms @conn :avet (:db/ident page))] - (map (fn [d] [:db/retract (:e d) (:a d)]) datoms)) - (map (fn [d] [:db/retractEntity (:e d)]) - (d/datoms @conn :avet :logseq.property.history/property (:db/ident page))))) - today-page? (when-let [day (:block/journal-day page)] - (= (date-time-util/ms->journal-day (js/Date.)) day)) - delete-page-tx (when-not today-page? - (concat (db-refs->page page) - delete-property-tx - [[:db/retractEntity (:db/id page)]])) - restore-class-parent-tx (->> (filter ldb/class? (:logseq.property.class/_extends page)) - (map (fn [p] - {:db/id (:db/id p) - :logseq.property.class/extends :logseq.class/Root}))) - tx-data (concat truncate-blocks-tx-data - restore-class-parent-tx - delete-page-tx)] - + ;; TODO: maybe we should add $$$favorites to built-in pages? + (if (or (ldb/built-in? page) (ldb/hidden? page)) + (do + (error-handler {:msg "Built-in page cannot be deleted"}) + false) + (let [today-page? (when-let [day (:block/journal-day page)] + (= (date-time-util/ms->journal-day (js/Date.)) day)) + tx-data (when-not today-page? + (outliner-recycle/recycle-page-tx-data @conn page {:deleted-by-uuid deleted-by-uuid + :now-ms now-ms}))] + (when (seq tx-data) (ldb/transact! conn tx-data (cond-> {:outliner-op :delete-page - :deleted-page (str (:block/uuid page)) + :deleted-page (:block/title page) :persist-op? persist-op?} rename? - (assoc :real-outliner-op :rename-page))) - true)))))) + (assoc :real-outliner-op :rename-page)))) + true))))) (defn- build-page-tx [db properties page {:keys [class? tags class-ident-namespace]}] (when (:block/uuid page) diff --git a/deps/outliner/src/logseq/outliner/property.cljs b/deps/outliner/src/logseq/outliner/property.cljs index 5a8c01ba1a..49d6a20d3c 100644 --- a/deps/outliner/src/logseq/outliner/property.cljs +++ b/deps/outliner/src/logseq/outliner/property.cljs @@ -427,7 +427,7 @@ entities) ;; Delete property value block if it's no longer used by other blocks retract-blocks-tx (when (seq deleting-entities) - (:tx-data (outliner-core/delete-blocks @conn deleting-entities)))] + (:tx-data (outliner-core/delete-blocks @conn deleting-entities {})))] (concat [[:db/retract (:db/id block) (:db/ident property)]] retract-blocks-tx))) @@ -847,7 +847,7 @@ {:type :notification :payload {:message "The choice can't be deleted because it's built-in." :type :warning}})) - (let [data (:tx-data (outliner-core/delete-blocks @conn [value-block])) + (let [data (:tx-data (outliner-core/delete-blocks @conn [value-block] {})) tx-data (conj data (outliner-core/block-with-updated-at {:db/id property-id}))] (ldb/transact! conn tx-data))))) diff --git a/deps/outliner/src/logseq/outliner/recycle.cljs b/deps/outliner/src/logseq/outliner/recycle.cljs new file mode 100644 index 0000000000..f0e4cd4983 --- /dev/null +++ b/deps/outliner/src/logseq/outliner/recycle.cljs @@ -0,0 +1,256 @@ +(ns logseq.outliner.recycle + "Recycle-based soft delete helpers for DB graphs" + (:require [datascript.core :as d] + [logseq.common.util :as common-util] + [logseq.common.uuid :as common-uuid] + [logseq.db :as ldb] + [logseq.db.common.initial-data :as common-initial-data] + [logseq.db.common.order :as db-order])) + +(def ^:private recycle-page-title "Recycle") +(def retention-ms (* 60 24 3600 1000)) +(def gc-interval-ms (* 24 3600 1000)) + +(defn recycled? + [entity] + (some? (:logseq.property/deleted-at entity))) + +(defn- build-recycle-page-tx + [db-id] + (let [now (common-util/time-ms)] + {:db/id db-id + :block/uuid (common-uuid/gen-uuid :builtin-block-uuid recycle-page-title) + :block/name (common-util/page-name-sanity-lc recycle-page-title) + :block/title recycle-page-title + :block/tags [:logseq.class/Page] + :block/created-at now + :block/updated-at now + :logseq.property/hide? true + :logseq.property/built-in? true})) + +(defn recycle-page + [db] + (ldb/get-built-in-page db recycle-page-title)) + +(defn- ensure-recycle-page + [db] + (if-let [page (recycle-page db)] + {:page page + :page-id (:db/id page) + :tx-data []} + {:page nil + :page-id "recycle-page" + :tx-data [(build-recycle-page-tx "recycle-page")]})) + +(defn- next-child-order + [parent] + (let [last-child (last (ldb/sort-by-order (:block/_parent parent)))] + (db-order/gen-key (:block/order last-child) nil))) + +(defn- maybe-assoc-ref + [m k entity] + (if (and entity (:db/id entity)) + (assoc m k (:db/id entity)) + m)) + +(defn- maybe-assoc + [m k v] + (if (some? v) + (assoc m k v) + m)) + +(defn- resolve-entity + [db value] + (cond + (and value (:db/id value)) value + (int? value) (d/entity db value) + (vector? value) (d/entity db value) + :else nil)) + +(defn- block-subtree + [db block] + (let [ids (cons (:db/id block) + (common-initial-data/get-block-full-children-ids db (:db/id block)))] + (keep #(d/entity db %) ids))) + +(defn- page-descendants + [page] + (loop [pages [page] + result []] + (if-let [page' (first pages)] + (let [children (->> (:block/_parent page') + (filter ldb/page?) + ldb/sort-by-order)] + (recur (concat (rest pages) children) + (conj result page'))) + result))) + +(defn- page-block-subtree-ids + [db page] + (->> (:block/_page page) + ldb/sort-by-order + (mapcat (fn [block] + (map :db/id (block-subtree db block)))))) + +(defn- page-tree-ids + [db page] + (->> (page-descendants page) + (mapcat (fn [page'] + (cons (:db/id page') + (page-block-subtree-ids db page')))) + distinct)) + +(defn- deleted-by-id + [db deleted-by-uuid] + (some-> deleted-by-uuid + (#(d/entity db [:block/uuid %])) + :db/id)) + +(defn recycle-blocks-tx-data + [db blocks {:keys [deleted-by-uuid now-ms]}] + (let [{:keys [page page-id tx-data]} (ensure-recycle-page db) + deleted-by-id (deleted-by-id db deleted-by-uuid) + now-ms (or now-ms (common-util/time-ms))] + (let [[recycle-tx _previous-order] + (reduce + (fn [[txs previous-order] block] + (let [subtree (block-subtree db block) + order (db-order/gen-key previous-order nil) + root-tx (cond-> {:db/id (:db/id block) + :block/parent page-id + :block/page page-id + :block/order order + :logseq.property/deleted-at now-ms} + true + (maybe-assoc-ref :logseq.property/deleted-by-ref (d/entity db deleted-by-id)) + true + (maybe-assoc-ref :logseq.property.recycle/original-parent (:block/parent block)) + true + (maybe-assoc-ref :logseq.property.recycle/original-page (:block/page block)) + true + (maybe-assoc :logseq.property.recycle/original-order (:block/order block))) + subtree-page-tx (map (fn [node] + {:db/id (:db/id node) + :block/page page-id}) + subtree)] + [(into txs (cons root-tx (rest subtree-page-tx))) order])) + [[] (some->> page :block/_parent ldb/sort-by-order last :block/order)] + blocks)] + (concat tx-data recycle-tx)))) + +(defn recycle-page-tx-data + [db page {:keys [deleted-by-uuid now-ms]}] + (let [{recycle-page-id :page-id + recycle-page-tx-data :tx-data + recycle-page-existing :page} (ensure-recycle-page db) + deleted-by-id (deleted-by-id db deleted-by-uuid) + now-ms (or now-ms (common-util/time-ms))] + (concat recycle-page-tx-data + [(cond-> {:db/id (:db/id page) + :block/parent recycle-page-id + :block/order (if recycle-page-existing + (next-child-order recycle-page-existing) + (db-order/gen-key nil nil)) + :logseq.property/deleted-at now-ms} + true + (maybe-assoc-ref :logseq.property/deleted-by-ref (d/entity db deleted-by-id)) + true + (maybe-assoc-ref :logseq.property.recycle/original-parent (:block/parent page)) + true + (maybe-assoc-ref :logseq.property.recycle/original-page page) + true + (maybe-assoc :logseq.property.recycle/original-order (:block/order page)))]))) + +(defn- restore-order + [target-parent] + (next-child-order target-parent)) + +(defn- restore-target + [db root] + (let [original-parent (resolve-entity db (:logseq.property.recycle/original-parent root)) + original-page (resolve-entity db (:logseq.property.recycle/original-page root)) + parent-valid? (and original-parent + (not (recycled? original-parent)) + (d/entity db (:db/id original-parent)))] + (cond + (ldb/page? root) + {:parent (when parent-valid? original-parent) + :page root + :order (or (:logseq.property.recycle/original-order root) + (when parent-valid? (restore-order original-parent)))} + + parent-valid? + {:parent original-parent + :page original-page + :order (or (:logseq.property.recycle/original-order root) + (restore-order original-parent))} + + (and original-page + (d/entity db (:db/id original-page)) + (not (recycled? original-page))) + {:parent original-page + :page original-page + :order (restore-order original-page)} + + :else + nil))) + +(defn restore-tx-data + [db root] + (when-let [{:keys [parent page order]} (restore-target db root)] + (let [subtree (when-not (ldb/page? root) + (block-subtree db root)) + clear-structure [[:db/retract (:db/id root) :block/parent] + [:db/retract (:db/id root) :block/order] + (when-not (ldb/page? root) + [:db/retract (:db/id root) :block/page])] + clear-meta [[:db/retract (:db/id root) :logseq.property/deleted-at] + [:db/retract (:db/id root) :logseq.property/deleted-by-ref] + [:db/retract (:db/id root) :logseq.property.recycle/original-parent] + [:db/retract (:db/id root) :logseq.property.recycle/original-page] + [:db/retract (:db/id root) :logseq.property.recycle/original-order]] + root-tx (cond-> {:db/id (:db/id root)} + parent + (assoc :block/parent (:db/id parent)) + order + (assoc :block/order order) + (not (ldb/page? root)) + (assoc :block/page (:db/id page))) + subtree-page-tx (when (seq subtree) + (map (fn [node] + {:db/id (:db/id node) + :block/page (:db/id page)}) + subtree))] + (concat clear-structure [root-tx] subtree-page-tx (remove nil? clear-meta))))) + +(defn restore! + [conn root-uuid] + (when-let [root (d/entity @conn [:block/uuid root-uuid])] + (when-let [tx-data (seq (restore-tx-data @conn root))] + (ldb/transact! conn tx-data {:outliner-op :restore-recycled}) + true))) + +(defn gc-tx-data + [db {:keys [now-ms] :or {now-ms (common-util/time-ms)}}] + (let [cutoff (- now-ms retention-ms)] + (->> + (d/q '[:find [?e ...] + :in $ ?cutoff + :where + [?e :logseq.property/deleted-at ?deleted-at] + [(<= ?deleted-at ?cutoff)]] + db cutoff) + (map #(d/entity db %)) + (filter recycled?) + (mapcat (fn [entity] + (if (ldb/page? entity) + (map (fn [id] [:db/retractEntity id]) (page-tree-ids db entity)) + (map (fn [node] [:db/retractEntity (:db/id node)]) (block-subtree db entity))))) + distinct))) + +(defn gc! + [conn opts] + (when-let [tx-data (seq (gc-tx-data @conn opts))] + (ldb/transact! conn tx-data {:outliner-op :recycle-gc + :persist-op? false}) + true)) diff --git a/deps/outliner/test/logseq/outliner/core_test.cljs b/deps/outliner/test/logseq/outliner/core_test.cljs index ef2f1d1584..f7698909dd 100644 --- a/deps/outliner/test/logseq/outliner/core_test.cljs +++ b/deps/outliner/test/logseq/outliner/core_test.cljs @@ -6,16 +6,20 @@ [logseq.outliner.core :as outliner-core])) (deftest test-delete-block-with-default-property - (testing "Delete block with default property" + (testing "Delete block with default property moves the block to recycle" (let [conn (db-test/create-conn-with-blocks [{:page {:block/title "page1"} :blocks [{:block/title "b1" :build/properties {:default "test block"}}]}]) - property-value (:user.property/default (db-test/find-block-by-content @conn "b1")) - _ (assert (:db/id property-value)) 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"))) - (is (nil? (db-test/find-block-by-content @conn "test block")))))) + (let [block' (db-test/find-block-by-content @conn "b1") + property-value (:user.property/default block') + recycle-page (ldb/get-built-in-page @conn "Recycle")] + (is (some? block')) + (is (some? property-value)) + (is (integer? (:logseq.property/deleted-at block'))) + (is (= (:db/id recycle-page) (:db/id (:block/page block')))) + (is (= (:db/id recycle-page) (:db/id (:block/page property-value)))))))) (deftest test-delete-page-with-outliner-core (testing "Pages shouldn't be deleted through outliner-core/delete-blocks" @@ -37,5 +41,36 @@ (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'))))))) + (is (= (:db/id page1) (:db/id (:block/parent page2')))) + (is (= "a1" (:block/order page2'))))))) + +(deftest delete-blocks-moves-subtree-to-recycle + (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"}]}]}]) + recycle-page (ldb/get-built-in-page @conn "Recycle") + page (ldb/get-page @conn "page1") + parent (db-test/find-block-by-content @conn "parent") + child (db-test/find-block-by-content @conn "child") + original-order (:block/order 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") + recycle-page (ldb/get-built-in-page @conn "Recycle")] + (is (some? parent')) + (is (some? child')) + (is (= (:block/uuid recycle-page) (:block/uuid (:block/parent parent')))) + (is (= (:block/uuid recycle-page) (:block/uuid (:block/page parent')))) + (is (integer? (:logseq.property/deleted-at parent'))) + (is (= user-uuid + (:block/uuid (d/entity @conn (:logseq.property/deleted-by-ref parent'))))) + (is (= (:block/uuid page) + (:block/uuid (d/entity @conn (:logseq.property.recycle/original-page parent'))))) + (is (= original-order (:logseq.property.recycle/original-order parent'))) + (is (= (:block/uuid parent') (:block/uuid (:block/parent child')))) + (is (= (:block/uuid recycle-page) (:block/uuid (:block/page child')))) + (is (nil? (:logseq.property/deleted-at child')))))) diff --git a/deps/outliner/test/logseq/outliner/page_test.cljs b/deps/outliner/test/logseq/outliner/page_test.cljs index cd8dfe5b7a..e762526d37 100644 --- a/deps/outliner/test/logseq/outliner/page_test.cljs +++ b/deps/outliner/test/logseq/outliner/page_test.cljs @@ -108,8 +108,14 @@ (is (contains? (set (map :db/id (:block/refs (d/entity @conn (:db/id b1))))) (:db/id d1))) (outliner-page/delete! conn (:block/uuid d1)) - (is (nil? (d/entity @conn (:db/id d1)))) - (is (nil? (d/entity @conn (:db/id b1)))))) + (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 (= (:block/uuid d1') (:block/uuid (:block/page b1'))))))) (deftest create-journal (let [conn (db-test/create-conn) diff --git a/deps/outliner/test/logseq/outliner/property_test.cljs b/deps/outliner/test/logseq/outliner/property_test.cljs index 677148c49a..3c18755228 100644 --- a/deps/outliner/test/logseq/outliner/property_test.cljs +++ b/deps/outliner/test/logseq/outliner/property_test.cljs @@ -305,7 +305,11 @@ _ (assert (:user.property/default (db-test/find-block-by-content @conn "b1"))) property-id (:db/id (d/entity @conn :user.property/default)) _ (outliner-property/delete-closed-value! conn property-id [:block/uuid closed-value-uuid])] - (is (nil? (d/entity @conn [:block/uuid closed-value-uuid]))))) + (let [closed-value (d/entity @conn [:block/uuid closed-value-uuid]) + recycle-page (ldb/get-built-in-page @conn "Recycle")] + (is (some? closed-value)) + (is (integer? (:logseq.property/deleted-at closed-value))) + (is (= (:db/id recycle-page) (:db/id (:block/page closed-value))))))) (deftest class-add-property! (let [conn (db-test/create-conn-with-blocks diff --git a/deps/outliner/test/logseq/outliner/recycle_test.cljs b/deps/outliner/test/logseq/outliner/recycle_test.cljs new file mode 100644 index 0000000000..e7149b4bae --- /dev/null +++ b/deps/outliner/test/logseq/outliner/recycle_test.cljs @@ -0,0 +1,75 @@ +(ns logseq.outliner.recycle-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.outliner.recycle :as recycle])) + +(deftest restore-recycled-block-returns-subtree-to-original-location + (let [conn (db-test/create-conn-with-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "parent" + :build/children [{:block/title "child"}]} + {:block/title "sibling"}]}]) + page (ldb/get-page @conn "page1") + parent (db-test/find-block-by-content @conn "parent")] + (outliner-core/delete-blocks! conn [parent] {}) + (recycle/restore! conn (:block/uuid parent)) + (let [parent' (db-test/find-block-by-content @conn "parent") + child' (db-test/find-block-by-content @conn "child")] + (is (= (:block/uuid page) (:block/uuid (:block/parent parent')))) + (is (= (:block/uuid page) (:block/uuid (:block/page parent')))) + (is (= (:block/uuid parent') (:block/uuid (:block/parent child')))) + (is (= (:block/uuid page) (:block/uuid (:block/page child')))) + (is (nil? (:logseq.property/deleted-at parent'))) + (is (nil? (:logseq.property/deleted-by-ref parent'))) + (is (nil? (:logseq.property.recycle/original-parent parent'))) + (is (nil? (:logseq.property.recycle/original-page parent'))) + (is (nil? (:logseq.property.recycle/original-order parent')))))) + +(deftest restore-recycled-block-falls-back-to-page-root-when-original-parent-is-unavailable + (let [conn (db-test/create-conn-with-blocks + [{:page {:block/title "page1"} + :blocks [{:block/title "parent" + :build/children [{:block/title "child"}]} + {:block/title "sibling"}]}]) + page (ldb/get-page @conn "page1") + parent (db-test/find-block-by-content @conn "parent") + child (db-test/find-block-by-content @conn "child")] + (outliner-core/delete-blocks! conn [child] {}) + (outliner-core/delete-blocks! conn [parent] {}) + (recycle/restore! conn (:block/uuid child)) + (let [child' (db-test/find-block-by-content @conn "child")] + (is (= (:block/uuid page) (:block/uuid (:block/parent child')))) + (is (= (:block/uuid page) (:block/uuid (:block/page child')))) + (is (nil? (:logseq.property/deleted-at child')))))) + +(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 gc-retracts-recycled-subtrees-older-than-retention-window + (let [now-ms 1000 + old-ms (- now-ms (* 61 24 3600 1000)) + 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")] + (outliner-core/delete-blocks! conn [parent] {}) + (d/transact! conn [{:db/id (:db/id (db-test/find-block-by-content @conn "parent")) + :logseq.property/deleted-at old-ms}]) + (recycle/gc! conn {:now-ms now-ms}) + (is (nil? (d/entity @conn [:block/uuid (:block/uuid parent)]))) + (is (nil? (d/entity @conn [:block/uuid (:block/uuid child)]))))) diff --git a/src/main/frontend/components/header.cljs b/src/main/frontend/components/header.cljs index 56f5ff98eb..6a3ae4afbe 100644 --- a/src/main/frontend/components/header.cljs +++ b/src/main/frontend/components/header.cljs @@ -29,6 +29,7 @@ [frontend.ui :as ui] [frontend.util :as util] [frontend.version :refer [version]] + [logseq.common.config :as common-config] [logseq.common.util :as common-util] [logseq.db :as ldb] [logseq.shui.hooks :as hooks] @@ -156,6 +157,11 @@ :options {:on-click #(state/pub-event! [:ui/toggle-appearance])} :icon (ui/icon "color-swatch")} + (when (db/get-page common-config/recycle-page-name) + {:title "Recycle" + :options {:on-click page-handler/open-recycle!} + :icon (ui/icon "trash")}) + (when current-repo {:title (t :export-graph) :options {:on-click #(shui/dialog-open! export/export)} diff --git a/src/main/frontend/components/page.cljs b/src/main/frontend/components/page.cljs index e747400b41..994aa2c4a7 100644 --- a/src/main/frontend/components/page.cljs +++ b/src/main/frontend/components/page.cljs @@ -12,6 +12,7 @@ [frontend.components.plugins :as plugins] [frontend.components.property.config :as property-config] [frontend.components.query :as query] + [frontend.components.recycle :as recycle] [frontend.components.reference :as reference] [frontend.components.scheduled-deadlines :as scheduled] [frontend.components.svg :as svg] @@ -418,6 +419,8 @@ property-page? (ldb/property? page) title (:block/title page) journal? (db/journal-page? title) + recycle-page? (and (ldb/page? page) + (= title common-config/recycle-page-name)) fmt-journal? (boolean (date/journal-title->int title)) today? (and journal? @@ -462,14 +465,16 @@ (tabs page {:current-page? option :sidebar? sidebar?})) (when (not tag-dialog?) - [:div.ls-page-blocks - {:style {:margin-left (if (util/mobile?) 0 -20)} - :class (when-not (or sidebar? (util/capacitor?)) - "mt-4")} - (page-blocks-cp page (merge option {:sidebar? sidebar? - :container-id (:container-id state)}))])] + (if recycle-page? + (recycle/recycle-page page) + [:div.ls-page-blocks + {:style {:margin-left (if (util/mobile?) 0 -20)} + :class (when-not (or sidebar? (util/capacitor?)) + "mt-4")} + (page-blocks-cp page (merge option {:sidebar? sidebar? + :container-id (:container-id state)}))]))] - (when-not preview? + (when-not (or preview? recycle-page?) [:div.flex.flex-col.gap-8 {:class (when-not (util/mobile?) "ml-1")} (when today? diff --git a/src/main/frontend/components/query.cljs b/src/main/frontend/components/query.cljs index 8d5984b664..f5bc60abe3 100644 --- a/src/main/frontend/components/query.cljs +++ b/src/main/frontend/components/query.cljs @@ -146,7 +146,11 @@ ;; Remove hidden pages from result result (if (and (coll? result) (not (map? result))) (->> result - (remove (fn [b] (when (and (map? b) (:block/title b)) (ldb/hidden? (:block/title b))))) + (remove (fn [b] + (when (and (map? b) (:block/title b)) + (ldb/hidden? (or (when-let [id (:db/id b)] + (db/entity id)) + (:block/title b)))))) (remove (fn [b] (when (and current-block (:db/id current-block)) (= (:db/id b) (:db/id current-block)))))) result) diff --git a/src/main/frontend/components/recycle.cljs b/src/main/frontend/components/recycle.cljs new file mode 100644 index 0000000000..7c61d90695 --- /dev/null +++ b/src/main/frontend/components/recycle.cljs @@ -0,0 +1,112 @@ +(ns frontend.components.recycle + "Recycle page UI" + (:require [clojure.string :as string] + [datascript.core :as d] + [frontend.components.block :as component-block] + [frontend.db :as db] + [frontend.handler.editor :as editor-handler] + [frontend.handler.page :as page-handler] + [frontend.state :as state] + [logseq.db :as ldb] + [logseq.shui.ui :as shui] + [rum.core :as rum])) + +(defn- resolve-entity + [db value] + (cond + (and (map? value) (:db/id value)) value + (integer? value) (d/entity db value) + (vector? value) (d/entity db value) + :else nil)) + +(defn- user-initials + [user] + (let [name (or (:logseq.property.user/name user) + (:block/title user) + "U") + name (string/trim name)] + (subs name 0 (min 2 (count name))))) + +(defn- deleted-roots + [db] + (->> (d/q '[:find [?e ...] + :where + [?e :logseq.property/deleted-at]] + db) + (map #(d/entity db %)) + (sort-by :logseq.property/deleted-at #(compare %2 %1)))) + +(defn- group-title + [db root] + (if (ldb/page? root) + (:block/title root) + (or (:block/title (resolve-entity db (:logseq.property.recycle/original-page root))) + "Unknown page"))) + +(defn- deleted-by + [db root] + (resolve-entity db (:logseq.property/deleted-by-ref root))) + +(defn- deleted-by-avatar + [user] + (let [avatar-src (:logseq.property.user/avatar user)] + (shui/avatar + {:class "w-4 h-4"} + (when (seq avatar-src) + (shui/avatar-image {:src avatar-src})) + (shui/avatar-fallback (user-initials user))))) + +(defn- deleted-root-header + [db root] + (let [user (deleted-by db root) + deleted-at (:logseq.property/deleted-at root)] + [:div.flex.items-center.justify-between.gap-4.text-xs.text-muted-foreground + [:div.flex.items-center.gap-1.min-w-0.flex-1 + (deleted-by-avatar user) + [:div.min-w-0 + [:div.truncate + (str (if (ldb/page? root) "Page" "Block") + " deleted " + (.toLocaleString (js/Date. deleted-at)))]]] + (shui/button + {:variant :ghost + :size :xs + :class "!py-0 !px-1 h-4" + :on-click #(page-handler/restore-recycled! (:block/uuid root))} + "Restore")])) + +(defn- deleted-root-outliner + [root] + (component-block/block-container + {:view? true + :block? true + :publishing? true + :stop-events? true + :default-collapsed? (boolean (editor-handler/collapsable? (:block/uuid root) + {:semantic? true})) + :container-id (state/get-container-id [:recycle-root (:block/uuid root)]) + :id (str (:block/uuid root))} + root)) + +(rum/defc recycle-page + [_page] + (let [db* (db/get-db) + groups (->> (deleted-roots db*) + (group-by #(group-title db* %)) + (sort-by (fn [[_ roots]] + (:logseq.property/deleted-at (first roots))) + #(compare %2 %1)))] + [:div.flex.flex-col.gap-1 + [:div.text-sm.text-muted-foreground.mb-4 + "Deleted pages and blocks stay here until restored or automatically garbage collected after 60 days."] + (if (seq groups) + (for [[title roots] groups] + [:section {:key title} + (when-not (some ldb/page? roots) + [:h2.text-lg.font-medium.mb-3 title]) + [:div.flex.flex-col + (for [root roots] + [:div {:key (str (:block/uuid root))} + (deleted-root-header db* root) + (deleted-root-outliner root)])]]) + [:div.text-sm.text-muted-foreground "Recycle is empty."])])) diff --git a/src/main/frontend/handler/block.cljs b/src/main/frontend/handler/block.cljs index 4574b3f57f..579ff2b0c4 100644 --- a/src/main/frontend/handler/block.cljs +++ b/src/main/frontend/handler/block.cljs @@ -6,6 +6,7 @@ [frontend.db :as db] [frontend.db.async :as db-async] [frontend.db.model :as db-model] + [frontend.handler.notification :as notification] [frontend.handler.property.util :as pu] [frontend.mobile.haptics :as haptics] [frontend.modules.outliner.op :as outliner-op] @@ -149,29 +150,31 @@ :as opts}] (when (and (not config/publishing?) (:block/uuid block)) (let [repo (state/get-current-repo)] - (p/do! - (db-async/ tail-len 0) (>= (count content) tail-len)) - (subs content 0 (- (count content) tail-len)) + (and (> tail-len 0) (>= (count content) tail-len)) + (subs content 0 (- (count content) tail-len)) - (or (= :max pos) (<= content-length pos)) - content + (or (= :max pos) (<= content-length pos)) + content - :else - (subs content 0 pos))] - (state/clear-selection!) - (edit-block-aux repo block content text-range (assoc opts :pos pos)))))))) + :else + (subs content 0 pos))] + (state/clear-selection!) + (edit-block-aux repo block content text-range (assoc opts :pos pos)))))))))) (defn- get-original-block-by-dom [node] diff --git a/src/main/frontend/handler/editor.cljs b/src/main/frontend/handler/editor.cljs index cf9f72ae33..823e278dc2 100644 --- a/src/main/frontend/handler/editor.cljs +++ b/src/main/frontend/handler/editor.cljs @@ -541,71 +541,72 @@ (db/get-page page) (db/entity [:block/uuid block-uuid]))] (when block - (let [last-block (when (not sibling?) - (let [children (:block/_parent block) - blocks (db/sort-by-order children) - last-block-id (:db/id (last blocks))] - (when last-block-id - (db/entity last-block-id)))) - new-block (-> (select-keys block [:block/page]) - (assoc :block/title content)) - new-block (assoc new-block :block/page - (if page - (:db/id block) - (:db/id (:block/page new-block)))) - new-block (-> new-block - (wrap-parse-block) - (assoc :block/uuid (or custom-uuid (db/new-block-id)))) - new-block (merge new-block other-attrs) - block' (db/entity (:db/id block)) - [target-block sibling?] (cond - before? - (let [left-or-parent (or (ldb/get-left-sibling block) - (:block/parent block)) - sibling? (if (= (:db/id (:block/parent block)) (:db/id left-or-parent)) - false sibling?)] - [left-or-parent sibling?]) + (if (ldb/recycled? block) + (notification/show! "Recycle is read-only." :warning) + (let [last-block (when (not sibling?) + (let [children (:block/_parent block) + blocks (db/sort-by-order children) + last-block-id (:db/id (last blocks))] + (when last-block-id + (db/entity last-block-id)))) + new-block (-> (select-keys block [:block/page]) + (assoc :block/title content)) + new-block (assoc new-block :block/page + (if page + (:db/id block) + (:db/id (:block/page new-block)))) + new-block (-> new-block + (wrap-parse-block) + (assoc :block/uuid (or custom-uuid (db/new-block-id)))) + new-block (merge new-block other-attrs) + block' (db/entity (:db/id block)) + [target-block sibling?] (cond + before? + (let [left-or-parent (or (ldb/get-left-sibling block) + (:block/parent block)) + sibling? (if (= (:db/id (:block/parent block)) (:db/id left-or-parent)) + false sibling?)] + [left-or-parent sibling?]) - sibling? - [block' sibling?] + sibling? + [block' sibling?] - start? - [block' false] + start? + [block' false] - end? - (if last-block + end? + (if last-block + [last-block true] + [block' false]) + + last-block [last-block true] - [block' false]) - last-block - [last-block true] + block + [block' sibling?] - block - [block' sibling?] - - ;; FIXME: assert - :else - nil)] - (when target-block - (p/do! - (let [new-block' (if (seq properties) - (into new-block properties) - new-block)] - (ui-outliner-tx/transact! - {:outliner-op :insert-blocks} - (outliner-insert-block! config target-block new-block' - {:sibling? sibling? - :keep-uuid? true - :ordered-list? ordered-list? - :outliner-op outliner-op - :replace-empty-target? replace-empty-target?}))) - (when edit-block? - (if (and replace-empty-target? - (string/blank? (:block/title last-block))) - (edit-block! last-block :max) - (edit-block! new-block :max))) - (when-let [id (:block/uuid new-block)] - (db/entity [:block/uuid id]))))))))) + :else + nil)] + (when target-block + (p/do! + (let [new-block' (if (seq properties) + (into new-block properties) + new-block)] + (ui-outliner-tx/transact! + {:outliner-op :insert-blocks} + (outliner-insert-block! config target-block new-block' + {:sibling? sibling? + :keep-uuid? true + :ordered-list? ordered-list? + :outliner-op outliner-op + :replace-empty-target? replace-empty-target?}))) + (when edit-block? + (if (and replace-empty-target? + (string/blank? (:block/title last-block))) + (edit-block! last-block :max) + (edit-block! new-block :max))) + (when-let [id (:block/uuid new-block)] + (db/entity [:block/uuid id])))))))))) (defn get-selected-blocks [] @@ -797,9 +798,12 @@ (defn move-blocks! [blocks target opts] (when (seq blocks) - (ui-outliner-tx/transact! - {:outliner-op :move-blocks} - (outliner-op/move-blocks! blocks target opts)))) + (if (or (some ldb/recycled? blocks) + (ldb/recycled? target)) + (notification/show! "Recycle is read-only." :warning) + (ui-outliner-tx/transact! + {:outliner-op :move-blocks} + (outliner-op/move-blocks! blocks target opts))))) (defn move-selected-blocks [e] @@ -976,10 +980,11 @@ (let [repo (state/get-current-repo) block-uuids (distinct (keep #(when-let [id (dom/attr % "blockid")] (uuid id)) dom-blocks)) lookup-refs (map (fn [id] [:block/uuid id]) block-uuids) - blocks (map db/entity lookup-refs)] - (ui-outliner-tx/transact! - {:outliner-op :delete-blocks} - (let [top-level-blocks (block-handler/get-top-level-blocks blocks)] + blocks (map db/entity lookup-refs) + top-level-blocks (block-handler/get-top-level-blocks blocks)] + (when-not (every? ldb/recycled? top-level-blocks) + (ui-outliner-tx/transact! + {:outliner-op :delete-blocks} (when (seq top-level-blocks) (let [sorted-blocks (mapcat (fn [block] (tree/get-sorted-block-and-children repo (:db/id block))) @@ -1831,6 +1836,14 @@ (let [ids (set (map :db/id blocks))] (some? (some #(ids (:db/id (:block/parent %))) blocks)))) +(defn- unrecycle-tx-data + [root] + [[:db/retract (:db/id root) :logseq.property/deleted-at] + [:db/retract (:db/id root) :logseq.property/deleted-by-ref] + [:db/retract (:db/id root) :logseq.property.recycle/original-parent] + [:db/retract (:db/id root) :logseq.property.recycle/original-page] + [:db/retract (:db/id root) :logseq.property.recycle/original-order]]) + (defn paste-blocks "Given a vec of blocks, insert them into the target page. keep-uuid?: if true, keep the uuid provided in the block structure." @@ -1865,6 +1878,13 @@ (or (ldb/get-left-sibling target-block) (:block/parent (db/entity (:db/id target-block)))) target-block) + existing-blocks (keep (fn [block] + (when-let [id (:block/uuid block)] + (db/entity [:block/uuid id]))) + blocks) + move-from-recycle? (and keep-uuid? + (seq existing-blocks) + (every? ldb/recycled? existing-blocks)) sibling? (cond (and paste-nested-blocks? empty-target?) (= (:block/parent target-block') (:block/parent target-block)) @@ -1877,20 +1897,32 @@ :else true) - transact-blocks! #(ui-outliner-tx/transact! - {:outliner-op :insert-blocks - :additional-tx revert-cut-txs} - (when target-block' - (let [format (get target-block' :block/format :markdown) - repo (state/get-current-repo) - blocks' (map (fn [block] - (paste-block-cleanup repo block page exclude-properties format content-update-fn keep-uuid?)) - blocks)] - (outliner-op/insert-blocks! blocks' target-block' {:sibling? sibling? - :outliner-op :paste - :outliner-real-op outliner-real-op - :replace-empty-target? replace-empty-target? - :keep-uuid? keep-uuid?}))))] + transact-blocks! #(if move-from-recycle? + (ui-outliner-tx/transact! + {:outliner-op :move-blocks + :additional-tx revert-cut-txs} + (when target-block' + (let [top-level-blocks (block-handler/get-top-level-blocks existing-blocks) + unrecycle-tx (mapcat unrecycle-tx-data top-level-blocks)] + (when (seq unrecycle-tx) + (outliner-op/transact! unrecycle-tx nil)) + (outliner-op/move-blocks! top-level-blocks target-block' + {:sibling? sibling? + :outliner-op :paste})))) + (ui-outliner-tx/transact! + {:outliner-op :insert-blocks + :additional-tx revert-cut-txs} + (when target-block' + (let [format (get target-block' :block/format :markdown) + repo (state/get-current-repo) + blocks' (map (fn [block] + (paste-block-cleanup repo block page exclude-properties format content-update-fn keep-uuid?)) + blocks)] + (outliner-op/insert-blocks! blocks' target-block' {:sibling? sibling? + :outliner-op :paste + :outliner-real-op outliner-real-op + :replace-empty-target? replace-empty-target? + :keep-uuid? keep-uuid?})))))] (if ops-only? (transact-blocks!) (p/let [_ (when has-unsaved-edits diff --git a/src/main/frontend/handler/page.cljs b/src/main/frontend/handler/page.cljs index 9708eccb72..7e0002cd11 100644 --- a/src/main/frontend/handler/page.cljs +++ b/src/main/frontend/handler/page.cljs @@ -17,6 +17,7 @@ [frontend.handler.notification :as notification] [frontend.handler.plugin :as plugin-handler] [frontend.handler.property :as property-handler] + [frontend.handler.route :as route-handler] [frontend.modules.outliner.op :as outliner-op] [frontend.modules.outliner.ui :as ui-outliner-tx] [frontend.state :as state] @@ -31,11 +32,31 @@ [logseq.common.util.page-ref :as page-ref] [logseq.db :as ldb] [logseq.graph-parser.text :as text] + [logseq.outliner.recycle :as outliner-recycle] [promesa.core :as p])) (def (or opts {}) + (and (nil? (:deleted-by-uuid opts)) + (user-handler/user-uuid)) + (assoc :deleted-by-uuid (uuid (user-handler/user-uuid))))) (def ^:private ^:dynamic *outliner-ops* "Stores outliner ops that are generated by the following calls" @@ -32,7 +40,7 @@ (op-transact! (let [ids (map :db/id blocks)] (when (seq ids) - [:delete-blocks [ids opts]])))) + [:delete-blocks [ids (current-user-delete-opts opts)]])))) (defn move-blocks! [blocks target-block opts] @@ -144,6 +152,8 @@ [:rename-page [page-uuid new-name]])) (defn delete-page! - [page-uuid] - (op-transact! - [:delete-page [page-uuid]])) + ([page-uuid] + (delete-page! page-uuid {})) + ([page-uuid opts] + (op-transact! + [:delete-page [page-uuid (current-user-delete-opts opts)]]))) diff --git a/src/main/frontend/state.cljs b/src/main/frontend/state.cljs index 2298f508fe..9d27688707 100644 --- a/src/main/frontend/state.cljs +++ b/src/main/frontend/state.cljs @@ -1043,7 +1043,14 @@ Similar to re-frame subscriptions" (set-selection-blocks! blocks nil)) ([blocks direction] (when (seq blocks) - (let [blocks (vec (remove nil? blocks))] + (let [blocks (->> blocks + (remove nil?) + (remove (fn [block] + (when-let [id (some-> block (dom/attr "blockid"))] + (when-let [conn (db-conn-state/get-conn (get-current-repo))] + (when-let [entity (d/entity @conn [:block/uuid (uuid id)])] + (ldb/recycled? entity)))))) + vec)] (set-selection-blocks-aux! blocks) (when direction (set-state! :selection/direction direction)) (let [ids (get-selection-block-ids)] @@ -1652,6 +1659,7 @@ Similar to re-frame subscriptions" (if (and page ;; TODO: Use config/dev? when it's not a circular dep (not goog.DEBUG) + (not= common-config/recycle-page-name (:block/title page)) (or (and (ldb/hidden? page) (not (ldb/property? page))) (and (ldb/built-in? page) (ldb/private-built-in-page? page)))) (pub-event! [:notification/show {:content "Cannot open an internal page." :status :warning}]) diff --git a/src/main/frontend/undo_redo.cljs b/src/main/frontend/undo_redo.cljs index 2ec1c92ce6..eab713428e 100644 --- a/src/main/frontend/undo_redo.cljs +++ b/src/main/frontend/undo_redo.cljs @@ -1,13 +1,13 @@ (ns frontend.undo-redo "Undo redo new implementation" - (:require [clojure.set :as set] - [datascript.core :as d] + (:require [datascript.core :as d] [frontend.db :as db] [frontend.state :as state] [frontend.util :as util] [lambdaisland.glogi :as log] [logseq.common.defkeywords :refer [defkeywords]] [logseq.db :as ldb] + [logseq.outliner.recycle :as outliner-recycle] [malli.core :as m] [malli.util :as mu] [promesa.core :as p])) @@ -150,26 +150,6 @@ [repo] (empty? (get @*redo-ops repo))) -(defn- get-moved-blocks - [e->datoms] - (->> - (keep (fn [[e datoms]] - (when (some - (fn [k] - (and (some (fn [d] (and (= k (:a d)) (:added d))) datoms) - (some (fn [d] (and (= k (:a d)) (not (:added d)))) datoms))) - [:block/parent :block/order]) - e)) e->datoms) - (set))) - -(defn- other-children-exist? - "return true if there are other children existing(not included in `ids`)" - [entity ids] - (seq - (set/difference - (set (map :db/id (:block/_parent entity))) - ids))) - (defn- reverse-datoms [conn datoms schema added-ids retracted-ids undo? redo?] (keep @@ -185,156 +165,62 @@ [op e a v]))) datoms)) -(defn- block-moved-and-target-deleted? - [conn e->datoms e moved-blocks tx-data] - (let [datoms (get e->datoms e)] - (and (moved-blocks e) - (let [b (d/entity @conn e) - cur-parent (:db/id (:block/parent b)) - move-datoms (filter (fn [d] (contains? #{:block/parent} (:a d))) datoms)] - (when cur-parent - (let [before-parent (some (fn [d] (when (and (= :block/parent (:a d)) (not (:added d))) (:v d))) move-datoms) - not-exists-in-current-db (nil? (d/entity @conn before-parent)) - ;; reverse tx-data will add parent before back - removed-before-parent (some (fn [d] (and (= :block/uuid (:a d)) - (= before-parent (:e d)) - (not (:added d)))) tx-data)] - (and before-parent - not-exists-in-current-db - (not removed-before-parent)))))))) +(defn- reversed-move-target-ref + [datoms attr undo?] + (some (fn [{:keys [a v added]}] + (when (and (= a attr) + (if undo? (not added) added)) + v)) + datoms)) -(defn- tx-added-attrs - [tx-data] - (reduce (fn [acc [op e a v]] - (if (= :db/add op) - (update acc e assoc a v) - acc)) - {} - tx-data)) - -(defn- entity-exists-or-added? - [conn added-attrs id] - (or (contains? added-attrs id) - (some? (d/entity @conn id)))) - -(defn- assert-reversed-tx-safe! - [conn reversed-tx-data] - (let [added-attrs (tx-added-attrs reversed-tx-data) - ops-by-entity (group-by second reversed-tx-data)] - (doseq [[e ops] ops-by-entity] - (let [retract-entity? (some #(= :db/retractEntity (first %)) ops) - retract-parent? (some #(and (= :db/retract (first %)) - (= :block/parent (nth % 2))) - ops) - add-parent? (some #(and (= :db/add (first %)) - (= :block/parent (nth % 2))) - ops) - retract-page? (some #(and (= :db/retract (first %)) - (= :block/page (nth % 2))) - ops) - add-page? (some #(and (= :db/add (first %)) - (= :block/page (nth % 2))) - ops)] - ;; Moving blocks must not leave entities without parent/page refs. - (when (and (not retract-entity?) - retract-parent? - (not add-parent?)) - (throw (ex-info "Reversed tx retracts parent without replacement" - {:error :block-moved-or-target-deleted - :entity-id e - :ops ops}))) - (when (and (not retract-entity?) - retract-page? - (not add-page?)) - (throw (ex-info "Reversed tx retracts page without replacement" - {:error :block-moved-or-target-deleted - :entity-id e - :ops ops}))))) - (doseq [[e attrs] added-attrs] - (let [existing (d/entity @conn e) - new-entity? (nil? existing) - page? (or (:block/name attrs) (:block/name existing)) - parent (:block/parent attrs) - page (:block/page attrs)] - ;; Redoing a block creation must restore parent/page refs. - (when (and new-entity? - (not page?) - (not (contains? attrs :block/uuid))) - (throw (ex-info "Missing block identity in reversed tx" - {:error :block-moved-or-target-deleted - :entity-id e - :attrs attrs}))) - - (when (and new-entity? - (contains? attrs :block/uuid) - (not page?) - (nil? parent)) - (throw (ex-info "Missing block parent in reversed tx" - {:error :block-parent-missing - :entity-id e - :attrs attrs}))) - - (when (and parent - (not (entity-exists-or-added? conn added-attrs parent))) - (throw (ex-info "Parent deleted in reversed tx" - {:error :block-moved-or-target-deleted - :entity-id e - :parent-id parent - :attrs attrs}))) - - (when (and page - (not (entity-exists-or-added? conn added-attrs page))) - (throw (ex-info "Page deleted in reversed tx" - {:error :block-moved-or-target-deleted - :entity-id e - :page-id page - :attrs attrs}))))))) +(defn- reversed-move-conflicted? + [conn e->datoms undo?] + (some (fn [[_e datoms]] + (let [target-parent (reversed-move-target-ref datoms :block/parent undo?) + target-page (reversed-move-target-ref datoms :block/page undo?) + parent-ent (when (int? target-parent) (d/entity @conn target-parent)) + page-ent (when (int? target-page) (d/entity @conn target-page))] + (or (and target-parent + (or (nil? parent-ent) + (ldb/recycled? parent-ent))) + (and target-page + (or (nil? page-ent) + (ldb/recycled? page-ent)))))) + e->datoms)) (defn get-reversed-datoms - [conn undo? {:keys [tx-data added-ids retracted-ids] :as op} _tx-meta] - (try - (let [redo? (not undo?) - e->datoms (->> (if redo? tx-data (reverse tx-data)) - (group-by :e)) - schema (:schema @conn) - moved-blocks (get-moved-blocks e->datoms) - reversed-tx-data (->> (mapcat - (fn [[e datoms]] - (let [entity (d/entity @conn e)] + [conn undo? {:keys [tx-data added-ids retracted-ids]} tx-meta] + (let [recycle-restore-tx (when (and undo? + (= :delete-blocks (:outliner-op tx-meta))) + (->> tx-data + (keep (fn [{:keys [e a added]}] + (when (and added + (= :logseq.property/deleted-at a)) + (d/entity @conn e)))) + (mapcat #(outliner-recycle/restore-tx-data @conn %)) + seq)) + redo? (not undo?) + e->datoms (->> (if redo? tx-data (reverse tx-data)) + (group-by :e)) + schema (:schema @conn) + move-conflicted? (and (= :move-blocks (:outliner-op tx-meta)) + (reversed-move-conflicted? conn e->datoms undo?)) + reversed-tx-data (or (when move-conflicted? nil) + (some-> recycle-restore-tx reverse seq) + (->> (mapcat + (fn [[e datoms]] (cond - ;; New children may have been added after the original op. - (or (and (contains? retracted-ids e) redo? - (other-children-exist? entity retracted-ids)) ; redo delete-blocks - (and (contains? added-ids e) undo? - (other-children-exist? entity added-ids))) ; undo insert-blocks - (throw (ex-info "Children still exists" - (merge op {:error :block-children-exists - :undo? undo?}))) + (and undo? (contains? added-ids e)) + [[:db/retractEntity e]] - ;; Block has moved or target got deleted. - (block-moved-and-target-deleted? conn e->datoms e moved-blocks tx-data) - (throw (ex-info "This block has been moved or its target has been deleted" - (merge op {:error :block-moved-or-target-deleted - :undo? undo?}))) - - ;; Delete entity instead of retracting attrs one-by-one. - (and entity - (or (and (contains? retracted-ids e) redo?) ; redo delete-blocks - (and (contains? added-ids e) undo?))) ; undo insert-blocks + (and redo? (contains? retracted-ids e)) [[:db/retractEntity e]] :else - (reverse-datoms conn datoms schema added-ids retracted-ids undo? redo?)))) - e->datoms) - (remove nil?))] - (assert-reversed-tx-safe! conn reversed-tx-data) - reversed-tx-data) - (catch :default e - (when-not (contains? #{:block-moved-or-target-deleted - :block-children-exists - :block-parent-missing} - (:error (ex-data e))) - (throw e))))) + (reverse-datoms conn datoms schema added-ids retracted-ids undo? redo?))) + e->datoms) + (remove nil?)))] + reversed-tx-data)) (defn- undo-redo-aux [repo undo?] @@ -426,7 +312,7 @@ (let [{:keys [outliner-op local-tx?]} tx-meta] (when (and (= (:client-id tx-meta) (:client-id @state/state)) - local-tx? + (true? local-tx?) outliner-op (not (false? (:gen-undo-ops? tx-meta))) (not (:create-today-journal? tx-meta))) @@ -450,6 +336,8 @@ :retracted-ids retracted-ids}]] (remove nil?) vec)] + ;; A new local edit invalidates any redo history. + (swap! *redo-ops assoc repo []) (push-undo-op repo op))))) (defn listen-db-changes! diff --git a/src/main/frontend/worker/db/migrate.cljs b/src/main/frontend/worker/db/migrate.cljs index 83a0233c9f..563a655e28 100644 --- a/src/main/frontend/worker/db/migrate.cljs +++ b/src/main/frontend/worker/db/migrate.cljs @@ -76,7 +76,12 @@ ["65.21" {:properties [:logseq.property.sync/large-title-object]}] ["65.22" {:properties [:logseq.property.reaction/emoji-id :logseq.property.reaction/target]}] - ["65.23" {:properties [:logseq.property.asset/align]}]]) + ["65.23" {:properties [:logseq.property.asset/align]}] + ["65.24" {:properties [:logseq.property/deleted-at + :logseq.property/deleted-by-ref + :logseq.property.recycle/original-parent + :logseq.property.recycle/original-page + :logseq.property.recycle/original-order]}]]) (let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first) schema-version->updates)))] diff --git a/src/main/frontend/worker/db_worker.cljs b/src/main/frontend/worker/db_worker.cljs index 07d2c02762..26ede853e9 100644 --- a/src/main/frontend/worker/db_worker.cljs +++ b/src/main/frontend/worker/db_worker.cljs @@ -31,7 +31,6 @@ [frontend.worker.sync.crypt :as sync-crypt] [frontend.worker.sync.log-and-state :as rtc-log-and-state] [frontend.worker.thread-atom] - [frontend.worker.undo-redo :as undo-validate] [goog.object :as gobj] [lambdaisland.glogi :as log] [lambdaisland.glogi.console :as glogi-console] @@ -53,6 +52,7 @@ [logseq.db.sqlite.util :as sqlite-util] [logseq.outliner.core :as outliner-core] [logseq.outliner.op :as outliner-op] + [logseq.outliner.recycle :as outliner-recycle] [me.tonsky.persistent-sorted-set :as set :refer [BTSet]] [missionary.core :as m] [promesa.core :as p])) @@ -251,6 +251,20 @@ :kv/value (common-util/time-ms)}] {:skip-validate-db? true})))) +(def ^:private recycle-gc-kv :logseq.kv/recycle-last-gc-at) + +(defn- maybe-run-recycle-gc! + [conn] + (let [now (common-util/time-ms) + last-gc-at (:kv/value (d/entity @conn recycle-gc-kv))] + (when (or (not (number? last-gc-at)) + (> (- now last-gc-at) outliner-recycle/gc-interval-ms)) + (outliner-recycle/gc! conn {:now-ms now}) + (ldb/transact! conn [{:db/ident recycle-gc-kv + :kv/value now}] + {:persist-op? false + :skip-validate-db? true})))) + (defn- > (get-datoms) - (remove (fn [datom] (= 1 (:e datom)))))] + (remove (fn [datom] (= 1 (:e datom)))) + (remove (fn [datom] + (let [block (db/pull test-db '[*] (:e datom))] + (or (nil? (:block/parent block)) + (= "Recycle" (:block/title block)))))))] (if (seq datoms) (let [id (:e (gen/generate (gen/elements datoms))) block (db/pull test-db '[*] id)] diff --git a/src/test/frontend/undo_redo_test.cljs b/src/test/frontend/undo_redo_test.cljs index cf6f4b8edb..8a1fa4ac18 100644 --- a/src/test/frontend/undo_redo_test.cljs +++ b/src/test/frontend/undo_redo_test.cljs @@ -2,13 +2,16 @@ (:require [clojure.test :as t :refer [deftest is testing use-fixtures]] [datascript.core :as d] [frontend.db :as db] + [frontend.handler.editor :as editor] [frontend.modules.outliner.core-test :as outliner-test] [frontend.state :as state] [frontend.test.helper :as test-helper] [frontend.undo-redo :as undo-redo] [frontend.worker.db-listener :as worker-db-listener] [frontend.worker.undo-redo :as undo-validate] - [logseq.db :as ldb])) + [logseq.db :as ldb] + [logseq.outliner.core :as outliner-core] + [logseq.outliner.op :as outliner-op])) ;; TODO: random property ops test @@ -239,6 +242,25 @@ :local-tx? false}) (is (= :frontend.undo-redo/empty-undo-stack (undo-redo/undo test-db)))))) +(deftest single-op-apply-ops-preserves-local-tx-and-client-id-test + (testing "single local outliner ops should reach listeners with local/client metadata intact" + (let [conn (db/get-db test-db false) + {:keys [child-uuid]} (seed-page-parent-child!) + tx-meta* (atom nil)] + (d/listen! conn ::capture-tx-meta + (fn [{:keys [tx-meta]}] + (reset! tx-meta* tx-meta))) + (try + (outliner-op/apply-ops! conn + [[:save-block [{:block/uuid child-uuid + :block/title "single-op-save"} {}]]] + {:client-id (:client-id @state/state) + :local-tx? true}) + (is (= true (:local-tx? @tx-meta*))) + (is (= (:client-id @state/state) (:client-id @tx-meta*))) + (finally + (d/unlisten! conn ::capture-tx-meta)))))) + (deftest undo-conflict-clears-history-test (testing "undo clears history when reverse tx is unsafe" (undo-redo/clear-history! test-db) @@ -267,6 +289,124 @@ (is (not= :frontend.undo-redo/empty-redo-stack redo-result)) (is (= "local-1" (:block/title (d/entity @conn [:block/uuid child-uuid])))))))) +(deftest undo-insert-retracts-added-entity-cleanly-test + (testing "undoing a local insert retracts the inserted entity instead of leaving a partial shell" + (undo-redo/clear-history! test-db) + (let [conn (db/get-db test-db false) + {:keys [page-uuid]} (seed-page-parent-child!) + inserted-uuid (random-uuid)] + (d/transact! conn + [{:block/uuid inserted-uuid + :block/title "inserted" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid]}] + {:outliner-op :insert-blocks + :local-tx? true}) + (is (some? (d/entity @conn [:block/uuid inserted-uuid]))) + (let [undo-result (undo-redo/undo test-db)] + (is (not= :frontend.undo-redo/empty-undo-stack undo-result)) + (is (nil? (d/entity @conn [:block/uuid inserted-uuid]))))))) + +(deftest repeated-save-block-content-undo-redo-test + (testing "multiple saves on the same block undo and redo one step at a time" + (undo-redo/clear-history! test-db) + (let [conn (db/get-db test-db false) + {:keys [child-uuid]} (seed-page-parent-child!)] + (doseq [title ["v1" "v2" "v3"]] + (d/transact! conn + [[:db/add [:block/uuid child-uuid] :block/title title]] + {:outliner-op :save-block + :local-tx? true})) + (is (= "v3" (:block/title (d/entity @conn [:block/uuid child-uuid])))) + (undo-redo/undo test-db) + (is (= "v2" (:block/title (d/entity @conn [:block/uuid child-uuid])))) + (undo-redo/undo test-db) + (is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid])))) + (undo-redo/undo test-db) + (is (= "child" (:block/title (d/entity @conn [:block/uuid child-uuid])))) + (undo-redo/redo test-db) + (is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid])))) + (undo-redo/redo test-db) + (is (= "v2" (:block/title (d/entity @conn [:block/uuid child-uuid])))) + (undo-redo/redo test-db) + (is (= "v3" (:block/title (d/entity @conn [:block/uuid child-uuid]))))))) + +(deftest repeated-editor-save-block-content-undo-redo-test + (testing "editor/save-block! records sequential content saves in order" + (undo-redo/clear-history! test-db) + (let [conn (db/get-db test-db false) + {:keys [child-uuid]} (seed-page-parent-child!)] + (doseq [title ["foo" "foo bar"]] + (editor/save-block! test-db child-uuid title)) + (is (= "foo bar" (:block/title (d/entity @conn [:block/uuid child-uuid])))) + (undo-redo/undo test-db) + (is (= "foo" (:block/title (d/entity @conn [:block/uuid child-uuid])))) + (undo-redo/redo test-db) + (is (= "foo bar" (:block/title (d/entity @conn [:block/uuid child-uuid]))))))) + +(deftest editor-save-two-blocks-undo-targets-latest-block-test + (testing "undo after saving two different blocks reverts the latest saved block first" + (undo-redo/clear-history! test-db) + (let [conn (db/get-db test-db false) + {:keys [parent-uuid child-uuid]} (seed-page-parent-child!)] + (editor/save-block! test-db parent-uuid "parent updated") + (editor/save-block! test-db child-uuid "child updated") + (undo-redo/undo test-db) + (is (= "parent updated" (:block/title (d/entity @conn [:block/uuid parent-uuid])))) + (is (= "child" (:block/title (d/entity @conn [:block/uuid child-uuid])))) + (undo-redo/undo test-db) + (is (= "parent" (:block/title (d/entity @conn [:block/uuid parent-uuid]))))))) + +(deftest new-local-save-clears-redo-stack-test + (testing "a new local save clears redo history" + (undo-redo/clear-history! test-db) + (let [conn (db/get-db test-db false) + {:keys [child-uuid]} (seed-page-parent-child!)] + (editor/save-block! test-db child-uuid "v1") + (editor/save-block! test-db child-uuid "v2") + (undo-redo/undo test-db) + (is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid])))) + (editor/save-block! test-db child-uuid "v3") + (is (= :frontend.undo-redo/empty-redo-stack (undo-redo/redo test-db))) + (is (= "v3" (:block/title (d/entity @conn [:block/uuid child-uuid]))))))) + +(deftest insert-save-delete-sequence-undo-redo-test + (testing "insert then save then recycle-delete can be undone and redone in order" + (undo-redo/clear-history! test-db) + (let [conn (db/get-db test-db false) + {:keys [page-uuid]} (seed-page-parent-child!) + inserted-uuid (random-uuid) + recycle-title "Recycle"] + (d/transact! conn + [{:block/uuid inserted-uuid + :block/title "draft" + :block/page [:block/uuid page-uuid] + :block/parent [:block/uuid page-uuid]}] + {:outliner-op :insert-blocks + :local-tx? true}) + (d/transact! conn + [[:db/add [:block/uuid inserted-uuid] :block/title "published"]] + {:outliner-op :save-block + :local-tx? true}) + (outliner-core/delete-blocks! conn [(d/entity @conn [:block/uuid inserted-uuid])] {}) + (is (= recycle-title + (:block/title (:block/page (d/entity @conn [:block/uuid inserted-uuid]))))) + (undo-redo/undo test-db) + (let [restored (d/entity @conn [:block/uuid inserted-uuid])] + (is (= page-uuid (:block/uuid (:block/page restored)))) + (is (= "published" (:block/title restored)))) + (undo-redo/undo test-db) + (is (= "draft" (:block/title (d/entity @conn [:block/uuid inserted-uuid])))) + (undo-redo/undo test-db) + (is (nil? (d/entity @conn [:block/uuid inserted-uuid]))) + (undo-redo/redo test-db) + (is (= "draft" (:block/title (d/entity @conn [:block/uuid inserted-uuid])))) + (undo-redo/redo test-db) + (is (= "published" (:block/title (d/entity @conn [:block/uuid inserted-uuid])))) + (undo-redo/redo test-db) + (is (= recycle-title + (:block/title (:block/page (d/entity @conn [:block/uuid inserted-uuid])))))))) + (deftest undo-works-with-remote-updates-test (testing "undo works after remote updates on sync graphs" (undo-redo/clear-history! test-db) @@ -284,6 +424,27 @@ (is (not= :frontend.undo-redo/empty-undo-stack undo-result)) (is (= "child" (:block/title (d/entity @conn [:block/uuid child-uuid])))))))) +(deftest undo-redo-works-for-recycle-delete-test + (testing "undo restores a recycled delete and redo sends it back to recycle" + (undo-redo/clear-history! test-db) + (let [conn (db/get-db test-db false) + {:keys [child-uuid page-uuid]} (seed-page-parent-child!) + recycle-page-title "Recycle"] + (outliner-core/delete-blocks! conn [(d/entity @conn [:block/uuid child-uuid])] {}) + (let [deleted-child (d/entity @conn [:block/uuid child-uuid])] + (is (integer? (:logseq.property/deleted-at deleted-child))) + (is (= recycle-page-title (:block/title (:block/page deleted-child))))) + (let [undo-result (undo-redo/undo test-db) + restored-child (d/entity @conn [:block/uuid child-uuid])] + (is (not= :frontend.undo-redo/empty-undo-stack undo-result)) + (is (= page-uuid (:block/uuid (:block/page restored-child)))) + (is (nil? (:logseq.property/deleted-at restored-child)))) + (let [redo-result (undo-redo/redo test-db) + recycled-child (d/entity @conn [:block/uuid child-uuid])] + (is (not= :frontend.undo-redo/empty-redo-stack redo-result)) + (is (= recycle-page-title (:block/title (:block/page recycled-child)))) + (is (integer? (:logseq.property/deleted-at recycled-child))))))) + (deftest undo-validation-allows-baseline-issues-test (testing "undo validation allows existing issues without introducing new ones" (let [conn (db/get-db test-db false) @@ -349,7 +510,7 @@ (is (= page-uuid (:block/uuid (:block/parent child)))))))) (deftest undo-skips-conflicted-move-and-keeps-earlier-history-test - (testing "undo drops a conflicting move op but still undoes earlier safe ops" + (testing "undo fails closed on a conflicting move and keeps db valid" (undo-redo/clear-history! test-db) (let [conn (db/get-db test-db false) {:keys [parent-a-uuid parent-b-uuid child-uuid]} (seed-page-two-parents-child!)] @@ -362,18 +523,16 @@ {:outliner-op :move-blocks :local-tx? true}) (d/transact! conn - [[:db/retractEntity [:block/uuid parent-a-uuid]]] + (:tx-data (outliner-core/delete-blocks @conn [(d/entity @conn [:block/uuid parent-a-uuid])] {})) {:outliner-op :delete-blocks :local-tx? false}) (let [undo-result (undo-redo/undo test-db) child (d/entity @conn [:block/uuid child-uuid])] - (is (not= :frontend.undo-redo/empty-undo-stack undo-result)) - (is (= "child" (:block/title child))) + (is (= :frontend.undo-redo/empty-undo-stack undo-result)) + (is (= "local-title" (:block/title child))) (is (= parent-b-uuid (:block/uuid (:block/parent child)))) - (is (empty? (db-issues @conn)))) - (is (= :frontend.undo-redo/empty-undo-stack - (undo-redo/undo test-db)))))) + (is (empty? (db-issues @conn))))))) (deftest undo-validation-fast-path-skips-db-issues-for-non-structural-tx-test (testing "undo validation skips db-issues for non-structural tx-data" @@ -398,8 +557,8 @@ [[:db/add [:block/uuid child-uuid] :block/parent [:block/uuid page-uuid]]]))) (is (pos? @calls)))))) -(deftest redo-skips-when-target-parent-deleted-test - (testing "redo skips move-blocks when target parent was deleted remotely" +(deftest redo-builds-reversed-tx-when-target-parent-is-recycled-test + (testing "redo still builds reversed tx from raw datoms when target parent was recycled remotely" (undo-redo/clear-history! test-db) (let [conn (db/get-db test-db false) {:keys [child-uuid parent-a-uuid parent-b-uuid]} (seed-page-two-parents-child!)] @@ -409,7 +568,7 @@ :local-tx? true}) (undo-redo/undo test-db) (d/transact! conn - [[:db/retractEntity [:block/uuid parent-b-uuid]]] + (:tx-data (outliner-core/delete-blocks @conn [(d/entity @conn [:block/uuid parent-b-uuid])] {})) {:outliner-op :delete-blocks :local-tx? false}) (let [redo-op (last (get @undo-redo/*redo-ops test-db)) @@ -417,7 +576,7 @@ (second %)) redo-op) reversed (undo-redo/get-reversed-datoms conn false data (:tx-meta data))] - (is (nil? reversed)) + (is (seq reversed)) (is (= parent-a-uuid (:block/uuid (:block/parent (d/entity @conn [:block/uuid child-uuid]))))))))) diff --git a/src/test/frontend/worker/search_test.cljs b/src/test/frontend/worker/search_test.cljs index 50fa52d22f..f6ecf6c539 100644 --- a/src/test/frontend/worker/search_test.cljs +++ b/src/test/frontend/worker/search_test.cljs @@ -182,6 +182,13 @@ ldb/class-instance? (fn [_ _] true)] (is (false? (#'search/code-block? :code-class {:logseq.property.node/display-type :code})))))) +(deftest hidden-entity-includes-recycled-entities + (testing "recycled roots are hidden" + (is (true? (#'search/hidden-entity? {:logseq.property/deleted-at 1})))) + + (testing "entities on recycled pages are hidden" + (is (true? (#'search/hidden-entity? {:block/page {:logseq.property/deleted-at 1}}))))) + (deftest search-blocks-aux-bind-count (testing "namespace match SQL keeps bind count aligned" (let [sql "select id, page, title, rank from blocks_fts where title match ? or title match ? order by rank limit ?"