mirror of
https://github.com/logseq/logseq.git
synced 2026-05-29 15:09:41 +00:00
feat: recycle
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
src/main/frontend/components/recycle.cljs
Normal file
112
src/main/frontend/components/recycle.cljs
Normal 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."])]))
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]])))
|
||||
|
||||
@@ -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}])
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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)))]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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')))))))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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])))))))))
|
||||
|
||||
|
||||
@@ -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 ?"
|
||||
|
||||
Reference in New Issue
Block a user