فهرست منبع

feat: recycle

Tienson Qin 1 ماه پیش
والد
کامیت
174cdfd865
37فایلهای تغییر یافته به همراه1160 افزوده شده و 417 حذف شده
  1. 1 0
      deps/common/src/logseq/common/config.cljs
  2. 1 0
      deps/db/src/logseq/db.cljs
  3. 3 2
      deps/db/src/logseq/db/common/initial_data.cljs
  4. 2 1
      deps/db/src/logseq/db/common/view.cljs
  5. 28 6
      deps/db/src/logseq/db/frontend/entity_util.cljs
  6. 2 1
      deps/db/src/logseq/db/frontend/malli_schema.cljs
  7. 20 0
      deps/db/src/logseq/db/frontend/property.cljs
  8. 1 1
      deps/db/src/logseq/db/frontend/schema.cljs
  9. 11 1
      deps/db/src/logseq/db/sqlite/create_graph.cljs
  10. 9 7
      deps/outliner/src/logseq/outliner/core.cljs
  11. 5 7
      deps/outliner/src/logseq/outliner/op.cljs
  12. 16 34
      deps/outliner/src/logseq/outliner/page.cljs
  13. 2 2
      deps/outliner/src/logseq/outliner/property.cljs
  14. 256 0
      deps/outliner/src/logseq/outliner/recycle.cljs
  15. 42 7
      deps/outliner/test/logseq/outliner/core_test.cljs
  16. 8 2
      deps/outliner/test/logseq/outliner/page_test.cljs
  17. 5 1
      deps/outliner/test/logseq/outliner/property_test.cljs
  18. 75 0
      deps/outliner/test/logseq/outliner/recycle_test.cljs
  19. 6 0
      src/main/frontend/components/header.cljs
  20. 13 8
      src/main/frontend/components/page.cljs
  21. 5 1
      src/main/frontend/components/query.cljs
  22. 112 0
      src/main/frontend/components/recycle.cljs
  23. 23 20
      src/main/frontend/handler/block.cljs
  24. 116 84
      src/main/frontend/handler/editor.cljs
  25. 21 0
      src/main/frontend/handler/page.cljs
  26. 1 0
      src/main/frontend/handler/route.cljs
  27. 15 5
      src/main/frontend/modules/outliner/op.cljs
  28. 9 1
      src/main/frontend/state.cljs
  29. 55 167
      src/main/frontend/undo_redo.cljs
  30. 6 1
      src/main/frontend/worker/db/migrate.cljs
  31. 22 12
      src/main/frontend/worker/db_worker.cljs
  32. 14 12
      src/main/frontend/worker/sync.cljs
  33. 47 20
      src/main/frontend/worker/undo_redo.cljs
  34. 25 1
      src/test/frontend/handler/editor_test.cljs
  35. 5 1
      src/test/frontend/modules/outliner/core_test.cljs
  36. 171 12
      src/test/frontend/undo_redo_test.cljs
  37. 7 0
      src/test/frontend/worker/search_test.cljs

+ 1 - 0
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]

+ 1 - 0
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?)

+ 3 - 2
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

+ 2 - 1
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)))))))

+ 28 - 6
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]

+ 2 - 1
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)

+ 20 - 0
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

+ 1 - 1
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.

+ 11 - 1
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)))

+ 9 - 7
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)))

+ 5 - 7
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))

+ 16 - 34
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)

+ 2 - 2
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)))))

+ 256 - 0
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))

+ 42 - 7
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'))))))

+ 8 - 2
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)

+ 5 - 1
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

+ 75 - 0
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)])))))

+ 6 - 0
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)}

