From 0e1340a4132fea10ae432135cd56ca1e2c995666 Mon Sep 17 00:00:00 2001 From: Gabriel Horner Date: Thu, 9 Apr 2026 16:37:37 -0400 Subject: [PATCH] fix(cli): upsert page on deleted/recycled page restores it. Also disable editing of deleted page to keep consistent with app --- src/main/logseq/cli/command/upsert.cljs | 14 ++++-- src/test/logseq/cli/commands_test.cljs | 62 +++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/main/logseq/cli/command/upsert.cljs b/src/main/logseq/cli/command/upsert.cljs index f67ae978ca..6ff76e23b8 100644 --- a/src/main/logseq/cli/command/upsert.cljs +++ b/src/main/logseq/cli/command/upsert.cljs @@ -9,6 +9,7 @@ [logseq.cli.transport :as transport] [logseq.common.graph :as common-graph] [logseq.common.util :as common-util] + [logseq.db :as ldb] [logseq.db.frontend.property.type :as db-property-type] [promesa.core :as p] [logseq.db.frontend.property :as db-property])) @@ -596,9 +597,14 @@ (defn- ensure-page-entity! [config repo page-name] - (p/let [existing (pull-page-by-name config repo page-name [:db/id :block/uuid])] - (if (:db/id existing) + (p/let [existing (pull-page-by-name config repo page-name + [:db/id :block/uuid :logseq.property/deleted-at])] + (if (and (:db/id existing) (not (ldb/recycled? existing))) existing + ;; Either no page exists, or only a recycled one does. Calling + ;; :create-page in both cases is correct: outliner-page/create has a + ;; (ldb/recycled? existing-page) branch that restores the recycled page + ;; instead of creating a duplicate. (p/let [result (transport/invoke config :thread-api/apply-outliner-ops false [repo [[:create-page [page-name {}]]] {}]) ;; create-page returns [title' page-uuid]; use uuid to find @@ -620,7 +626,7 @@ :upsert-id-type-mismatch) (def ^:private page-selector - [:db/id :block/uuid :block/name :block/title]) + [:db/id :block/uuid :block/name :block/title :logseq.property/deleted-at]) (def ^:private tag-selector [:db/id :block/uuid :block/name :block/title @@ -665,7 +671,7 @@ [config repo id] (p/let [entity (pull-entity-by-id config repo page-selector id)] (cond - (not (:db/id entity)) + (or (not (:db/id entity)) (ldb/recycled? entity)) (throw-upsert-id-not-found! "page" id) (not (page-entity? entity)) diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index 8f1c45d85f..4a73656158 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -3155,6 +3155,68 @@ (p/catch (fn [e] (is false (str "unexpected error: " e)))) (p/finally done))))) +(deftest test-execute-upsert-page-restores-recycled-page + ;; A recycled page with the same name must be treated as "not existing" so + ;; the create-page outliner op runs. The outliner's `create` already has a + ;; (ldb/recycled? existing-page) branch that restores the page in place, + ;; preventing duplicate :block/name entries. + (async done + (let [batches* (atom []) + recycled-uuid (uuid "00000000-0000-0000-0000-0000000000ec") + action {:type :upsert-page :repo "demo" :page "Home" + :update-properties {:logseq.property/publishing-public? true}}] + (-> (p/with-redefs [cli-server/list-graphs (fn [_] ["demo"]) + cli-server/ensure-server! (fn [_ _] {:base-url "http://example"}) + add-command/resolve-tags (fn [_ _ _] (p/resolved nil)) + add-command/resolve-properties (fn [_ _ properties & _] (p/resolved properties)) + add-command/resolve-property-identifiers (fn [_ _ properties & _] (p/resolved properties)) + transport/invoke (fn [_ method _ args] + (case method + :thread-api/pull (let [[_ _ lookup] args] + (cond + (= lookup [:block/name "home"]) + {:db/id 50 + :block/uuid recycled-uuid + :logseq.property/deleted-at 1712000000000} + (= lookup [:block/uuid recycled-uuid]) + {:db/id 50 :block/uuid recycled-uuid} + (and (vector? lookup) (= :db/ident (first lookup))) + {:db/id 999} + :else {})) + :thread-api/apply-outliner-ops (let [[_ ops _] args] + (swap! batches* conj ops) + ["Home" recycled-uuid]) + (throw (ex-info "unexpected invoke" {:method method :args args}))))] + (p/let [result (commands/execute action {})] + (is (= :ok (:status result))) + (is (some (fn [batch] + (some #(= [:create-page ["Home" {}]] %) batch)) + @batches*) + "create-page op is invoked, which restores the recycled page in the outliner"))) + (p/catch (fn [e] (is false (str "unexpected error: " e)))) + (p/finally done))))) + +(deftest test-execute-upsert-page-by-id-rejects-recycled-page + (async done + (let [action {:type :upsert-page :mode :update :repo "demo" :id 50}] + (-> (p/with-redefs [cli-server/list-graphs (fn [_] ["demo"]) + cli-server/ensure-server! (fn [_ _] {:base-url "http://example"}) + transport/invoke (fn [_ method _ _] + (case method + :thread-api/pull {:db/id 50 + :block/name "home" + :block/title "Home" + :block/uuid (uuid "00000000-0000-0000-0000-0000000000ed") + :logseq.property/deleted-at 1712000000000} + :thread-api/apply-outliner-ops + (throw (ex-info "should not apply ops on recycled page" {:method method})) + (throw (ex-info "unexpected invoke" {:method method}))))] + (p/let [result (commands/execute action {})] + (is (= :error (:status result))) + (is (= :upsert-id-not-found (get-in result [:error :code]))))) + (p/catch (fn [e] (is false (str "unexpected error: " e)))) + (p/finally done))))) + (deftest test-execute-show-page-skips-recycled-page ;; `execute-show` throws `ex-info` with `:code :page-not-found` rather than ;; returning `:status :error`; the top-level CLI catches it. The test