diff --git a/deps/outliner/src/logseq/outliner/op.cljs b/deps/outliner/src/logseq/outliner/op.cljs index 5c60cf2a9b..5319093213 100644 --- a/deps/outliner/src/logseq/outliner/op.cljs +++ b/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)) diff --git a/deps/outliner/src/logseq/outliner/op/construct.cljc b/deps/outliner/src/logseq/outliner/op/construct.cljc index b47426ac18..11412bfb52 100644 --- a/deps/outliner/src/logseq/outliner/op/construct.cljc +++ b/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) diff --git a/deps/outliner/src/logseq/outliner/recycle.cljs b/deps/outliner/src/logseq/outliner/recycle.cljs index 7211e32a07..ca5c289452 100644 --- a/deps/outliner/src/logseq/outliner/recycle.cljs +++ b/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)] diff --git a/deps/outliner/test/logseq/outliner/recycle_test.cljs b/deps/outliner/test/logseq/outliner/recycle_test.cljs index 2f60044d7b..99fd642845 100644 --- a/deps/outliner/test/logseq/outliner/recycle_test.cljs +++ b/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]))))) diff --git a/src/main/frontend/components/recycle.cljs b/src/main/frontend/components/recycle.cljs index ff08c543cb..e6cf96ecc9 100644 --- a/src/main/frontend/components/recycle.cljs +++ b/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))) diff --git a/src/main/frontend/handler/page.cljs b/src/main/frontend/handler/page.cljs index 7e0002cd11..69b05ade23 100644 --- a/src/main/frontend/handler/page.cljs +++ b/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 > (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]]))] (->> diff --git a/src/main/frontend/worker/sync/apply_txs.cljs b/src/main/frontend/worker/sync/apply_txs.cljs index 315984f06f..51bf787008 100644 --- a/src/main/frontend/worker/sync/apply_txs.cljs +++ b/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) diff --git a/src/test/frontend/worker/db_sync_test.cljs b/src/test/frontend/worker/db_sync_test.cljs index cc9170e02a..2e8d02d52a 100644 --- a/src/test/frontend/worker/db_sync_test.cljs +++ b/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 {}}