+ 13 - 8
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)}))])]
-
-         (when-not preview?
+            (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 (or preview? recycle-page?)
            [:div.flex.flex-col.gap-8
             {:class (when-not (util/mobile?) "ml-1")}
             (when today?

+ 5 - 1
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)

+ 112 - 0
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."])]))

+ 23 - 20
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/<get-block repo (:db/id block) {:children? false})
-       (when save-code-editor? (state/pub-event! [:editor/save-code-editor]))
-       (when (not= (:block/uuid block) (:block/uuid (state/get-edit-block)))
-         (state/clear-edit! {:clear-editing-block? false}))
-       (when-let [block-id (:block/uuid block)]
-         (let [block (or (db/entity [:block/uuid block-id]) block)
-               content (or custom-content (:block/title block) "")
-               content-length (count content)
-               text-range (cond
-                            (vector? pos)
-                            (text-range-by-lst-fst-line content pos)
+      (when-let [block-id (:block/uuid block)]
+        (let [block (or (db/entity [:block/uuid block-id]) block)]
+          (if (ldb/recycled? block)
+            (notification/show! "Recycle is read-only." :warning)
+            (p/do!
+             (db-async/<get-block repo (:db/id block) {:children? false})
+             (when save-code-editor? (state/pub-event! [:editor/save-code-editor]))
+             (when (not= (:block/uuid block) (:block/uuid (state/get-edit-block)))
+               (state/clear-edit! {:clear-editing-block? false}))
+             (let [content (or custom-content (:block/title block) "")
+                   content-length (count content)
+                   text-range (cond
+                                (vector? pos)
+                                (text-range-by-lst-fst-line content pos)
 
-                            (and (> 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]

+ 116 - 84
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?])
-
-                                        sibling?
-                                        [block' sibling?]
-
-                                        start?
-                                        [block' false]
-
-                                        end?
-                                        (if last-block
+        (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?]
+
+                                          start?
+                                          [block' false]
+
+                                          end?
+                                          (if last-block
+                                            [last-block true]
+                                            [block' false])
+
+                                          last-block
                                           [last-block true]
-                                          [block' false])
 
-                                        last-block
-                                        [last-block true]
-
-                                        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])))))))))
+                                          block
+                                          [block' sibling?]
+
+                                          :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

+ 21 - 0
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 <create! page-common-handler/<create!)
 (def <delete! page-common-handler/<delete!)
 
+(defn get-recycle-page
+  []
+  (db/get-page common-config/recycle-page-name))
+
+(defn open-recycle!
+  []
+  (when-let [page (get-recycle-page)]
+    (route-handler/redirect-to-page! (:block/uuid page))))
+
+(defn restore-recycled!
+  [root-uuid]
+  (when-let [root (db/entity [:block/uuid root-uuid])]
+    (when-let [tx-data (seq (outliner-recycle/restore-tx-data (db/get-db) root))]
+      (p/do!
+       (ui-outliner-tx/transact!
+        {:outliner-op :restore-recycled}
+        (outliner-op/transact! tx-data nil))
+       true))))
+
 (defn <unfavorite-page!
   [page-name]
   (p/do!

+ 1 - 0
src/main/frontend/handler/route.cljs

@@ -81,6 +81,7 @@
              (and (string? page-name) (not (string/blank? page-name))))
      (let [page (db/get-page page-name)]
        (if (and (not config/dev?)
+                (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))))
          (notification/show! "Cannot go to an internal page." :warning)

+ 15 - 5
src/main/frontend/modules/outliner/op.cljs

@@ -1,6 +1,14 @@
 (ns frontend.modules.outliner.op
   "Build outliner ops"
-  (:require [datascript.impl.entity :as de]))
+  (:require [datascript.impl.entity :as de]
+            [frontend.handler.user :as user-handler]))
+
+(defn- current-user-delete-opts
+  [opts]
+  (cond-> (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)]])))

+ 9 - 1
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}])

+ 55 - 167
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- 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-target-ref
+  [datoms attr undo?]
+  (some (fn [{:keys [a v added]}]
+          (when (and (= a attr)
+                     (if undo? (not added) added))
+            v))
+        datoms))
+
+(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?})))
-
-                                         ;; 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 undo? (contains? added-ids e))
+                                       [[:db/retractEntity e]]
+
+                                       (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!

+ 6 - 1
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)))]

