feat: recycle

This commit is contained in:
Tienson Qin
2026-03-17 16:08:08 +08:00
parent dc829be3f1
commit 174cdfd865
37 changed files with 1153 additions and 410 deletions

View File

@@ -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)}

View File

@@ -12,6 +12,7 @@
[frontend.components.plugins :as plugins]
[frontend.components.property.config :as property-config]
[frontend.components.query :as query]
[frontend.components.recycle :as recycle]
[frontend.components.reference :as reference]
[frontend.components.scheduled-deadlines :as scheduled]
[frontend.components.svg :as svg]
@@ -418,6 +419,8 @@
property-page? (ldb/property? page)
title (:block/title page)
journal? (db/journal-page? title)
recycle-page? (and (ldb/page? page)
(= title common-config/recycle-page-name))
fmt-journal? (boolean (date/journal-title->int title))
today? (and
journal?
@@ -462,14 +465,16 @@
(tabs page {:current-page? option :sidebar? sidebar?}))
(when (not tag-dialog?)
[:div.ls-page-blocks
{:style {:margin-left (if (util/mobile?) 0 -20)}
:class (when-not (or sidebar? (util/capacitor?))
"mt-4")}
(page-blocks-cp page (merge option {:sidebar? sidebar?
:container-id (:container-id state)}))])]
(if recycle-page?
(recycle/recycle-page page)
[:div.ls-page-blocks
{:style {:margin-left (if (util/mobile?) 0 -20)}
:class (when-not (or sidebar? (util/capacitor?))
"mt-4")}
(page-blocks-cp page (merge option {:sidebar? sidebar?
:container-id (:container-id state)}))]))]
(when-not preview?
(when-not (or preview? recycle-page?)
[:div.flex.flex-col.gap-8
{:class (when-not (util/mobile?) "ml-1")}
(when today?

View File

@@ -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)

View File

@@ -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."])]))

View File

@@ -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]

View File

@@ -541,71 +541,72 @@
(db/get-page page)
(db/entity [:block/uuid block-uuid]))]
(when block
(let [last-block (when (not sibling?)
(let [children (:block/_parent block)
blocks (db/sort-by-order children)
last-block-id (:db/id (last blocks))]
(when last-block-id
(db/entity last-block-id))))
new-block (-> (select-keys block [:block/page])
(assoc :block/title content))
new-block (assoc new-block :block/page
(if page
(:db/id block)
(:db/id (:block/page new-block))))
new-block (-> new-block
(wrap-parse-block)
(assoc :block/uuid (or custom-uuid (db/new-block-id))))
new-block (merge new-block other-attrs)
block' (db/entity (:db/id block))
[target-block sibling?] (cond
before?
(let [left-or-parent (or (ldb/get-left-sibling block)
(:block/parent block))
sibling? (if (= (:db/id (:block/parent block)) (:db/id left-or-parent))
false sibling?)]
[left-or-parent sibling?])
(if (ldb/recycled? block)
(notification/show! "Recycle is read-only." :warning)
(let [last-block (when (not sibling?)
(let [children (:block/_parent block)
blocks (db/sort-by-order children)
last-block-id (:db/id (last blocks))]
(when last-block-id
(db/entity last-block-id))))
new-block (-> (select-keys block [:block/page])
(assoc :block/title content))
new-block (assoc new-block :block/page
(if page
(:db/id block)
(:db/id (:block/page new-block))))
new-block (-> new-block
(wrap-parse-block)
(assoc :block/uuid (or custom-uuid (db/new-block-id))))
new-block (merge new-block other-attrs)
block' (db/entity (:db/id block))
[target-block sibling?] (cond
before?
(let [left-or-parent (or (ldb/get-left-sibling block)
(:block/parent block))
sibling? (if (= (:db/id (:block/parent block)) (:db/id left-or-parent))
false sibling?)]
[left-or-parent sibling?])
sibling?
[block' sibling?]
sibling?
[block' sibling?]
start?
[block' false]
start?
[block' false]
end?
(if last-block
end?
(if last-block
[last-block true]
[block' false])
last-block
[last-block true]
[block' false])
last-block
[last-block true]
block
[block' sibling?]
block
[block' sibling?]
;; FIXME: assert
:else
nil)]
(when target-block
(p/do!
(let [new-block' (if (seq properties)
(into new-block properties)
new-block)]
(ui-outliner-tx/transact!
{:outliner-op :insert-blocks}
(outliner-insert-block! config target-block new-block'
{:sibling? sibling?
:keep-uuid? true
:ordered-list? ordered-list?
:outliner-op outliner-op
:replace-empty-target? replace-empty-target?})))
(when edit-block?
(if (and replace-empty-target?
(string/blank? (:block/title last-block)))
(edit-block! last-block :max)
(edit-block! new-block :max)))
(when-let [id (:block/uuid new-block)]
(db/entity [:block/uuid id])))))))))
:else
nil)]
(when target-block
(p/do!
(let [new-block' (if (seq properties)
(into new-block properties)
new-block)]
(ui-outliner-tx/transact!
{:outliner-op :insert-blocks}
(outliner-insert-block! config target-block new-block'
{:sibling? sibling?
:keep-uuid? true
:ordered-list? ordered-list?
:outliner-op outliner-op
:replace-empty-target? replace-empty-target?})))
(when edit-block?
(if (and replace-empty-target?
(string/blank? (:block/title last-block)))
(edit-block! last-block :max)
(edit-block! new-block :max)))
(when-let [id (:block/uuid new-block)]
(db/entity [:block/uuid id]))))))))))
(defn get-selected-blocks
[]
@@ -797,9 +798,12 @@
(defn move-blocks!
[blocks target opts]
(when (seq blocks)
(ui-outliner-tx/transact!
{:outliner-op :move-blocks}
(outliner-op/move-blocks! blocks target opts))))
(if (or (some ldb/recycled? blocks)
(ldb/recycled? target))
(notification/show! "Recycle is read-only." :warning)
(ui-outliner-tx/transact!
{:outliner-op :move-blocks}
(outliner-op/move-blocks! blocks target opts)))))
(defn move-selected-blocks
[e]
@@ -976,10 +980,11 @@
(let [repo (state/get-current-repo)
block-uuids (distinct (keep #(when-let [id (dom/attr % "blockid")] (uuid id)) dom-blocks))
lookup-refs (map (fn [id] [:block/uuid id]) block-uuids)
blocks (map db/entity lookup-refs)]
(ui-outliner-tx/transact!
{:outliner-op :delete-blocks}
(let [top-level-blocks (block-handler/get-top-level-blocks blocks)]
blocks (map db/entity lookup-refs)
top-level-blocks (block-handler/get-top-level-blocks blocks)]
(when-not (every? ldb/recycled? top-level-blocks)
(ui-outliner-tx/transact!
{:outliner-op :delete-blocks}
(when (seq top-level-blocks)
(let [sorted-blocks (mapcat (fn [block]
(tree/get-sorted-block-and-children repo (:db/id block)))
@@ -1831,6 +1836,14 @@
(let [ids (set (map :db/id blocks))]
(some? (some #(ids (:db/id (:block/parent %))) blocks))))
(defn- unrecycle-tx-data
[root]
[[:db/retract (:db/id root) :logseq.property/deleted-at]
[:db/retract (:db/id root) :logseq.property/deleted-by-ref]
[:db/retract (:db/id root) :logseq.property.recycle/original-parent]
[:db/retract (:db/id root) :logseq.property.recycle/original-page]
[:db/retract (:db/id root) :logseq.property.recycle/original-order]])
(defn paste-blocks
"Given a vec of blocks, insert them into the target page.
keep-uuid?: if true, keep the uuid provided in the block structure."
@@ -1865,6 +1878,13 @@
(or (ldb/get-left-sibling target-block)
(:block/parent (db/entity (:db/id target-block))))
target-block)
existing-blocks (keep (fn [block]
(when-let [id (:block/uuid block)]
(db/entity [:block/uuid id])))
blocks)
move-from-recycle? (and keep-uuid?
(seq existing-blocks)
(every? ldb/recycled? existing-blocks))
sibling? (cond
(and paste-nested-blocks? empty-target?)
(= (:block/parent target-block') (:block/parent target-block))
@@ -1877,20 +1897,32 @@
:else
true)
transact-blocks! #(ui-outliner-tx/transact!
{:outliner-op :insert-blocks
:additional-tx revert-cut-txs}
(when target-block'
(let [format (get target-block' :block/format :markdown)
repo (state/get-current-repo)
blocks' (map (fn [block]
(paste-block-cleanup repo block page exclude-properties format content-update-fn keep-uuid?))
blocks)]
(outliner-op/insert-blocks! blocks' target-block' {:sibling? sibling?
:outliner-op :paste
:outliner-real-op outliner-real-op
:replace-empty-target? replace-empty-target?
:keep-uuid? keep-uuid?}))))]
transact-blocks! #(if move-from-recycle?
(ui-outliner-tx/transact!
{:outliner-op :move-blocks
:additional-tx revert-cut-txs}
(when target-block'
(let [top-level-blocks (block-handler/get-top-level-blocks existing-blocks)
unrecycle-tx (mapcat unrecycle-tx-data top-level-blocks)]
(when (seq unrecycle-tx)
(outliner-op/transact! unrecycle-tx nil))
(outliner-op/move-blocks! top-level-blocks target-block'
{:sibling? sibling?
:outliner-op :paste}))))
(ui-outliner-tx/transact!
{:outliner-op :insert-blocks
:additional-tx revert-cut-txs}
(when target-block'
(let [format (get target-block' :block/format :markdown)
repo (state/get-current-repo)
blocks' (map (fn [block]
(paste-block-cleanup repo block page exclude-properties format content-update-fn keep-uuid?))
blocks)]
(outliner-op/insert-blocks! blocks' target-block' {:sibling? sibling?
:outliner-op :paste
:outliner-real-op outliner-real-op
:replace-empty-target? replace-empty-target?
:keep-uuid? keep-uuid?})))))]
(if ops-only?
(transact-blocks!)
(p/let [_ (when has-unsaved-edits

View File

@@ -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!

View File

@@ -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)

View File

@@ -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)]])))

View File

@@ -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}])

View File

@@ -1,13 +1,13 @@
(ns frontend.undo-redo
"Undo redo new implementation"
(:require [clojure.set :as set]
[datascript.core :as d]
(:require [datascript.core :as d]
[frontend.db :as db]
[frontend.state :as state]
[frontend.util :as util]
[lambdaisland.glogi :as log]
[logseq.common.defkeywords :refer [defkeywords]]
[logseq.db :as ldb]
[logseq.outliner.recycle :as outliner-recycle]
[malli.core :as m]
[malli.util :as mu]
[promesa.core :as p]))
@@ -150,26 +150,6 @@
[repo]
(empty? (get @*redo-ops repo)))
(defn- get-moved-blocks
[e->datoms]
(->>
(keep (fn [[e datoms]]
(when (some
(fn [k]
(and (some (fn [d] (and (= k (:a d)) (:added d))) datoms)
(some (fn [d] (and (= k (:a d)) (not (:added d)))) datoms)))
[:block/parent :block/order])
e)) e->datoms)
(set)))
(defn- other-children-exist?
"return true if there are other children existing(not included in `ids`)"
[entity ids]
(seq
(set/difference
(set (map :db/id (:block/_parent entity)))
ids)))
(defn- reverse-datoms
[conn datoms schema added-ids retracted-ids undo? redo?]
(keep
@@ -185,156 +165,62 @@
[op e a v])))
datoms))
(defn- block-moved-and-target-deleted?
[conn e->datoms e moved-blocks tx-data]
(let [datoms (get e->datoms e)]
(and (moved-blocks e)
(let [b (d/entity @conn e)
cur-parent (:db/id (:block/parent b))
move-datoms (filter (fn [d] (contains? #{:block/parent} (:a d))) datoms)]
(when cur-parent
(let [before-parent (some (fn [d] (when (and (= :block/parent (:a d)) (not (:added d))) (:v d))) move-datoms)
not-exists-in-current-db (nil? (d/entity @conn before-parent))
;; reverse tx-data will add parent before back
removed-before-parent (some (fn [d] (and (= :block/uuid (:a d))
(= before-parent (:e d))
(not (:added d)))) tx-data)]
(and before-parent
not-exists-in-current-db
(not removed-before-parent))))))))
(defn- reversed-move-target-ref
[datoms attr undo?]
(some (fn [{:keys [a v added]}]
(when (and (= a attr)
(if undo? (not added) added))
v))
datoms))
(defn- tx-added-attrs
[tx-data]
(reduce (fn [acc [op e a v]]
(if (= :db/add op)
(update acc e assoc a v)
acc))
{}
tx-data))
(defn- entity-exists-or-added?
[conn added-attrs id]
(or (contains? added-attrs id)
(some? (d/entity @conn id))))
(defn- assert-reversed-tx-safe!
[conn reversed-tx-data]
(let [added-attrs (tx-added-attrs reversed-tx-data)
ops-by-entity (group-by second reversed-tx-data)]
(doseq [[e ops] ops-by-entity]
(let [retract-entity? (some #(= :db/retractEntity (first %)) ops)
retract-parent? (some #(and (= :db/retract (first %))
(= :block/parent (nth % 2)))
ops)
add-parent? (some #(and (= :db/add (first %))
(= :block/parent (nth % 2)))
ops)
retract-page? (some #(and (= :db/retract (first %))
(= :block/page (nth % 2)))
ops)
add-page? (some #(and (= :db/add (first %))
(= :block/page (nth % 2)))
ops)]
;; Moving blocks must not leave entities without parent/page refs.
(when (and (not retract-entity?)
retract-parent?
(not add-parent?))
(throw (ex-info "Reversed tx retracts parent without replacement"
{:error :block-moved-or-target-deleted
:entity-id e
:ops ops})))
(when (and (not retract-entity?)
retract-page?
(not add-page?))
(throw (ex-info "Reversed tx retracts page without replacement"
{:error :block-moved-or-target-deleted
:entity-id e
:ops ops})))))
(doseq [[e attrs] added-attrs]
(let [existing (d/entity @conn e)
new-entity? (nil? existing)
page? (or (:block/name attrs) (:block/name existing))
parent (:block/parent attrs)
page (:block/page attrs)]
;; Redoing a block creation must restore parent/page refs.
(when (and new-entity?
(not page?)
(not (contains? attrs :block/uuid)))
(throw (ex-info "Missing block identity in reversed tx"
{:error :block-moved-or-target-deleted
:entity-id e
:attrs attrs})))
(when (and new-entity?
(contains? attrs :block/uuid)
(not page?)
(nil? parent))
(throw (ex-info "Missing block parent in reversed tx"
{:error :block-parent-missing
:entity-id e
:attrs attrs})))
(when (and parent
(not (entity-exists-or-added? conn added-attrs parent)))
(throw (ex-info "Parent deleted in reversed tx"
{:error :block-moved-or-target-deleted
:entity-id e
:parent-id parent
:attrs attrs})))
(when (and page
(not (entity-exists-or-added? conn added-attrs page)))
(throw (ex-info "Page deleted in reversed tx"
{:error :block-moved-or-target-deleted
:entity-id e
:page-id page
:attrs attrs})))))))
(defn- reversed-move-conflicted?
[conn e->datoms undo?]
(some (fn [[_e datoms]]
(let [target-parent (reversed-move-target-ref datoms :block/parent undo?)
target-page (reversed-move-target-ref datoms :block/page undo?)
parent-ent (when (int? target-parent) (d/entity @conn target-parent))
page-ent (when (int? target-page) (d/entity @conn target-page))]
(or (and target-parent
(or (nil? parent-ent)
(ldb/recycled? parent-ent)))
(and target-page
(or (nil? page-ent)
(ldb/recycled? page-ent))))))
e->datoms))
(defn get-reversed-datoms
[conn undo? {:keys [tx-data added-ids retracted-ids] :as op} _tx-meta]
(try
(let [redo? (not undo?)
e->datoms (->> (if redo? tx-data (reverse tx-data))
(group-by :e))
schema (:schema @conn)
moved-blocks (get-moved-blocks e->datoms)
reversed-tx-data (->> (mapcat
(fn [[e datoms]]
(let [entity (d/entity @conn e)]
[conn undo? {:keys [tx-data added-ids retracted-ids]} tx-meta]
(let [recycle-restore-tx (when (and undo?
(= :delete-blocks (:outliner-op tx-meta)))
(->> tx-data
(keep (fn [{:keys [e a added]}]
(when (and added
(= :logseq.property/deleted-at a))
(d/entity @conn e))))
(mapcat #(outliner-recycle/restore-tx-data @conn %))
seq))
redo? (not undo?)
e->datoms (->> (if redo? tx-data (reverse tx-data))
(group-by :e))
schema (:schema @conn)
move-conflicted? (and (= :move-blocks (:outliner-op tx-meta))
(reversed-move-conflicted? conn e->datoms undo?))
reversed-tx-data (or (when move-conflicted? nil)
(some-> recycle-restore-tx reverse seq)
(->> (mapcat
(fn [[e datoms]]
(cond
;; New children may have been added after the original op.
(or (and (contains? retracted-ids e) redo?
(other-children-exist? entity retracted-ids)) ; redo delete-blocks
(and (contains? added-ids e) undo?
(other-children-exist? entity added-ids))) ; undo insert-blocks
(throw (ex-info "Children still exists"
(merge op {:error :block-children-exists
:undo? undo?})))
(and undo? (contains? added-ids e))
[[:db/retractEntity e]]
;; Block has moved or target got deleted.
(block-moved-and-target-deleted? conn e->datoms e moved-blocks tx-data)
(throw (ex-info "This block has been moved or its target has been deleted"
(merge op {:error :block-moved-or-target-deleted
:undo? undo?})))
;; Delete entity instead of retracting attrs one-by-one.
(and entity
(or (and (contains? retracted-ids e) redo?) ; redo delete-blocks
(and (contains? added-ids e) undo?))) ; undo insert-blocks
(and redo? (contains? retracted-ids e))
[[:db/retractEntity e]]
:else
(reverse-datoms conn datoms schema added-ids retracted-ids undo? redo?))))
e->datoms)
(remove nil?))]
(assert-reversed-tx-safe! conn reversed-tx-data)
reversed-tx-data)
(catch :default e
(when-not (contains? #{:block-moved-or-target-deleted
:block-children-exists
:block-parent-missing}
(:error (ex-data e)))
(throw e)))))
(reverse-datoms conn datoms schema added-ids retracted-ids undo? redo?)))
e->datoms)
(remove nil?)))]
reversed-tx-data))
(defn- undo-redo-aux
[repo undo?]
@@ -426,7 +312,7 @@
(let [{:keys [outliner-op local-tx?]} tx-meta]
(when (and
(= (:client-id tx-meta) (:client-id @state/state))
local-tx?
(true? local-tx?)
outliner-op
(not (false? (:gen-undo-ops? tx-meta)))
(not (:create-today-journal? tx-meta)))
@@ -450,6 +336,8 @@
:retracted-ids retracted-ids}]]
(remove nil?)
vec)]
;; A new local edit invalidates any redo history.
(swap! *redo-ops assoc repo [])
(push-undo-op repo op)))))
(defn listen-db-changes!

View File

@@ -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)))]

View File

@@ -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]

View File

@@ -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!

View File

@@ -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)))

View File

@@ -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')))))))

View File

@@ -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)]

View File

@@ -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])))))))))

View File

@@ -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 ?"