Преглед изворни кода

enhance(recycle): permanently delete recycled roots with sync-safe replay

Tienson Qin пре 3 дана
родитељ
комит
801620b901

+ 10 - 0
deps/outliner/src/logseq/outliner/op.cljs

@@ -8,6 +8,7 @@
             [logseq.outliner.core :as outliner-core]
             [logseq.outliner.page :as outliner-page]
             [logseq.outliner.property :as outliner-property]
+            [logseq.outliner.recycle :as outliner-recycle]
             [logseq.outliner.transaction :as outliner-tx]
             [malli.core :as m]))
 
@@ -128,6 +129,11 @@
      [:op :keyword]
      [:args [:tuple ::uuid ::option]]]]
 
+   [:recycle-delete-permanently
+    [:catn
+     [:op :keyword]
+     [:args [:tuple ::uuid]]]]
+
    [:toggle-reaction
     [:catn
      [:op :keyword]
@@ -346,6 +352,10 @@
     (let [[page-uuid opts] args]
       (outliner-page/delete! conn page-uuid (merge opts opts')))
 
+    :recycle-delete-permanently
+    (let [[root-uuid] args]
+      (outliner-recycle/permanently-delete! conn root-uuid))
+
     :toggle-reaction
     (reset! *result (apply toggle-reaction! conn args))
     nil))

+ 5 - 0
deps/outliner/src/logseq/outliner/op/construct.cljc

@@ -22,6 +22,7 @@
     :rename-page
     :delete-page
     :restore-recycled
+    :recycle-delete-permanently
     :set-block-property
     :remove-block-property
     :batch-set-property
@@ -441,6 +442,10 @@
     (let [[root-id] args]
       [:restore-recycled [root-id]])
 
+    :recycle-delete-permanently
+    (let [[root-id] args]
+      [:recycle-delete-permanently [(stable-entity-ref db root-id)]])
+
     :set-block-property
     (let [[block-eid property-id v] args]
       [:set-block-property [(stable-entity-ref db block-eid)

+ 20 - 0
deps/outliner/src/logseq/outliner/recycle.cljs

@@ -229,6 +229,26 @@
       (ldb/transact! conn tx-data {:outliner-op :restore-recycled})
       true)))
 
+(defn ^:api permanently-delete-tx-data
+  [db root]
+  (when (and root (recycled? root))
+    (->> (if (ldb/page? root)
+           (keep (fn [id]
+                   (some-> (d/entity db id) :block/uuid))
+                 (page-tree-ids db root))
+           (keep :block/uuid (block-subtree db root)))
+         (map (fn [block-uuid]
+                [:db/retractEntity [:block/uuid block-uuid]]))
+         distinct
+         seq)))
+
+(defn ^:api permanently-delete!
+  [conn root-uuid]
+  (when-let [root (d/entity @conn [:block/uuid root-uuid])]
+    (when-let [tx-data (permanently-delete-tx-data @conn root)]
+      (ldb/transact! conn tx-data {:outliner-op :recycle-delete-permanently})
+      true)))
+
 (defn- gc-tx-data
   [db {:keys [now-ms] :or {now-ms (common-util/time-ms)}}]
   (let [cutoff (- now-ms retention-ms)]

+ 32 - 0
deps/outliner/test/logseq/outliner/recycle_test.cljs

@@ -1,5 +1,6 @@
 (ns logseq.outliner.recycle-test
   (:require [cljs.test :refer [deftest is]]
+            [datascript.core :as d]
             [logseq.db :as ldb]
             [logseq.db.test.helper :as db-test]
             [logseq.outliner.recycle :as recycle]))
@@ -16,3 +17,34 @@
       (is (nil? (:block/parent page')))
       (is (nil? (:logseq.property/deleted-at page')))
       (is (nil? (:logseq.property.recycle/original-parent page'))))))
+
+(deftest permanently-delete-recycled-page-removes-page-and-descendants
+  (let [conn (db-test/create-conn-with-blocks
+              [{:page {:block/title "page1"}
+                :blocks [{:block/title "b1"}]}])
+        page (ldb/get-page @conn "page1")
+        block (db-test/find-block-by-content @conn "b1")
+        page-uuid (:block/uuid page)
+        block-uuid (:block/uuid block)]
+    (ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page})
+    (is (true? (ldb/recycled? (d/entity @conn [:block/uuid page-uuid]))))
+    (is (true? (recycle/permanently-delete! conn page-uuid)))
+    (is (nil? (d/entity @conn [:block/uuid page-uuid])))
+    (is (nil? (d/entity @conn [:block/uuid block-uuid])))))
+
+(deftest permanently-delete-recycled-block-removes-subtree-only
+  (let [conn (db-test/create-conn-with-blocks
+              [{:page {:block/title "page1"}
+                :blocks [{:block/title "parent"
+                          :build/children [{:block/title "child"}]}]}])
+        page (ldb/get-page @conn "page1")
+        parent (db-test/find-block-by-content @conn "parent")
+        child (db-test/find-block-by-content @conn "child")
+        parent-uuid (:block/uuid parent)
+        child-uuid (:block/uuid child)]
+    (ldb/transact! conn (recycle/recycle-blocks-tx-data @conn [parent] {}) {:outliner-op :delete-blocks})
+    (is (true? (ldb/recycled? (d/entity @conn [:block/uuid parent-uuid]))))
+    (is (true? (recycle/permanently-delete! conn parent-uuid)))
+    (is (some? (d/entity @conn [:block/uuid (:block/uuid page)])))
+    (is (nil? (d/entity @conn [:block/uuid parent-uuid])))
+    (is (nil? (d/entity @conn [:block/uuid child-uuid])))))

+ 45 - 9
src/main/frontend/components/recycle.cljs

@@ -4,9 +4,12 @@
             [datascript.core :as d]
             [frontend.components.block :as component-block]
             [frontend.db :as db]
+            [frontend.db-mixins :as db-mixins]
+            [frontend.db.react :as react]
             [frontend.handler.editor :as editor-handler]
             [frontend.handler.page :as page-handler]
             [frontend.state :as state]
+            [frontend.util :as util]
             [logseq.db :as ldb]
             [logseq.shui.ui :as shui]
             [rum.core :as rum]))
@@ -36,6 +39,20 @@
        (map #(d/entity db %))
        (sort-by :logseq.property/deleted-at #(compare %2 %1))))
 
+(defn- sub-deleted-root-ids
+  []
+  (when-let [repo (state/get-current-repo)]
+    (some-> (react/q repo
+                     [:frontend.worker.react/recycle-roots]
+                     {:query-fn (fn [db _]
+                                  (->> (d/q '[:find [?e ...]
+                                              :where
+                                              [?e :logseq.property/deleted-at]]
+                                            db)
+                                       vec))}
+                     nil)
+            util/react)))
+
 (defn- group-title
   [db root]
   (if (ldb/page? root)
@@ -59,7 +76,11 @@
 (defn- deleted-root-header
   [db root]
   (let [user (deleted-by db root)
-        deleted-at (:logseq.property/deleted-at root)]
+        deleted-at (:logseq.property/deleted-at root)
+        root-uuid (:block/uuid root)
+        delete-message (str "Permanently delete this "
+                            (if (ldb/page? root) "page" "block")
+                            " from Recycle? This cannot be undone.")]
     [: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)
@@ -68,12 +89,20 @@
         (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")]))
+     [:div.flex.items-center.gap-1
+      (shui/button
+       {:variant :ghost
+        :size :xs
+        :class "!py-0 !px-1 h-4"
+        :on-click #(page-handler/restore-recycled! root-uuid)}
+       "Restore")
+      (shui/button
+       {:variant :ghost
+        :size :xs
+        :class "!py-0 !px-1 h-4 hover:text-destructive"
+        :on-click #(when (js/confirm delete-message)
+                     (page-handler/delete-recycled-permanently! root-uuid))}
+       "Delete permanently")]]))
 
 (defn- deleted-root-outliner
   [root]
@@ -88,10 +117,17 @@
     :id (str (:block/uuid root))}
    root))
 
-(rum/defc recycle-page
+(rum/defc recycle-page < rum/reactive db-mixins/query
   [_page]
   (let [db* (db/get-db)
-        groups (->> (deleted-roots db*)
+        root-ids (or (sub-deleted-root-ids)
+                     [])
+        roots (if (seq root-ids)
+                (->> root-ids
+                     (keep #(d/entity db* %))
+                     (sort-by :logseq.property/deleted-at #(compare %2 %1)))
+                (deleted-roots db*))
+        groups (->> roots
                     (group-by #(group-title db* %))
                     (sort-by (fn [[_ roots]]
                                (:logseq.property/deleted-at (first roots)))

+ 10 - 0
src/main/frontend/handler/page.cljs

@@ -57,6 +57,16 @@
         (outliner-op/transact! tx-data nil))
        true))))
 
+(defn delete-recycled-permanently!
+  [root-uuid]
+  (when-let [root (db/entity [:block/uuid root-uuid])]
+    (when-let [tx-data (seq (outliner-recycle/permanently-delete-tx-data (db/get-db) root))]
+      (p/do!
+       (ui-outliner-tx/transact!
+        {:outliner-op :recycle-delete-permanently}
+        (outliner-op/recycle-delete-permanently! root-uuid))
+       true))))
+
 (defn <unfavorite-page!
   [page-name]
   (p/do!

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

@@ -163,3 +163,8 @@
   ([page-uuid opts]
    (op-transact!
     [:delete-page [page-uuid (current-user-delete-opts opts)]])))
+
+(defn recycle-delete-permanently!
+  [root-uuid]
+  (op-transact!
+   [:recycle-delete-permanently [root-uuid]]))

+ 9 - 0
src/main/frontend/worker/react.cljs

@@ -19,6 +19,8 @@
 (s/def ::objects (s/tuple #(= ::objects %) int?))
 ;; get block reactions
 (s/def ::block-reactions (s/tuple #(= ::block-reactions %) int?))
+;; recycle roots list
+(s/def ::recycle-roots (s/tuple #(= ::recycle-roots %)))
 ;; custom react-query
 (s/def ::custom any?)
 
@@ -27,6 +29,7 @@
                                 :refs ::refs
                                 :objects ::objects
                                 :block-reactions ::block-reactions
+                                :recycle-roots ::recycle-roots
                                 :custom ::custom))
 
 (s/def ::affected-keys (s/coll-of ::react-query-keys))
@@ -71,6 +74,9 @@
                                         (= :logseq.property.reaction/target (:a datom))) tx-data)
                               (map :v)
                               (distinct))
+        recycle-roots? (some (fn [datom]
+                               (= :logseq.property/deleted-at (:a datom)))
+                             tx-data)
         other-blocks (->> (filter (fn [datom] (= "block" (namespace (:a datom)))) tx-data)
                           (map :e))
         blocks (-> (concat blocks other-blocks) distinct)
@@ -114,6 +120,9 @@
                           (when tag [::objects tag]))
                         tags)
 
+                       (when recycle-roots?
+                         [[::recycle-roots]])
+
                        (when journals?
                          [[::journals]]))]
     (->>

+ 21 - 0
src/main/frontend/worker/sync/apply_txs.cljs

@@ -800,6 +800,27 @@
       (ldb/transact! conn tx-data
                      {:outliner-op :restore-recycled}))
 
+    :recycle-delete-permanently
+    (let [[root-id] args
+          root-ref (cond
+                     (and (vector? root-id)
+                          (= :block/uuid (first root-id)))
+                     root-id
+
+                     (uuid? root-id)
+                     [:block/uuid root-id]
+
+                     :else
+                     root-id)
+          root (d/entity @conn root-ref)
+          tx-data (when root
+                    (seq (outliner-recycle/permanently-delete-tx-data @conn root)))]
+      ;; Keep replay idempotent under concurrent edits where the recycled root may
+      ;; already be permanently removed by a preceding remote tx.
+      (when (seq tx-data)
+        (ldb/transact! conn tx-data
+                       {:outliner-op :recycle-delete-permanently})))
+
     :set-block-property
     (let [[block-eid property-id v] args
           block-eid' (or (replay-entity-id-value @conn block-eid)

+ 28 - 0
src/test/frontend/worker/db_sync_test.cljs

@@ -1776,6 +1776,34 @@
           (is (= #{"page y"}
                  (set (map :block/name (:user.property/x7 block'))))))))))
 
+(deftest replay-recycle-delete-permanently-removes-recycled-page-test
+  (testing "replay should permanently delete a recycled page subtree"
+    (let [conn (db-test/create-conn-with-blocks
+                [{:page {:block/title "page 1"}
+                  :blocks [{:block/title "child 1"}]}])
+          page (db-test/find-page-by-title @conn "page 1")
+          child (db-test/find-block-by-content @conn "child 1")
+          page-uuid (:block/uuid page)
+          child-uuid (:block/uuid child)]
+      (outliner-page/delete! conn page-uuid {})
+      (is (true? (ldb/recycled? (d/entity @conn [:block/uuid page-uuid]))))
+      (is (nil? (#'sync-apply/replay-canonical-outliner-op!
+                 conn
+                 [:recycle-delete-permanently [[:block/uuid page-uuid]]]
+                 nil)))
+      (is (nil? (d/entity @conn [:block/uuid page-uuid])))
+      (is (nil? (d/entity @conn [:block/uuid child-uuid]))))))
+
+(deftest replay-recycle-delete-permanently-missing-root-is-idempotent-test
+  (testing "replay should no-op when recycled root has already been removed"
+    (let [conn (db-test/create-conn-with-blocks
+                [{:page {:block/title "page 1"}}])
+          missing-uuid (random-uuid)]
+      (is (nil? (#'sync-apply/replay-canonical-outliner-op!
+                 conn
+                 [:recycle-delete-permanently [[:block/uuid missing-uuid]]]
+                 nil))))))
+
 (deftest replay-set-block-property-converts-raw-uuid-to-eid-test
   (testing "replay should resolve raw block uuid ids for set-block-property"
     (let [graph {:classes {:tag1 {}}