+ 22 - 12
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- <create-or-open-db!
   [repo {:keys [config datoms sync-download-graph?] :as opts}]
   (when-not (worker-state/get-sqlite-conn repo)
@@ -309,7 +323,8 @@
                                                    {:initial-db? true})))]
           (when-not sync-download-graph?
             (db-migrate/migrate conn)
-            (gc-sqlite-dbs! db client-ops-db conn {}))
+            (gc-sqlite-dbs! db client-ops-db conn {})
+            (maybe-run-recycle-gc! conn))
 
           (when initial-tx-report
             (db-sync/handle-local-tx! repo initial-tx-report))
@@ -604,14 +619,9 @@
 
           ;; (prn :debug :transact :tx-data tx-data' :tx-meta tx-meta')
 
-          (when (and (or (:undo? tx-meta) (:redo? tx-meta))
-                     (not (undo-validate/valid-undo-redo-tx? conn tx-data')))
-            (throw (ex-info "undo/redo tx invalid"
-                            {:repo repo
-                             :undo? (:undo? tx-meta)
-                             :redo? (:redo? tx-meta)})))
           (worker-util/profile "Worker db transact"
                                (ldb/transact! conn tx-data' tx-meta')))
+        (maybe-run-recycle-gc! conn)
         nil)
       (catch :default e
         (prn :debug :worker-transact-failed :tx-meta tx-meta :tx-data tx-data)
@@ -1093,11 +1103,11 @@
       (p/all (map #(.unsafeUnlinkDB this (:name %)) dbs)))))
 
 (defn- delete-page!
-  [conn page-uuid]
+  [conn page-uuid opts]
   (let [error-handler (fn [{:keys [msg]}]
                         (worker-util/post-message :notification
                                                   [[:div [:p msg]] :error]))]
-    (worker-page/delete! conn page-uuid {:error-handler error-handler})))
+    (worker-page/delete! conn page-uuid (merge opts {:error-handler error-handler}))))
 
 (defn- create-page!
   [conn title options]
@@ -1119,8 +1129,8 @@
                      (outliner-core/save-block! conn
                                                 {:block/uuid page-uuid
                                                  :block/title new-title})))
-    :delete-page (fn [conn [page-uuid]]
-                   (delete-page! conn page-uuid))}))
+    :delete-page (fn [conn [page-uuid opts]]
+                   (delete-page! conn page-uuid opts))}))
 
 (defn- on-become-master
   [repo start-opts]

+ 14 - 12
src/main/frontend/worker/sync.cljs

@@ -1641,11 +1641,11 @@
         remote-tx-data (mapcat :tx-data remote-results)
         remote-tx-report (combine-tx-reports (map :report remote-results))
         _ (reset! *remote-tx-report remote-tx-report)
-        deleted-context (combine-deleted-contexts
-                         (local-deleted-context reversed-tx-reports)
-                         (remote-deleted-context remote-tx-report remote-tx-data))
+        deleted-context-data (combine-deleted-contexts
+                              (local-deleted-context reversed-tx-reports)
+                              (remote-deleted-context remote-tx-report remote-tx-data))
         remote-db @temp-conn]
-    {:deleted-context deleted-context
+    {:deleted-context deleted-context-data
      :remote-db remote-db
      :remote-results remote-results
      :remote-tx-data remote-tx-data
@@ -1654,13 +1654,14 @@
      :remote-updated-keys (remote-updated-attr-keys remote-db remote-tx-data)}))
 
 (defn- rebase-remote-state!
-  [{:keys [temp-conn local-txs tx-meta deleted-context remote-db remote-tx-data-set remote-updated-keys]}]
+  [{:keys [temp-conn local-txs tx-meta remote-db remote-tx-data-set remote-updated-keys]
+    :as remote-state}]
   (let [rebase-tx-reports (rebase-local-txs! temp-conn
                                              local-txs
                                              remote-db
                                              remote-updated-keys
                                              remote-tx-data-set
-                                             (:deleted-block? deleted-context)
+                                             (:deleted-block? (:deleted-context remote-state))
                                              tx-meta)]
     {:rebase-tx-report (combine-tx-reports rebase-tx-reports)
      :rebase-tx-reports rebase-tx-reports}))
@@ -1668,17 +1669,18 @@
 (declare fix-tx! delete-nodes!)
 
 (defn- delete-context-nodes!
-  [temp-conn deleted-context tx-meta]
+  [temp-conn deleted-context-data tx-meta]
   (let [db @temp-conn
         deleted-nodes (keep (fn [id] (d/entity db [:block/uuid id]))
-                            (:deleted-uuids deleted-context))]
+                            (:deleted-uuids deleted-context-data))]
     (delete-nodes! temp-conn deleted-nodes tx-meta)))
 
 (defn- finalize-remote-state!
-  [{:keys [temp-conn tx-meta deleted-context remote-tx-report rebase-tx-report *temp-after-db]}]
+  [{:keys [temp-conn tx-meta remote-tx-report rebase-tx-report *temp-after-db]
+    :as remote-state}]
   (reset! *temp-after-db @temp-conn)
   (fix-tx! temp-conn remote-tx-report rebase-tx-report (assoc tx-meta :op :fix))
-  (delete-context-nodes! temp-conn deleted-context (assoc tx-meta :op :delete-blocks)))
+  (delete-context-nodes! temp-conn (:deleted-context remote-state) (assoc tx-meta :op :delete-blocks)))
 
 (defn- normalize-rebased-pending-tx
   [{:keys [db-before db-after tx-data remote-tx-data-set keep-local-retract-entity?]}]
@@ -1813,8 +1815,8 @@
            remote-tx-report (combine-tx-reports (map :report remote-results))]
        (when remote-tx-report
          (let [tx-meta (:tx-meta remote-tx-report)
-               deleted-context (remote-deleted-context remote-tx-report remote-tx-data)]
-           (delete-context-nodes! temp-conn deleted-context
+               deleted-context-data (remote-deleted-context remote-tx-report remote-tx-data)]
+           (delete-context-nodes! temp-conn deleted-context-data
                                   (assoc tx-meta :op :delete-blocks))))))))
 
 (defn- apply-remote-txs!

+ 47 - 20
src/main/frontend/worker/undo_redo.cljs

@@ -11,6 +11,31 @@
 (def ^:private ref-attrs
   #{:block/parent :block/page})
 
+(def ^:private recycle-attrs
+  #{:logseq.property/deleted-at
+    :logseq.property/deleted-by-ref
+    :logseq.property.recycle/original-parent
+    :logseq.property.recycle/original-page
+    :logseq.property.recycle/original-order})
+
+(defn- recycle-tx-item?
+  [item]
+  (cond
+    (map? item)
+    (some recycle-attrs (keys item))
+
+    (vector? item)
+    (contains? recycle-attrs (nth item 2 nil))
+
+    (d/datom? item)
+    (contains? recycle-attrs (:a item))
+
+    :else false))
+
+(defn- recycle-tx?
+  [tx-data]
+  (boolean (some recycle-tx-item? tx-data)))
+
 (defn- structural-tx-item?
   [item]
   (cond
@@ -175,26 +200,28 @@
 (defn valid-undo-redo-tx?
   [conn tx-data]
   (try
-    (if-not (structural-tx? tx-data)
-      (if (entities-exist? @conn tx-data)
-        true
-        (do
-          (log/warn ::undo-redo-invalid {:reason :missing-entities})
-          false))
-      (let [db-before @conn
-            tx-report (d/with db-before tx-data)
-            db-after (:db-after tx-report)
-            affected-ids (affected-entity-ids db-before tx-report tx-data)
-            baseline-issues (if (seq affected-ids)
-                              (set (issues-for-entity-ids db-before affected-ids))
-                              #{})
-            after-issues (if (seq affected-ids)
-                           (set (issues-for-entity-ids db-after affected-ids))
-                           #{})
-            new-issues (seq (set/difference after-issues baseline-issues))]
-        (when (seq new-issues)
-          (log/warn ::undo-redo-invalid {:issues (take 5 new-issues)}))
-        (empty? new-issues)))
+    (if (recycle-tx? tx-data)
+      true
+      (if-not (structural-tx? tx-data)
+        (if (entities-exist? @conn tx-data)
+          true
+          (do
+            (log/warn ::undo-redo-invalid {:reason :missing-entities})
+            false))
+        (let [db-before @conn
+              tx-report (d/with db-before tx-data)
+              db-after (:db-after tx-report)
+              affected-ids (affected-entity-ids db-before tx-report tx-data)
+              baseline-issues (if (seq affected-ids)
+                                (set (issues-for-entity-ids db-before affected-ids))
+                                #{})
+              after-issues (if (seq affected-ids)
+                             (set (issues-for-entity-ids db-after affected-ids))
+                             #{})
+              new-issues (seq (set/difference after-issues baseline-issues))]
+          (when (seq new-issues)
+            (log/warn ::undo-redo-invalid {:issues (take 5 new-issues)}))
+          (empty? new-issues))))
     (catch :default e
       (log/error ::undo-redo-validate-failed e)
       false)))

+ 25 - 1
src/test/frontend/handler/editor_test.cljs

@@ -5,7 +5,8 @@
             [frontend.handler.editor :as editor]
             [frontend.state :as state]
             [frontend.test.helper :as test-helper]
-            [frontend.util.cursor :as cursor]))
+            [frontend.util.cursor :as cursor]
+            [logseq.outliner.core :as outliner-core]))
 
 (use-fixtures :each test-helper/start-and-destroy-db)
 
@@ -207,3 +208,26 @@
 
       (editor/save-block! repo block-uuid "# bar")
       (is (= "bar" (:block/title (model/query-block-by-uuid block-uuid)))))))
+
+(deftest paste-cut-recycled-block-moves-existing-node-out-of-recycle
+  (test-helper/load-test-files [{:page {:block/title "Page 1"}
+                                 :blocks [{:block/title "source"}]}
+                                {:page {:block/title "Page 2"}
+                                 :blocks [{:block/title "target"}]}])
+  (let [source (test-helper/find-block-by-content "source")
+        target (test-helper/find-block-by-content "target")
+        recycle-page (db/get-page "Recycle")]
+    (outliner-core/delete-blocks! (db/get-db test-helper/test-db false) [source] {})
+    (state/set-block-op-type! :cut)
+    (editor/paste-blocks [{:block/uuid (:block/uuid source)
+                           :block/title "source"}]
+                         {:target-block target
+                          :sibling? true
+                          :keep-uuid? true
+                          :ops-only? true})
+    (let [source' (db/entity [:block/uuid (:block/uuid source)])]
+      (is (= (:db/id (:block/page target)) (:db/id (:block/page source'))))
+      (is (= (:db/id (:block/parent target)) (:db/id (:block/parent source'))))
+      (is (nil? (:logseq.property/deleted-at source')))
+      (is (nil? (:logseq.property.recycle/original-page source')))
+      (is (not= (:db/id recycle-page) (:db/id (:block/page source')))))))

+ 5 - 1
src/test/frontend/modules/outliner/core_test.cljs

@@ -630,7 +630,11 @@
 (defn get-random-block
   []
   (let [datoms (->> (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)]

+ 171 - 12
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])))))))))
 

+ 7 - 0
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 ?"