Merge remote-tracking branch 'origin/master' into feat/cliable

This commit is contained in:
rcmerci
2026-04-18 10:25:32 +08:00
252 changed files with 22062 additions and 5171 deletions

View File

@@ -13,7 +13,7 @@
[electron.server :as server]
[electron.updater :refer [init-updater] :as updater]
[electron.url :refer [logseq-url-handler]]
[electron.utils :refer [*win mac? linux? dev? get-win-from-sender
[electron.utils :refer [*win mac? dev? get-win-from-sender
decode-protected-assets-schema-path send-to-renderer]
:as utils]
[electron.window :as win]
@@ -33,14 +33,10 @@
(defonce *teardown-fn (volatile! nil))
(defonce *quit-dirty? (volatile! true))
;; Handle creating/removing shortcuts on Windows when installing/uninstalling.
(when (js/require "electron-squirrel-startup") (.quit app))
(defn setup-updater! [^js win]
;; manual/auto updater
(when-not linux?
(init-updater {:repo "logseq/logseq"
:win win})))
(init-updater {:repo "logseq/logseq"
:win win}))
(defn open-url-handler
"win - the main window instance (first renderer process)

View File

@@ -5,7 +5,8 @@
["abort-controller" :as AbortController]
["buffer" :as buffer]
["diff-match-patch" :as google-diff]
["electron" :refer [app autoUpdater dialog ipcMain shell]]
["electron" :refer [app dialog ipcMain shell]]
["electron-updater" :refer [autoUpdater]]
["electron-window-state" :as windowStateKeeper]
["fs" :as fs]
["fs-extra" :as fs-extra]
@@ -421,7 +422,8 @@
(defmethod handle :quitAndInstall []
(logger/info ::quick-and-install)
(.quitAndInstall autoUpdater))
;; https://www.electron.build/electron-updater.class.appupdater#quitandinstall
(.quitAndInstall autoUpdater false true))
;; The graphHas* events are not used but maybe useful later?
(defmethod handle :graphHasOtherWindow [^js win [_ graph]]

View File

@@ -1,161 +1,163 @@
(ns electron.updater
(:require [electron.utils :refer [mac? win32? prod? open fetch *win]]
[electron.logger :as logger]
[frontend.version :refer [version]]
[clojure.string :as string]
[promesa.core :as p]
[cljs-bean.core :as bean]
(:require [cljs-bean.core :as bean]
[electron.configs :as cfgs]
["semver" :as semver]
["os" :as os]
["fs" :as fs]
["path" :as node-path]
["electron" :refer [ipcMain app autoUpdater]]))
[electron.logger :as logger]
[electron.utils :refer [*win prod?]]
[frontend.version :refer [version]]
["electron" :refer [ipcMain]]
["electron-updater" :refer [autoUpdater]]))
(def *update-ready-to-install (atom nil))
(def *update-pending (atom nil))
(def *downloaded-update (atom nil))
(def debug (partial logger/debug "[updater]"))
(def electron-version version)
;Event: 'error'
;Event: 'checking-for-update'
;Event: 'update-available'
;Event: 'update-not-available'
;Event: 'download-progress'
;Event: 'update-downloaded'
;Event: 'completed'
(defn- updater-channel
[]
(let [platform (.-platform js/process)
arch (.-arch js/process)]
(case platform
"win32" (when (#{"x64" "arm64"} arch)
(str "latest-" arch))
"darwin" (when (#{"x64" "arm64"} arch)
(str "latest-" arch))
nil)))
(def electron-version
(let [parts (string/split version #"\.")
parts (take 3 parts)]
(string/join "." parts)))
(defn- emit-update!
[^js win type payload]
(when-let [web-contents (and win (. ^js win -webContents))]
(.send web-contents "updates-callback"
(bean/->js {:type type :payload payload}))))
(defn get-latest-artifact-info
[repo]
(let [endpoint (str "https://update.electronjs.org/" repo "/" js/process.platform "-" js/process.arch "/" electron-version)]
(debug "checking" endpoint)
(p/catch
(p/let [res (fetch endpoint)
status (.-status res)
text (.text res)]
(if (.-ok res)
(let [info (when-not (string/blank? text) (js/JSON.parse text))]
(bean/->clj info))
(throw (js/Error. (str "[" status "] " text)))))
(fn [e]
(logger/warn "[update server error]" e)
(throw e)))))
(defn- emit-completed!
[^js win]
(emit-update! win "completed" nil))
(defn check-for-updates
[{:keys [repo ^js win]
[auto-download] :args}]
(let [emit (fn [type payload]
(.. win -webContents
(send "updates-callback" (bean/->js {:type type :payload payload}))))]
(debug "check for updates #" repo version)
(p/create
(fn [resolve reject]
(emit "checking-for-update" nil)
(-> (p/let
[artifact (get-latest-artifact-info repo)
(defn- normalize-payload
[payload]
(when payload
(bean/->clj payload)))
artifact (when-let [remote-version (and artifact (re-find #"\d+\.\d+\.\d+" (:url artifact)))]
(when (and (. semver valid remote-version)
(. semver lt electron-version remote-version)) artifact))
(defn- normalize-error
[^js e]
{:message (or (.-message e) (str e))})
url (if-not artifact (do (emit "update-not-available" nil) (throw (js/Error. "update not available"))) (:url artifact))
_ (if url (emit "update-available" (bean/->js artifact)) (throw (js/Error. "download url not exists")))
;; start download FIXME: user's preference about auto download
_ (when-not auto-download (throw (js/Error. "no auto download")))
^js dl-res (fetch url)
_ (when-not (.-ok dl-res) (throw (js/Error. "download resource not available")))
dest-info (p/create
(fn [resolve1 reject1]
(let [headers (. dl-res -headers)
total-size (js/parseInt (.get headers "content-length"))
body (.-body dl-res)
start-at (.now js/Date)
*downloaded (atom 0)
dest-basename (node-path/basename url)
tmp-dest-file (node-path/join (os/tmpdir) (str dest-basename ".pending"))
dest-file (.createWriteStream fs tmp-dest-file)]
(doto body
(.on "data" (fn [chunk]
(let [downloaded (+ @*downloaded (.-length chunk))
percent (.toFixed (/ (* 100 downloaded) total-size) 2)
elapsed (/ (- (js/Date.now) start-at) 1000)]
(.write dest-file chunk)
(emit "download-progress" {:total total-size
:downloaded downloaded
:percent percent
:elapsed elapsed})
(reset! *downloaded downloaded))))
(.on "error" (fn [e]
(reject1 e)))
(.on "end" (fn [_e]
(.close dest-file)
(let [dest-file (string/replace tmp-dest-file ".pending" "")]
(fs/renameSync tmp-dest-file dest-file)
(resolve1 (merge artifact {:dest-file dest-file})))))))))]
(reset! *update-ready-to-install dest-info)
(emit "update-downloaded" dest-info)
(resolve nil))
(p/catch
(fn [e]
(if e
(do
(emit "error" e)
(reject e))
(resolve nil))))
(p/finally
(fn []
(emit "completed" nil))))))))
(defn- new-version-downloaded-cb
[_ notes name date url]
(logger/info "[update-downloaded]" name notes date url)
(defn- emit-update-downloaded!
[payload]
(when-let [web-contents (and @*win (. ^js @*win -webContents))]
(.send web-contents "auto-updater-downloaded"
(bean/->js {:notes notes :name name :date date :url url}))))
(.send web-contents "auto-updater-downloaded" (bean/->js payload))))
(defn init-auto-updater
[repo]
(when (.valid semver electron-version)
(p/let [info (get-latest-artifact-info repo)]
(when-let [remote-version (and info (re-find #"\d+\.\d+\.\d+" (:url info)))]
(if (and (. semver valid remote-version)
(. semver lt electron-version remote-version))
(defn- configure-auto-updater!
[]
(let [channel (updater-channel)]
(when channel
(set! (.-channel autoUpdater) channel)
;; Keep the original downgrade policy even though setting channel flips it on.
(set! (.-allowDowngrade autoUpdater) false))
(debug "configure-auto-updater" {:platform (.-platform js/process)
:arch (.-arch js/process)
:channel channel}))
(set! (.-autoInstallOnAppQuit autoUpdater) false)
(set! (.-autoDownload autoUpdater) false))
;; start auto updater
(do
(debug "Found remote version" remote-version)
(when (or mac? win32?)
(debug "forward update to autoUpdater")
;; FIXME: It seems that update-electron-app doesn't work on linux
(when-let [f (js/require "update-electron-app")]
(f #js{:notifyUser false})
(.once autoUpdater "update-downloaded"
new-version-downloaded-cb))))
(defn- register-auto-updater-listeners!
[^js win]
(let [checking-handler
(fn []
(emit-update! win "checking-for-update" nil))
(debug "Skip remote version [ahead of pre-release]" remote-version))))))
available-handler
(fn [info]
(emit-update! win "update-available" (normalize-payload info)))
not-available-handler
(fn [info]
(emit-update! win "update-not-available" (normalize-payload info))
(emit-completed! win))
progress-handler
(fn [progress]
(emit-update! win "download-progress" (normalize-payload progress)))
downloaded-handler
(fn [info]
(let [payload (normalize-payload info)]
(reset! *downloaded-update payload)
(logger/info "[update-downloaded]" payload)
(emit-update! win "update-downloaded" payload)
(emit-update-downloaded! payload)
(emit-completed! win)))
error-handler
(fn [error]
(logger/warn "[updater/error]" error)
(emit-update! win "error" (normalize-error error))
(emit-completed! win))]
(.on autoUpdater "checking-for-update" checking-handler)
(.on autoUpdater "update-available" available-handler)
(.on autoUpdater "update-not-available" not-available-handler)
(.on autoUpdater "download-progress" progress-handler)
(.on autoUpdater "update-downloaded" downloaded-handler)
(.on autoUpdater "error" error-handler)
#(do
(.off autoUpdater "checking-for-update" checking-handler)
(.off autoUpdater "update-available" available-handler)
(.off autoUpdater "update-not-available" not-available-handler)
(.off autoUpdater "download-progress" progress-handler)
(.off autoUpdater "update-downloaded" downloaded-handler)
(.off autoUpdater "error" error-handler))))
(defn- <check-for-updates!
[^js win auto-download?]
(debug "check-for-updates" {:auto-download? auto-download?})
(set! (.-autoDownload autoUpdater) auto-download?)
(-> (.checkForUpdates autoUpdater)
(.then
(fn [_]
;; Manual checks without auto download need an explicit terminal event.
(when-not auto-download?
(emit-completed! win))))
(.catch
(fn [error]
(logger/warn "[updater/check]" error)
(emit-update! win "error" (normalize-error error))
(emit-completed! win)))))
(defn- init-auto-updater!
[^js win]
(when (and prod? (not= false (cfgs/get-item :auto-update)))
(debug "init-auto-updater")
(set! (.-autoDownload autoUpdater) true)
(-> (.checkForUpdates autoUpdater)
(.catch (fn [error]
(logger/warn "[updater/auto-check]" error)
(emit-update! win "error" (normalize-error error))
(emit-completed! win))))))
(defn init-updater
[{:keys [repo ^js _win] :as opts}]
(and prod? (not= false (cfgs/get-item :auto-update)) (init-auto-updater repo))
(let [check-channel "check-for-updates"
[{:keys [^js win] :as _opts}]
(configure-auto-updater!)
(let [dispose-listeners! (register-auto-updater-listeners! win)
check-channel "check-for-updates"
install-channel "install-updates"
get-downloaded-channel "get-downloaded-update"
check-listener (fn [_e & args]
(when-not @*update-pending
(reset! *update-pending true)
(p/finally
(check-for-updates (merge opts {:args args}))
#(reset! *update-pending nil))))
install-listener (fn [_e quit-app?]
(when-let [dest-file (:dest-file @*update-ready-to-install)]
(open dest-file)
(and quit-app? (js/setTimeout #(.quit app) 1000))))]
(let [auto-download? (true? (first args))]
(-> (<check-for-updates! win auto-download?)
(.finally #(reset! *update-pending nil))))))
install-listener (fn [_e _quit-app?]
(.quitAndInstall autoUpdater false true))
get-downloaded-listener (fn [_e]
(some-> @*downloaded-update bean/->js))]
(init-auto-updater! win)
(.handle ipcMain check-channel check-listener)
(.handle ipcMain install-channel install-listener)
(.handle ipcMain get-downloaded-channel get-downloaded-listener)
#(do
(dispose-listeners!)
(.removeHandler ipcMain install-channel)
(.removeHandler ipcMain check-channel)
(.removeHandler ipcMain get-downloaded-channel)
(reset! *update-pending nil))))

View File

@@ -259,10 +259,32 @@
[:<>
(let [handle-copy!
(fn [_e]
(-> (util/copy-image-to-clipboard image-src)
(p/then #(notification/show! "Copied!" :success))
(p/catch (fn [error]
(js/console.error error)))))
;; Electron renderer cannot fetch file:// URLs; read the
;; file via IPC and copy the blob directly.
(if (util/electron?)
(let [ext (some-> (util/get-file-ext image-src) string/lower-case)
;; Should support all exts in common-config/img-formats
ext->mime {"png" "image/png"
"jpg" "image/jpeg"
"jpeg" "image/jpeg"
"gif" "image/gif"
"webp" "image/webp"
"bmp" "image/bmp"
"svg" "image/svg+xml"
"ico" "image/x-icon"}
mime (get ext->mime ext)]
(if-not mime
(notification/show! (str "Copy image is not supported for ." ext " files") :warning)
(-> (p/let [binary (fs/read-file-raw nil image-src {})
blob (js/Blob. (array binary) (clj->js {:type mime}))]
(util/copy-image-blob-to-clipboard blob))
(p/then #(notification/show! "Copied!" :success))
(p/catch (fn [error]
(js/console.error error))))))
(-> (util/copy-image-to-clipboard src')
(p/then #(notification/show! "Copied!" :success))
(p/catch (fn [error]
(js/console.error error))))))
handle-delete!
(fn [_e]
(when-let [block-id (get-blockid)]
@@ -569,7 +591,8 @@
(if (assets-handler/check-alias-path? href)
(assets-handler/normalize-asset-resource-url href)
href))]
(resizable-image config title href metadata full_text false))))))
[:div.as-plain-image-link
(resizable-image config title href metadata full_text false)])))))
(def timestamp-to-string export-common-handler/timestamp-to-string)
@@ -874,7 +897,9 @@
([config format v]
(when (string? v)
(let [inline-list (gp-mldoc/inline->edn v (mldoc/get-default-config format))]
[:div.inline.mr-1 (map-inline config inline-list)]))))
[:div.inline
(when (get config :add-margin? true) {:class "mr-1"})
(map-inline config inline-list)]))))
(defn- <get-block
[block-id]
@@ -1190,19 +1215,23 @@
(defn- render-macro
[config name arguments macro-content format]
[:div.macro {:data-macro-name name}
(if macro-content
(let [ast (->> (mldoc/->edn macro-content format)
(map first))
paragraph? (and (= 1 (count ast))
(= "Paragraph" (ffirst ast)))]
(if (and (not paragraph?)
(mldoc/block-with-title? (ffirst ast)))
(markup-elements-cp (assoc config :block/format format) ast)
(inline-text config format macro-content)))
[:span.warning {:title (str "Unsupported macro name: " name)}
(macro->text name arguments)])])
(into
[:div.macro]
(let [attributes {:data-macro-name name}]
(if macro-content
(let [ast (->> (mldoc/->edn macro-content (gp-mldoc/default-config format))
(map first))
paragraph? (and (= 1 (count ast))
(= "Paragraph" (ffirst ast)))]
(if (and (not paragraph?)
(mldoc/block-with-title? (ffirst ast)))
[attributes
(markup-elements-cp (assoc config :block/format format) ast)]
[(assoc attributes :class "inline")
(inline-text {:add-margin? false} format macro-content)]))
[attributes
[:span.warning {:title (str "Unsupported macro name: " name)}
(macro->text name arguments)]]))))
(rum/defc nested-link < rum/reactive
[config html-export? link]
@@ -1776,19 +1805,135 @@
(apply [(str uuid)])))))
(declare block-list)
(rum/defc block-children < rum/reactive
(defn- should-defer-block-children-render?
[config children-count anchor]
(let [has-anchor? (not (string/blank? anchor))]
(and
(pos? children-count)
(number? (:defer-ready-index config))
(:current-page? config)
;; Defer only the first level under current page blocks.
;; Deeper levels render immediately after their parent subtree is released,
;; preserving top-to-bottom perception instead of level-by-level batches.
(= 1 (:level config))
(not (or has-anchor?
(:ref? config)
(:custom-query? config)
(:sidebar? config)
(:embed? config)
(:library? config)
(:document/mode? config))))))
;; Progressive root rendering should kick in for pages with either many root
;; blocks or heavy expanded descendant trees.
(def ^:private defer-root-render-batch-size 1)
(def ^:private defer-children-initial-render-budget 12)
(def ^:private defer-children-render-batch-budget 12)
(defn- should-defer-root-block-render?
[config root-block blocks anchor]
(let [has-anchor? (not (string/blank? anchor))
page-blocks-count (count (:block/_page root-block))
children-blocks-count (count blocks)]
(and
(:current-page? config)
(zero? (or (:level config) 0))
(not (or has-anchor?
(:ref? config)
(:custom-query? config)
(:sidebar? config)
(:embed? config)
(:library? config)
(:document/mode? config)))
(>= (- page-blocks-count children-blocks-count) 30))))
(defn- defer-placeholder-element
[]
(js/React.createElement "div"
#js {:style #js {:minHeight 28}}))
(defn- deferred-child-render-cost
[child]
(if (seq (:block/children child)) 4 1))
(defn- deferred-children-visible-count
[children ready-budget]
(let [children-count (count children)]
(loop [idx 0
budget (max 0 ready-budget)]
(if (or (>= idx children-count)
(<= budget 0))
idx
(let [cost (deferred-child-render-cost (nth children idx))]
(if (>= budget cost)
(recur (inc idx) (- budget cost))
;; Keep one child visible for progressive feedback.
(if (zero? idx) 1 idx)))))))
(rum/defc block-children
[config block children collapsed?]
(let [ref? (:ref? config)
query? (:custom-query? config)
library? (:library? config)
children (when (coll? children)
(let [ref-matched-children-ids (:ref-matched-children-ids config)]
(cond->> (remove nil? children)
ref-matched-children-ids
;; Block children will not be rendered if the filters do not match them
(filter (fn [b] (ref-matched-children-ids (:db/id b))))
library?
(filter (fn [b] (and (ldb/page? b) (not (or (ldb/class? b) (ldb/property? b)))))))))]
(into []
(cond->> (remove nil? children)
ref-matched-children-ids
;; Block children will not be rendered if the filters do not match them
(filter (fn [b] (ref-matched-children-ids (:db/id b))))
library?
(filter (fn [b] (and (ldb/page? b) (not (or (ldb/class? b) (ldb/property? b))))))))))
children-count (count children)
anchor (get-in (state/get-route-match) [:query-params :anchor])
defer-render? (should-defer-block-children-render? config children-count anchor)
defer-ready-index (:defer-ready-index config)
defer-index (or (:defer-top-index config) 0)
render-children? (or (not defer-render?)
(<= defer-index defer-ready-index))
*defer-children-render-complete-by-root (:defer-children-render-complete-by-root* config)
fallback-children-ready-budget* (hooks/use-memo #(atom defer-children-initial-render-budget) [])
*children-ready-budget fallback-children-ready-budget*
[children-ready-budget] (hooks/use-atom *children-ready-budget)
visible-children-count (if defer-render?
(deferred-children-visible-count children children-ready-budget)
children-count)
children-max-render-budget (* children-count 4)
children-fully-rendered? (or collapsed?
(>= visible-children-count children-count))
children' (if (and defer-render? render-children?)
(subvec children 0 visible-children-count)
children)]
(hooks/use-effect!
(fn []
(when (and defer-render?
render-children?
(number? defer-index)
*defer-children-render-complete-by-root)
(swap! *defer-children-render-complete-by-root
assoc
defer-index
children-fully-rendered?))
(if (and defer-render?
render-children?
(< visible-children-count children-count))
(let [raf-id (js/requestAnimationFrame
(fn []
(swap! *children-ready-budget
(fn [v]
(min children-max-render-budget
(+ v defer-children-render-batch-budget))))))]
#(js/cancelAnimationFrame raf-id))
(fn [])))
[defer-render?
render-children?
defer-index
children-fully-rendered?
visible-children-count
children-count
children-max-render-budget
*children-ready-budget
*defer-children-render-complete-by-root])
(when (and (coll? children)
(seq children)
(not collapsed?))
@@ -1804,7 +1949,8 @@
(assoc :block-children? true)
(integer? (:block-level config))
(update :block-level inc))]
(block-list config' children))]])))
(when render-children?
(block-list config' children')))]])))
(defn- block-content-empty?
[block]
@@ -1991,8 +2137,6 @@
item-content (.. target -nextSibling -data)]
(editor-handler/toggle-list-checkbox block item-content)))}))
(declare block-content)
(declare src-cp)
(rum/defc ^:large-vars/cleanup-todo text-block-title
@@ -3643,8 +3787,6 @@
(gp-mldoc/inline->edn title
(mldoc/get-default-config :markdown))))
(declare ->hiccup)
(defn- get-code-mode-by-lang
[lang]
(some (fn [m] (when (= (.-name m) lang) (.-mode m))) js/window.CodeMirror.modeInfo))
@@ -3794,8 +3936,9 @@
[:div.warning "#+BEGIN_QUOTE is deprecated. Use '/Quote' command instead."]
["Raw_Html" content]
(when (not html-export?)
[:div.raw_html {:dangerouslySetInnerHTML
{:__html (security/sanitize-html content)}}])
[:div.raw_html.inline-block
{:dangerouslySetInnerHTML
{:__html (security/sanitize-html content)}}])
["Export" "html" _options content]
(when (not html-export?)
[:div.export_html {:dangerouslySetInnerHTML
@@ -3804,8 +3947,9 @@
(ui/catch-error
[:div.warning {:title "Invalid hiccup"}
content]
[:div.hiccup_html {:dangerouslySetInnerHTML
{:__html (hiccup->html content)}}])
[:div.hiccup_html.inline
{:dangerouslySetInnerHTML
{:__html (hiccup->html content)}}])
["Export" "latex" _options content]
(if html-export?
@@ -3917,24 +4061,59 @@
(when linked-block
(str "-" (:block/uuid original-block))))))))
(rum/defc block-list
(rum/defc ^:large-vars/cleanup-todo block-list
[config blocks]
(let [[virtualized? _] (hooks/use-state (not (or (util/rtc-test?)
(let [blocks-count (count blocks)
root-block (when-let [id (:db/id config)]
(db/entity id))
[virtualized? _] (hooks/use-state (not (or (util/rtc-test?)
(and (util/mobile?) (:journals? config))
(if (:journals? config)
(< (count blocks) 50)
(< (count blocks) 10))
(< blocks-count 50)
(< blocks-count 10))
(and (:block-children? config)
;; zoom-in block's children
(not (and (:id config) (= (:id config) (str (:block/uuid (:block/parent (first blocks)))))))))))
root-level? (zero? (or (:level config) 0))
anchor (get-in (state/get-route-match) [:query-params :anchor])
fallback-ready-index* (hooks/use-memo #(atom -1) [])
fallback-children-complete-by-root* (hooks/use-memo #(atom {}) [])
*defer-ready-index (or (:defer-children-ready-index* config)
fallback-ready-index*)
*defer-children-render-complete-by-root (or (:defer-children-render-complete-by-root* config)
fallback-children-complete-by-root*)
[defer-ready-index] (hooks/use-atom *defer-ready-index)
[defer-children-render-complete-by-root] (hooks/use-atom *defer-children-render-complete-by-root)
defer-root-render? (should-defer-root-block-render? config root-block blocks anchor)
current-root-block (when (<= 0 defer-ready-index (dec blocks-count))
(nth blocks defer-ready-index))
current-root-children-need-deferring? (and current-root-block
(not (util/collapsed? current-root-block))
(should-defer-block-children-render?
(assoc config :level 1 :defer-ready-index defer-ready-index)
(count (:block/children current-root-block))
anchor))
current-root-children-rendered? (or (neg? defer-ready-index)
(not current-root-children-need-deferring?)
(true? (get defer-children-render-complete-by-root
defer-ready-index)))
root-item-visible? (fn [idx]
(or (not defer-root-render?)
(<= idx defer-ready-index)))
render-item (fn [idx]
(let [top? (zero? idx)
bottom? (= (dec (count blocks)) idx)
block (nth blocks idx)]
(block-item (assoc config :top? top?)
block
{:top? top?
:bottom? bottom?})))
bottom? (= (dec blocks-count) idx)
block (nth blocks idx)
config' (cond-> (assoc config :top? top?)
(and root-level? defer-root-render?) (assoc :defer-top-index idx)
(and root-level? defer-root-render?) (assoc :defer-ready-index defer-ready-index)
(and root-level? defer-root-render?) (assoc :defer-children-render-complete-by-root* *defer-children-render-complete-by-root))]
(if (and root-level? (not (root-item-visible? idx)))
(defer-placeholder-element)
(block-item config'
block
{:top? top?
:bottom? bottom?}))))
virtualized? (and virtualized? (seq blocks))
*virtualized-ref (hooks/use-ref nil)
virtual-opts (when virtualized?
@@ -3949,16 +4128,47 @@
;; Leave some space for the new inserted block
:increase-viewport-by 254
:overscan 254
:total-count (count blocks)
:total-count blocks-count
:item-content (fn [idx]
(let [top? (zero? idx)
bottom? (= (dec (count blocks)) idx)
block (nth blocks idx)]
(block-item (assoc config :top? top?)
block
{:top? top?
:bottom? bottom?})))})
bottom? (= (dec blocks-count) idx)
block (nth blocks idx)
config' (cond-> (assoc config :top? top?)
(and root-level? defer-root-render?) (assoc :defer-top-index idx)
(and root-level? defer-root-render?) (assoc :defer-ready-index defer-ready-index)
(and root-level? defer-root-render?) (assoc :defer-children-render-complete-by-root* *defer-children-render-complete-by-root))]
(if (and root-level? (not (root-item-visible? idx)))
(defer-placeholder-element)
(block-item config'
block
{:top? top?
:bottom? bottom?}))))})
*wrap-ref (hooks/use-ref nil)]
(hooks/use-effect!
(fn []
(let [last-idx (dec blocks-count)]
(if (and defer-root-render?
current-root-children-rendered?
(< defer-ready-index last-idx))
(let [raf-id (js/requestAnimationFrame
(fn []
(swap! *defer-ready-index
(fn [v]
(let [next-v (min (dec blocks-count)
(+ v defer-root-render-batch-size))]
(swap! *defer-children-render-complete-by-root
assoc
next-v
false)
next-v)))))]
#(js/cancelAnimationFrame raf-id))
(fn []))))
[defer-root-render?
defer-ready-index
current-root-children-rendered?
blocks-count
*defer-ready-index
*defer-children-render-complete-by-root])
(hooks/use-effect!
(fn []
(when virtualized?
@@ -3999,10 +4209,15 @@
(rum/defcs blocks-container < mixins/container-id rum/static
{:init (fn [state]
(assoc state ::id (str (random-uuid))))}
(assoc state
::id (str (random-uuid))
::defer-children-ready-index* (atom -1)
::defer-children-render-complete-by-root* (atom {})))}
[state config blocks]
(let [doc-mode? (:document/mode? config)
id (::id state)]
id (::id state)
*defer-children-ready-index (::defer-children-ready-index* state)
*defer-children-render-complete-by-root (::defer-children-render-complete-by-root* state)]
(when (seq blocks)
[:div.blocks-container.flex-1
{:id id
@@ -4010,6 +4225,8 @@
:containerid (:container-id state)}
(block-list (assoc config
:blocks-node-id id
:defer-children-ready-index* *defer-children-ready-index
:defer-children-render-complete-by-root* *defer-children-render-complete-by-root
:container-id (:container-id state))
blocks)])))

View File

@@ -158,6 +158,9 @@
.block-children-container {
position: relative;
margin-left: 29px;
/* Keep vertical rhythm stable when a sibling becomes a nested child. */
padding-top: 0.125rem;
margin-bottom: -0.125rem;
}
.block-children-left-border {
@@ -1224,8 +1227,20 @@ html.is-mac {
}
}
.property-value .ls-resize-image {
max-width: 35%;
.property-value {
.ls-resize-image {
max-width: 35%;
}
.as-plain-image-link {
.ls-resize-image {
max-width: 80px;
}
.image-resize {
display: none;
}
}
}
.recent-block {

View File

@@ -9,6 +9,33 @@
@apply p-0 w-auto !max-w-fit overflow-hidden;
}
/* Remove input focus glow/ring for cmdk search box only */
.cp__cmdk-input-row:focus-within,
.cp__cmdk-search-input,
.cp__cmdk-search-input:focus,
.cp__cmdk-search-input:focus-visible {
box-shadow: none !important;
outline: none !important;
}
.cp__cmdk-search-input:focus,
.cp__cmdk-search-input:focus-visible {
border-color: transparent !important;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
}
/* Cmd+K input text selection color should stay theme-neutral in dark mode */
.cp__cmdk-search-input::selection {
color: var(--lx-gray-12, var(--ls-primary-text-color, inherit));
background: var(--lx-gray-06, var(--ls-tertiary-background-color, rgba(255, 255, 255, 0.16)));
}
.cp__cmdk-search-input::-moz-selection {
color: var(--lx-gray-12, var(--ls-primary-text-color, inherit));
background: var(--lx-gray-06, var(--ls-tertiary-background-color, rgba(255, 255, 255, 0.16)));
}
.cp__cmdk-current-page-badge {
@apply inline-flex items-center rounded-full border text-xs font-medium leading-none;
padding: 2px 8px;
@@ -17,20 +44,32 @@
border-color: var(--lx-gray-06, var(--ls-border-color, rgba(0, 0, 0, 0.12)));
}
[data-cmdk-item] {
border-radius: 0.5rem;
margin-inline: 2px;
}
/* Keyboard navigation highlight */
[data-cmdk-item][data-kb-highlighted] {
background-color: var(--lx-gray-03, var(--ls-a-chosen-bg, var(--ls-tertiary-background-color, rgba(0, 0, 0, 0.10))));
box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0.07), inset 0 0 0 2px var(--lx-accent-09, #3b82f6);
box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0.07), inset 0 0 0 1px var(--lx-accent-03, #3b82f6);
}
/* Mouse hover — item not selected */
[data-cmdk-item][data-hoverable]:not([data-highlighted]):hover {
background-color: var(--lx-gray-03, var(--ls-a-chosen-bg, var(--ls-tertiary-background-color, rgba(0, 0, 0, 0.10))));
box-shadow: inset 0 0 0 1px var(--ls-border-color, var(--lx-gray-08, rgba(0, 0, 0, 0.24)));
box-shadow: inset 0 0 0 1px var(--ls-border-color, var(--lx-gray-03, rgba(0, 0, 0, 0.24)));
}
/* Mouse hover — item selected (stronger border) */
/* Mouse hover — item selected */
[data-cmdk-item][data-hoverable][data-highlighted]:hover {
background-color: var(--lx-gray-03, var(--ls-a-chosen-bg, var(--ls-tertiary-background-color, rgba(0, 0, 0, 0.10))));
box-shadow: inset 0 0 0 1px var(--ls-border-color, var(--lx-gray-09, rgba(0, 0, 0, 0.32)));
}
/* Dark theme: remove cmdk item outline/border effect */
.dark [data-cmdk-item][data-kb-highlighted],
.dark [data-cmdk-item][data-hoverable]:not([data-highlighted]):hover,
.dark [data-cmdk-item][data-hoverable][data-highlighted]:hover {
box-shadow: none;
}

View File

@@ -1075,9 +1075,9 @@
(when timeout-id
(js/clearTimeout timeout-id)))))
[])
[:div {:class "bg-gray-02 border-b border-1 border-gray-07"}
[:div.cp__cmdk-input-row {:class "bg-gray-02 border-b border-1 border-gray-07"}
[:input.cp__cmdk-search-input
{:class "text-xl bg-transparent border-none w-full outline-none px-3 py-3"
{:class "text-xl bg-transparent !border-none w-full !outline-none !shadow-none px-3 py-3 focus:!border-none focus:!outline-none focus:!shadow-none focus-visible:!outline-none focus-visible:!shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0"
:auto-focus true
:autoComplete "off"
:autoCapitalize "off"
@@ -1299,4 +1299,3 @@
(rum/defc cmdk-block [props]
[:div {:class "cp__cmdk__block rounded-md"}
(cmdk props)])

View File

@@ -220,7 +220,6 @@
(rum/defc help-menu-popup
[]
(hooks/use-effect!
(fn []
(state/set-state! :ui/handbooks-open? false))
@@ -273,6 +272,16 @@
(when handbooks-open?
(handbooks/handbooks-popup))]))
(defn- context-menu-click-should-hide?
[target]
(let [menu-item (some-> target (.closest "[role='menuitem']"))
submenu-trigger? (= "menu" (some-> menu-item (.getAttribute "aria-haspopup")))]
(boolean
(and target
(not (util/input? target))
menu-item
(not submenu-trigger?)))))
(rum/defc app-context-menu-observer
< rum/static
(mixins/event-mixin
@@ -292,10 +301,8 @@
(fn [{:keys [id]}]
[:div {:on-click (fn [^js e]
(when-let [target (.-target e)]
(let [input? (util/input? target)
popup? (.closest target "[data-radix-popper-content-wrapper]")]
(when (not (or input? popup?))
(shui/popup-hide! id)))))
(when (context-menu-click-should-hide? target)
(shui/popup-hide! id))))
:data-keep-selection true}
content])
(merge

View File

@@ -2,11 +2,13 @@
(:require [cljs-time.coerce :as tc]
[cljs.pprint :as pp]
[clojure.string :as string]
[electron.ipc :as ipc]
[frontend.commands :as commands]
[frontend.components.editor :as editor]
[frontend.components.export :as export]
[frontend.components.icon :as icon-component]
[frontend.components.page-menu :as page-menu]
[frontend.config :as config]
[frontend.context.i18n :refer [t]]
[frontend.db :as db]
[frontend.extensions.fsrs :as fsrs]
@@ -23,6 +25,7 @@
[frontend.util.url :as url-util]
[goog.dom :as gdom]
[goog.object :as gobj]
[logseq.common.path :as path]
[logseq.common.util :as common-util]
[logseq.db :as ldb]
[logseq.shui.ui :as shui]
@@ -240,6 +243,16 @@
(editor-handler/copy-block-ref! block-id tap-f)))}
(t :content/copy-block-url)))
(when (and (util/electron?) (ldb/asset? block))
(shui/dropdown-menu-item
{:key "Show asset in folder"
:on-click (fn [_e]
(let [assets-dir (config/get-current-repo-assets-root)
ext (name (:logseq.property.asset/type block))
file-path (path/path-join assets-dir (str (:block/uuid block) "." ext))]
(ipc/ipc "openFileInFolder" file-path)))}
(t :asset/show-file-in-folder)))
(shui/dropdown-menu-item
{:key "Copy as"
:on-click (fn [_]

View File

@@ -4,6 +4,7 @@
[cljs-time.core :as t]
[clojure.string :as string]
[dommy.core :as d]
[electron.ipc :as ipc]
[frontend.common.missionary :as c.m]
[frontend.components.block :as component-block]
[frontend.components.export :as export]
@@ -35,6 +36,7 @@
[logseq.shui.ui :as shui]
[logseq.shui.util :as shui-util]
[missionary.core :as m]
[promesa.core :as p]
[reitit.frontend.easy :as rfe]
[rum.core :as rum]))
@@ -244,8 +246,17 @@
(let [[downloaded, set-downloaded] (rum/use-state nil)
_ (hooks/use-effect!
(fn []
(when-let [channel (and (util/electron?) "auto-updater-downloaded")]
(let [callback (fn [_ args]
(when (util/electron?)
(-> (ipc/invoke "get-downloaded-update")
(p/then
(fn [args]
(when args
(let [args (bean/->clj args)]
(set-downloaded args)
(state/set-state! :electron/auto-updater-downloaded args)))))
(p/catch (fn [_] nil)))
(let [channel "auto-updater-downloaded"
callback (fn [_ args]
(js/console.debug "[new-version downloaded] args:" args)
(let [args (bean/->clj args)]
(set-downloaded args)
@@ -347,11 +358,25 @@
(ldb/page? page) (:block/parent page))
[:div.ls-block-breadcrumb
[:div.text-sm
(component-block/breadcrumb {}
(component-block/breadcrumb {}
(state/get-current-repo)
(:block/uuid page)
{:header? true})]])))
(rum/defc search-index-progress < rum/reactive
[]
(let [current-repo (state/get-current-repo)
{:keys [running? repo progress]} (or (state/sub :search/index-build) {})
progress' (-> (or progress 0)
(max 0)
(min 100))]
(when (and running? (= repo current-repo))
[:div.search-index-progress
[ui/loading ""]
[:span.search-index-progress__text (str "Indexing " progress' "%")]
[:div.search-index-progress__bar
[:div.search-index-progress__bar-fill {:style {:width (str progress' "%")}}]]])))
(rum/defc ^:large-vars/cleanup-todo header-aux < rum/reactive
[{:keys [current-repo default-home new-block-mode]}]
(let [electron-mac? (and util/mac? (util/electron?))
@@ -414,6 +439,7 @@
(rtc-indicator/downloading-detail))
(when (user-handler/logged-in?)
(rtc-indicator/uploading-detail))
(search-index-progress)
(when (and (not= (state/get-current-route) :home)
(not custom-home-page?))

View File

@@ -341,4 +341,24 @@ html.is-zoomed-native-ios {
max-width: 34ch;
}
}
.search-index-progress {
@apply flex items-center gap-2 rounded px-2 py-1 text-xs opacity-90;
-webkit-app-region: no-drag;
background-color: var(--ls-tertiary-background-color);
}
.search-index-progress__text {
@apply whitespace-nowrap;
}
.search-index-progress__bar {
@apply h-1 w-16 overflow-hidden rounded;
background-color: var(--ls-quaternary-background-color);
}
.search-index-progress__bar-fill {
@apply h-full transition-all duration-200;
background-color: var(--ls-link-text-color);
}
}

View File

@@ -157,6 +157,16 @@
(add-button-inner block (assoc config :editing? editing?))))
(rum/defcs page-blocks-cp < rum/reactive db-mixins/query
{:did-mount (fn [state]
(when-let [on-page-blocks-rendered (some-> (last (:rum/args state))
:on-page-blocks-rendered)]
(on-page-blocks-rendered))
state)
:did-update (fn [state]
(when-let [on-page-blocks-rendered (some-> (last (:rum/args state))
:on-page-blocks-rendered)]
(on-page-blocks-rendered))
state)}
[state block* {:keys [sidebar? hide-add-button? journals?] :as config}]
(when-let [id (:db/id block*)]
(let [block (db/sub-block id)
@@ -337,6 +347,18 @@
(when-let [path-page-name (get-path-page-name state page-name)]
(util/page-name-sanity-lc path-page-name)))
(rum/defcs on-mounted <
{:did-mount (fn [state]
(when-let [f (last (:rum/args state))]
(f))
state)
:did-update (fn [state]
(when-let [f (last (:rum/args state))]
(f))
state)}
[state child _on-mounted]
child)
(rum/defc lsp-pagebar-slot <
rum/static
[]
@@ -382,11 +404,13 @@
(when class?
(shui/tabs-content
{:value "tag"}
(objects/class-objects page opts)))
(on-mounted (objects/class-objects page opts)
(:on-tagged-nodes-rendered opts))))
(when property?
(shui/tabs-content
{:value "property"}
(objects/property-related-objects page opts))))]))
(on-mounted (objects/property-related-objects page opts)
(:on-tagged-nodes-rendered opts)))))]))
(rum/defc sidebar-page-properties
[config page]
@@ -408,9 +432,14 @@
;; A page is just a logical block
(rum/defcs ^:large-vars/cleanup-todo page-inner < rum/reactive db-mixins/query mixins/container-id
(rum/local nil ::current-page)
(rum/local nil ::linked-refs-blocks-ready-page-id)
(rum/local nil ::linked-refs-tagged-ready-page-id)
[state {:keys [repo page preview? sidebar? tag-dialog? linked-refs? unlinked-refs? config journals?] :as option}]
(let [current-repo (state/sub :git/current-repo)
linked-refs-blocks-ready-page-id (get state ::linked-refs-blocks-ready-page-id)
linked-refs-tagged-ready-page-id (get state ::linked-refs-tagged-ready-page-id)
page (or page (some-> (:db/id option) db/entity))
page-id (:db/id page)
config (assoc config
:id (str (:block/uuid page)))
repo (or repo current-repo)
@@ -427,7 +456,14 @@
(= title (date/journal-name)))
home? (= :home (state/get-current-route))
recycled? (ldb/recycled? page)
show-tabs? (and (or class-page? (ldb/property? page)) (not tag-dialog?))]
show-tabs? (and (or class-page? (ldb/property? page)) (not tag-dialog?))
blocks-ready? (or journals?
(= page-id @linked-refs-blocks-ready-page-id))
tagged-ready? (or (not show-tabs?)
(= page-id @linked-refs-tagged-ready-page-id)
;; Fallback to avoid blocking refs forever when tab content is reused.
(= page-id @linked-refs-blocks-ready-page-id))
linked-refs-ready? (and blocks-ready? tagged-ready?)]
(if page
(when (or title block?)
(if recycled?
@@ -441,9 +477,10 @@
{:data-page-tags (text-util/build-data-value page-names)}))
{})
{:key title
:class (util/classnames [{:is-journals (or journal? fmt-journal?)
:is-node-page (or class-page? property-page?)}])})
{:key title
:class (util/classnames [{:is-journals (or journal? fmt-journal?)
:is-today-page (and (not home?) (boolean today?))
:is-node-page (or class-page? property-page?)}])})
[:div.relative.grid.gap-4.sm:gap-8.page-inner.mb-16
(when-not (or block? sidebar?)
@@ -467,16 +504,21 @@
(sidebar-page-properties config page)])
(when show-tabs?
(tabs page {:current-page? option :sidebar? sidebar?}))
(tabs page {:current-page? option
:sidebar? sidebar?
:on-tagged-nodes-rendered #(when-not (= @linked-refs-tagged-ready-page-id page-id)
(reset! linked-refs-tagged-ready-page-id page-id))}))
(when (not tag-dialog?)
(if recycle-page?
(recycle/recycle-page page)
(recycle/recycle-page page {:class "ls-recycle-page-title-compact"})
[: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?
:on-page-blocks-rendered #(when-not (= @linked-refs-blocks-ready-page-id page-id)
(reset! linked-refs-blocks-ready-page-id page-id))
:container-id (:container-id state)}))]))]
(when-not (or preview? recycle-page?)
@@ -492,7 +534,9 @@
(class-component/class-children page))
;; referenced blocks
(when-not (or tag-dialog? linked-refs?)
(when (and linked-refs-ready?
(not tag-dialog?)
(not linked-refs?))
[:div.fade-in.delay {:key "page-references"}
(rum/with-key
(reference/references page {:sidebar? sidebar?
@@ -994,7 +1038,7 @@
(ui/icon "alert-triangle")]]
[:div.mt-3.text-center.sm:mt-0.sm:ml-4.sm:text-left
[:h3#modal-headline.text-lg.leading-6.font-medium
(t :page/delete-confirmation)]]]
(t :page/batch-delete-confirmation)]]]
[:ol.p-2.pt-4
(for [page-item pages]

View File

@@ -240,6 +240,16 @@ html.is-native-ios {
@apply min-h-[60px] overflow-hidden;
}
/* Recycle page only: reduce the gap between "Recycle" title and its description label.
Scoped class so other pages are unaffected. */
.ls-recycle-page-title-compact {
margin-top: var(--ls-recycle-page-title-description-gap-offset, -1.25rem);
}
.ls-recycle-page-description {
margin-bottom: var(--ls-recycle-page-description-margin-bottom, 1rem);
}
.block-add-button {
&.selected {
opacity: 1 !important;

View File

@@ -71,7 +71,9 @@
{:title [:h3.text-lg.leading-6.font-medium.flex.gap-2.items-center
[:span.top-1.relative
(shui/tabler-icon "alert-triangle")]
(t :page/db-delete-confirmation)]
(if (or (ldb/class? page) (ldb/property? page))
(t :page/permanently-delete-confirmation)
(t :page/db-delete-confirmation))]
:content [:p.opacity-60 (str "- " (:block/title page))]
:outside-cancel? true})
(p/then #(delete-page! page))

View File

@@ -141,7 +141,7 @@
(fn [^js e]
(case (keyword (aget e "name"))
:IllegalPluginPackageError
(notification/show! "Illegal Logseq plugin package." :error)
(plugin-handler/show-illegal-plugin-package-notification! e)
:ExistedImportedPluginPackageError
(notification/show! (str "Existed plugin package (" (.-message e) ").") :error)
:default)
@@ -1580,6 +1580,35 @@
:align :start
:id "ls-focused-settings-modal"}))
;; tools for user registered host renderers
(rum/defc renderer-container
[{:keys [_pid render] :as opts}]
(if (fn? render)
[:div.lsp-host-renderer-container
(render opts)]
[:pre (pr-str opts)]))
(rum/defc renderer-resolver < rum/static
[nskey']
(when-let [pid (some-> nskey' (namespace))]
(let [key (name nskey')
[renderer set-renderer!] (rum/use-state nil)]
(hooks/use-effect!
(fn []
(try
(when-let [renderer (plugin-handler/resolve-hosted-render pid key :sidebar)]
(let [r (bean/->clj renderer)
title (:title r)]
(when-let [^js dom (and title (js/document.getElementById nskey'))]
(set! (. dom -textContent) title))
(set-renderer! r)))
(catch js/Error e (js/console.error "Failed to resolve renderer:" nskey' e))))
[pid key])
(when renderer
(renderer-container renderer)))))
(defn hook-custom-routes
[routes]
(cond-> routes

View File

@@ -975,3 +975,7 @@ body[data-page=plugins] {
padding-right: 30px;
}
}
.lsp-host-renderer-container {
user-select: text;
}

View File

@@ -546,7 +546,8 @@
[:div.property-key property-key-cp'])
(let [property-desc (when-not (= (:db/ident property) :logseq.property/description)
(:logseq.property/description property))]
(:logseq.property/description property))
block' (assoc block (:db/ident property) v)]
[:div.ls-block.property-value-container.flex.flex-row.gap-1
{:class (if (contains? #{:checkbox :date :datetime} type)
"items-center"
@@ -560,7 +561,67 @@
[:div.property-value.flex.flex-1
(if (:class-schema? opts)
(pv/property-value property (db/entity :logseq.property/description) opts)
(pv/property-value block property opts))]]])]))))
(pv/property-value block' property opts))]]])]))))
(defn- entity-ref-value?
[value]
(and (map? value)
(or (contains? value :db/id)
(contains? value :block/uuid))))
(defn- contains-recycled-entity-value?
[value]
(cond
(entity-ref-value? value)
(ldb/recycled? value)
(and (coll? value) (not (map? value)))
(some (fn [item]
(and (entity-ref-value? item)
(ldb/recycled? item)))
value)
:else
false))
(defn- filter-recycled-entity-values
[value]
(let [active-entity-value? (fn [item]
(or (not (entity-ref-value? item))
(not (ldb/recycled? item))))]
(cond
(and (entity-ref-value? value) (ldb/recycled? value))
nil
(set? value)
(let [value' (set (filter active-entity-value? value))]
(when (seq value') value'))
(vector? value)
(let [value' (vec (filter active-entity-value? value))]
(when (seq value') value'))
(and (coll? value) (not (map? value)))
(let [value' (vec (filter active-entity-value? value))]
(when (seq value') value'))
:else
value)))
(defn- sanitize-property-values-for-display
[properties]
(reduce-kv
(fn [{:keys [properties recycled-only-property-ids] :as result} property-id property-value]
(let [property-value' (filter-recycled-entity-values property-value)]
(if (and (nil? property-value')
(contains-recycled-entity-value? property-value))
(assoc result
:properties (assoc properties property-id nil)
:recycled-only-property-ids (conj recycled-only-property-ids property-id))
(assoc result :properties (assoc properties property-id property-value')))))
{:properties {}
:recycled-only-property-ids #{}}
properties))
(rum/defc ordered-properties
[block properties* sorted-property-entities opts]
@@ -603,7 +664,7 @@
(let [prev-order (db-order/get-prev-order (db/get-db) nil (:db/id over))]
(db-order/gen-key prev-order over-order)))]
(db/transact! (state/get-current-repo)
[{:db/id (:db/id active)
[{:block/uuid (:block/uuid active)
:block/order new-order}
(outliner-core/block-with-updated-at
{:db/id (:db/id block)})]
@@ -656,11 +717,13 @@
(and show?
(or (= mode :global)
(and (set? ids) (contains? ids (:block/uuid block))))))
properties (cond-> (:block/properties block)
(and (ldb/class? block)
(not (ldb/built-in? block)))
(assoc :logseq.property.class/enable-bidirectional?
(:logseq.property.class/enable-bidirectional? block)))
properties* (cond-> (:block/properties block)
(and (ldb/class? block)
(not (ldb/built-in? block)))
(assoc :logseq.property.class/enable-bidirectional?
(:logseq.property.class/enable-bidirectional? block)))
{:keys [properties recycled-only-property-ids]}
(sanitize-property-values-for-display properties*)
remove-built-in-or-other-position-properties
(fn [properties show-in-hidden-properties?]
(remove (fn [property]
@@ -686,6 +749,7 @@
{:keys [all-classes classes-properties]} (outliner-property/get-block-classes-properties (db/get-db) (:db/id block))
classes-properties-set (set (map :db/ident classes-properties))
block-own-properties (->> properties
(remove (fn [[id _]] (contains? recycled-only-property-ids id)))
(remove (fn [[id _]] (classes-properties-set id))))
state-hide-empty-properties? (:ui/hide-empty-properties? (state/get-config))
;; This section produces own-properties and full-hidden-properties
@@ -696,7 +760,7 @@
show-empty-and-hidden-properties?
false
state-hide-empty-properties?
(nil? (get block property-id))
(nil? (get properties property-id))
(and (:logseq.property/hide-empty-value property)
(nil? (get properties property-id)))
true
@@ -732,11 +796,15 @@
(into result cur-properties)
result)))
result))
class-property-pairs (->> class-properties
(map (fn [p] [p (get properties p)]))
(remove (fn [[property-id _]]
(contains? recycled-only-property-ids property-id))))
full-properties (-> (concat block-own-properties'
(remove property-hide-f (map (fn [p] [p (get block p)]) class-properties)))
(remove property-hide-f class-property-pairs))
(remove-built-in-or-other-position-properties false))
hidden-properties (-> (concat block-hidden-properties
(filter property-hide-f (map (fn [p] [p (get block p)]) class-properties)))
(filter property-hide-f class-property-pairs))
(remove-built-in-or-other-position-properties true))
root-block? (or (= (str (:block/uuid block))
(state/get-current-page))

View File

@@ -36,6 +36,10 @@
(when (contains? #{:logseq.property/status :logseq.property/priority} (:db/ident property))
(state/pub-event! [:init/commands])))
(defn- ->block-lookup-id
[block]
[:block/uuid (:block/uuid block)])
(defn- <upsert-closed-value!
"Create new closed value and returns its block UUID."
[property item]
@@ -64,7 +68,7 @@
(if-let [ent (:logseq.property/description property)]
(db/transact! (state/get-current-repo)
[(outliner-core/block-with-updated-at
{:db/id (:db/id ent) :block/title description})]
{:block/uuid (:block/uuid ent) :block/title description})]
{:outliner-op :save-block})
(when-not (string/blank? description)
(db-property-handler/set-block-property!
@@ -121,8 +125,11 @@
(if (= value :no-tag)
(toggle-fn)
(p/let [result (<create-class-if-not-exists! value)
value' (or result value)
tx-data [[(if select? :db/add :db/retract) (:db/id property) :logseq.property/classes [:block/uuid value']]]
class-uuid (or result value)
tx-data [[(if select? :db/add :db/retract)
(->block-lookup-id property)
:logseq.property/classes
[:block/uuid class-uuid]]]
_ (db/transact! (state/get-current-repo) tx-data {:outliner-op :update-property})]
(when-not multiple-choices? (toggle-fn)))))}]
@@ -468,10 +475,10 @@
(db-order/gen-key prev-order over-order)))]
(db/transact! (state/get-current-repo)
[{:db/id (:db/id active)
[{:block/uuid (:block/uuid active)
:block/order new-order}
(outliner-core/block-with-updated-at
{:db/id (:db/id property)})]
{:block/uuid (:block/uuid property)})]
{:outliner-op :save-block})))})]
(shui/dropdown-menu-separator)])

View File

@@ -313,6 +313,22 @@
(when done-choice
(db-property/property-value-content done-choice))]])]))
(defn- <resolve-journal-page-for-date
([^js d]
(<resolve-journal-page-for-date d
state/get-current-repo
db-async/<get-block
page-handler/<create!
date/js-date->journal-title))
([^js d get-current-repo-f get-block-f create-page-f journal-title-f]
(p/let [journal (journal-title-f d)
page (get-block-f (get-current-repo-f) journal {:children? false})
journal-page (when (:block/journal-day page)
page)]
(if journal-page
journal-page
(create-page-f journal {:redirect? false})))))
(rum/defcs calendar-inner < rum/reactive db-mixins/query
(rum/local (str "calendar-inner-" (js/Date.now)) ::identity)
{:init (fn [state]
@@ -353,13 +369,8 @@
select-handler!
(fn [^js d]
(when d
(p/let [journal (date/js-date->journal-title d)
page (db-async/<get-block (state/get-current-repo) journal {:children? false})
journal-page (when (:block/journal-day page)
page)]
(p/let [journal-page (<resolve-journal-page-for-date d)]
(p/do!
(when-not journal-page
(page-handler/<create! journal {:redirect? false}))
(when (fn? on-change)
(let [value (if datetime? (tc/to-long d) journal-page)]
(on-change value)))
@@ -1568,6 +1579,23 @@
(when (some? value) #{value}))]
(multiple-values-inner block property value' opts)))
(defn- resolved-property-value-for-render
[block property multiple-values?]
(let [v (get block (:db/ident property))
block-loaded? (some? (:block/uuid block))]
(or
(cond
(and multiple-values? (or (set? v) (coll? v) (nil? v)))
v
multiple-values?
#{v}
(set? v)
(first v)
:else
v)
(when block-loaded?
(:logseq.property/default-value property)))))
(rum/defcs ^:large-vars/cleanup-todo property-value < rum/reactive db-mixins/query
[state block property {:keys [show-tooltip? p-block p-property editing?]
:as opts}]
@@ -1584,18 +1612,7 @@
editor-id (str dom-id "-editor")
type (:logseq.property/type property)
multiple-values? (db-property/many? property)
v (let [v (get block (:db/ident property))]
(or
(cond
(and multiple-values? (or (set? v) (coll? v) (nil? v)))
v
multiple-values?
#{v}
(set? v)
(first v)
:else
v)
(:logseq.property/default-value property)))
v (resolved-property-value-for-render block property multiple-values?)
self-value-or-embedded? (fn [v]
(or (= (:db/id v) (:db/id block))
;; property value self embedded

View File

@@ -4,9 +4,12 @@
[datascript.core :as d]
[frontend.components.block :as component-block]
[frontend.db :as db]
[frontend.db-mixins :as db-mixins]
[frontend.db.react :as react]
[frontend.handler.editor :as editor-handler]
[frontend.handler.page :as page-handler]
[frontend.state :as state]
[frontend.util :as util]
[logseq.db :as ldb]
[logseq.shui.ui :as shui]
[rum.core :as rum]))
@@ -36,6 +39,20 @@
(map #(d/entity db %))
(sort-by :logseq.property/deleted-at #(compare %2 %1))))
(defn- sub-deleted-root-ids
[]
(when-let [repo (state/get-current-repo)]
(some-> (react/q repo
[:frontend.worker.react/recycle-roots]
{:query-fn (fn [db _]
(->> (d/q '[:find [?e ...]
:where
[?e :logseq.property/deleted-at]]
db)
vec))}
nil)
util/react)))
(defn- group-title
[db root]
(if (ldb/page? root)
@@ -59,7 +76,11 @@
(defn- deleted-root-header
[db root]
(let [user (deleted-by db root)
deleted-at (:logseq.property/deleted-at root)]
deleted-at (:logseq.property/deleted-at root)
root-uuid (:block/uuid root)
delete-message (str "Permanently delete this "
(if (ldb/page? root) "page" "block")
" from Recycle? This cannot be undone.")]
[:div.flex.items-center.justify-between.gap-4.text-xs.text-muted-foreground
[:div.flex.items-center.gap-1.min-w-0.flex-1
(deleted-by-avatar user)
@@ -68,12 +89,20 @@
(str (if (ldb/page? root) "Page" "Block")
" deleted "
(.toLocaleString (js/Date. deleted-at)))]]]
(shui/button
{:variant :ghost
:size :xs
:class "!py-0 !px-1 h-4"
:on-click #(page-handler/restore-recycled! (:block/uuid root))}
"Restore")]))
[:div.flex.items-center.gap-1
(shui/button
{:variant :ghost
:size :xs
:class "!py-0 !px-1 h-4"
:on-click #(page-handler/restore-recycled! root-uuid)}
"Restore")
(shui/button
{:variant :ghost
:size :xs
:class "!py-0 !px-1 h-4 hover:text-red-rx-09 dark:hover:text-red-rx-10 hover:bg-red-rx-04-alpha dark:hover:bg-red-rx-06-alpha"
:on-click #(when (js/confirm delete-message)
(page-handler/delete-recycled-permanently! root-uuid))}
"Delete")]]))
(defn- deleted-root-outliner
[root]
@@ -88,16 +117,23 @@
:id (str (:block/uuid root))}
root))
(rum/defc recycle-page
[_page]
(rum/defc recycle-page < rum/reactive db-mixins/query
[_page {:keys [class]}]
(let [db* (db/get-db)
groups (->> (deleted-roots db*)
root-ids (or (sub-deleted-root-ids)
[])
roots (if (seq root-ids)
(->> root-ids
(keep #(d/entity db* %))
(sort-by :logseq.property/deleted-at #(compare %2 %1)))
(deleted-roots db*))
groups (->> roots
(group-by #(group-title db* %))
(sort-by (fn [[_ roots]]
(:logseq.property/deleted-at (first roots)))
#(compare %2 %1)))]
[:div.flex.flex-col.gap-1
[:div.text-sm.text-muted-foreground.mb-4
[:div {:class (util/classnames ["flex" "flex-col" "gap-8" "ls-recycle-page-content" class])}
[:div.text-sm.text-muted-foreground.ls-recycle-page-description.ml-1
"Deleted pages and blocks stay here until restored or automatically garbage collected after 30 days."]
(if (seq groups)
(for [[title roots] groups]

View File

@@ -452,8 +452,8 @@
(state/pub-event! [:rtc/sync-app-state])
(state/<invoke-db-worker :thread-api/set-db-sync-config
{:enabled? true
:ws-url config/db-sync-ws-url
:http-base config/db-sync-http-base})
:ws-url (config/db-sync-ws-url)
:http-base (config/db-sync-http-base)})
(p/let [rsa-key-pair (state/<invoke-db-worker :thread-api/db-sync-ensure-user-rsa-keys)]
(set-e2ee-rsa-key-ensured? (some? rsa-key-pair))))
(p/catch (fn [e]

View File

@@ -8,6 +8,7 @@
[frontend.components.page :as page]
[frontend.components.profiler :as profiler]
[frontend.components.shortcut-help :as shortcut-help]
[frontend.components.plugins :as plugins]
[frontend.config :as config]
[frontend.context.i18n :refer [t]]
[frontend.date :as date]
@@ -17,6 +18,7 @@
[frontend.handler.editor :as editor-handler]
[frontend.handler.route :as route-handler]
[frontend.handler.ui :as ui-handler]
[frontend.handler.plugin :as plugin-handler]
[frontend.state :as state]
[frontend.ui :as ui]
[frontend.undo-redo.debug-ui :as undo-redo-debug-ui]
@@ -134,6 +136,12 @@
:page
(block-render)
:plugin
[[:.flex.items-center.page-title
(ui/icon "puzzle" {:class "text-md mr-2"})
[:h3 {:id db-id} (str db-id)]]
(plugins/renderer-resolver db-id)]
:search
[[:.flex.items-center.page-title
(ui/icon "search" {:class "text-md mr-2"})
@@ -423,6 +431,20 @@
:tabIndex "0"
:data-expanded sidebar-open?}]))
(rum/defc plugin-renderer-menu-items
[renderers]
(for [r renderers]
(shui/dropdown-menu-item
{:on-click #(state/sidebar-add-block!
(state/get-current-repo)
(keyword (:pid r) (:key r))
:plugin
)}
[:div.flex.items-center
{:title (str (:pid r))}
[:span.pr-1.flex.items-center (shui/tabler-icon "puzzle")]
[:strong (:title r)]])))
(rum/defcs sidebar-inner <
(rum/local false ::anim-finished?)
{:will-mount (fn [state]
@@ -438,6 +460,20 @@
{:on-drag-over util/stop}
[:div.cp__right-sidebar-topbar.flex.flex-row.justify-between.items-center
[:div.cp__right-sidebar-settings.hide-scrollbar.gap-1 {:key "right-sidebar-settings"}
;; sidebar renderers from plugins
(when-let [renderers (and config/lsp-enabled?
(some->> (plugin-handler/get-hosted-renderers)
(filter #(= (:type %) "sidebar"))
(seq)))]
[:div.text-sm
[:button.button.cp__right-sidebar-settings-btn
{:on-click (fn [e]
(shui/popup-show! e
(plugin-renderer-menu-items renderers)
{:as-dropdown? true
:content-props {:on-click (fn [] (shui/popup-hide!))}}))}
[:span.nu.flex.items-center.opacity-80 (shui/tabler-icon "cube-plus")]]])
[:div.text-sm
[:button.button.cp__right-sidebar-settings-btn {:on-click (fn [_e]
(state/sidebar-add-block! repo "contents" :contents))}

View File

@@ -122,6 +122,7 @@
[]
(let [online? (hooks/use-flow-state flows/network-online-event-flow)
[expand-debug? set-expand-debug!] (hooks/use-state false)
show-checksums? (or config/dev? util/node-test?)
{:keys [graph-uuid local-tx remote-tx local-checksum remote-checksum rtc-state
download-logs upload-logs misc-logs pending-local-ops pending-server-ops]}
(hooks/use-flow-state (m/watch *detail-info))]
@@ -148,8 +149,8 @@
graph-uuid (assoc :graph-uuid graph-uuid)
local-tx (assoc :local-tx local-tx)
remote-tx (assoc :remote-tx remote-tx)
local-checksum (assoc :local-checksum local-checksum)
remote-checksum (assoc :remote-checksum remote-checksum)
(and show-checksums? local-checksum) (assoc :local-checksum local-checksum)
(and show-checksums? remote-checksum) (assoc :remote-checksum remote-checksum)
rtc-state (assoc :rtc-state rtc-state))
pprint/pprint
with-out-str)]])

View File

@@ -76,7 +76,7 @@
(if update-pending? (t :settings-page/checking) (t :settings-page/check-for-updates))
:class "text-sm mr-1"
:disabled update-pending?
:on-click #(js/window.apis.checkForUpdates false))
:on-click #(js/window.apis.checkForUpdates true))
:else
nil)]
@@ -101,6 +101,9 @@
(string/blank? type))
[:div.update-state.text-sm
(case type
"checking-for-update"
[:p (t :settings-page/checking)]
"update-not-available"
[:p (t :settings-page/app-updated)]
@@ -114,6 +117,21 @@
(util/stop e))}
svg/external-link name " 🎉"]])
"download-progress"
(let [percent (some-> payload :percent js/Math.round)]
[:p (str "Downloading update"
(when (number? percent)
(str " " percent "%"))
"...")])
"update-downloaded"
[:div.flex.items-center.gap-2.flex-wrap
[:p (t :updater/new-version-install)]
(ui/button
(t :updater/quit-and-install)
:class "text-sm"
:on-click #(ipc/ipc :quitAndInstall))]
"error"
[:p (t :settings-page/update-error-1) [:br] (t :settings-page/update-error-2)
[:a.link
@@ -121,7 +139,9 @@
(fn [e]
(js/window.apis.openExternal "https://github.com/logseq/logseq/releases")
(util/stop e))}
svg/external-link " release channel"]])])]))
svg/external-link " release channel"]]
nil)])]))
(rum/defc outdenting-hint
[]
@@ -547,6 +567,76 @@
(config-handler/set-config! :feature/enable-flashcards? value)))
true))
(defn- push-sync-config-to-worker!
"Push the current sync URL config to the db worker so changes take effect
without restarting the app."
[]
(state/<invoke-db-worker :thread-api/set-db-sync-config
{:enabled? true
:ws-url (config/db-sync-ws-url)
:http-base (config/db-sync-http-base)}))
(rum/defc sync-server-url-settings-container
[]
(let [current-url (config/get-custom-sync-server-url)
[url set-url!] (rum/use-state (or current-url ""))
reset-url! (fn []
(config/set-custom-sync-server-url! nil)
(set-url! "")
(-> (push-sync-config-to-worker!)
(p/then #(notification/show! (t :settings-page/sync-server-url-cleared) :success))
(p/catch #(notification/show! (str "Failed to update worker: " %) :error))))]
[:div.cp__settings-sync-server-cnt
[:h1.mb-2.text-2xl.font-bold (t :settings-page/sync-server-url)]
[:div.p-2
[:p.text-sm.opacity-70.mb-4 (t :settings-page/sync-server-url-desc)]
[:p
[:label
[:strong "URL"]
[:input.form-input.is-small
{:value url
:placeholder config/default-db-sync-http-base
:style {:width "100%"}
:on-change #(set-url! (util/evalue %))}]]]
[:p.pt-2.flex.gap-2
(shui/button
{:size :sm
:on-click (fn []
(let [trimmed (string/trim url)]
(if (string/blank? trimmed)
(reset-url!)
(if-not (config/valid-sync-server-url? trimmed)
(notification/show! "URL must start with https:// or http://" :error)
(do
(config/set-custom-sync-server-url! trimmed)
(-> (push-sync-config-to-worker!)
(p/then #(notification/show! (t :settings-page/sync-server-url-saved) :success))
(p/catch #(notification/show! (str "Failed to update worker: " %) :error))))))))}
(t :save))
(when (seq url)
(shui/button
{:size :sm
:variant :outline
:on-click (fn [] (reset-url!))}
(t :settings-page/sync-server-url-reset)))]]]))
(rum/defc sync-server-url-button
[]
(let [current-url (config/get-custom-sync-server-url)]
(ui/button [:span.flex.items-center
[:span.pr-1
(if (seq current-url)
current-url
(t :settings-page/sync-server-url-default))]
(ui/icon "edit")]
:class "text-sm"
:on-click #(state/pub-event! [:go/sync-server-settings]))))
(defn sync-server-url-row []
(row-with-button-action
{:left-label (t :settings-page/sync-server-url)
:action (sync-server-url-button)}))
(rum/defc user-proxy-settings
[{:keys [type protocol host port] :as agent-opts}]
(ui/button [:span.flex.items-center
@@ -661,6 +751,7 @@
(when (and (or util/mac? util/win32?) (util/electron?)) (app-auto-update-row t))
(usage-diagnostics-row t instrument-disabled?)
(when-not (mobile-util/native-platform?) (developer-mode-row t developer-mode?))
(sync-server-url-row)
(when (util/electron?) (https-user-agent-row https-agent-opts))
(when (util/electron?) (auto-chmod-row t))
;; (clear-cache-row t)
@@ -1174,6 +1265,9 @@
:did-mount
(fn [state]
(let [active-tab (first (:rum/args state))
active-tab (if (and (= active-tab :ai) (not (util/electron?)))
:advanced
active-tab)
*active (::active state)]
(when (keyword? active-tab)
(reset! *active [active-tab nil])))
@@ -1203,8 +1297,8 @@
[:general "general" (t :settings-page/tab-general) (ui/icon "adjustments")]
[:editor "editor" (t :settings-page/tab-editor) (ui/icon "writing")]
[:keymap "keymap" (t :settings-page/tab-keymap) (ui/icon "keyboard")]
[:ai (t :settings-page/tab-ai) (t :settings-page/ai) (ui/icon "wand")]
(when (util/electron?)
[:ai (t :settings-page/tab-ai) (t :settings-page/ai) (ui/icon "wand")])
[:advanced "advanced" (t :settings-page/tab-advanced) (ui/icon "bulb")]
[:features "features" (t :settings-page/tab-features) (ui/icon "app-feature")]
@@ -1269,6 +1363,8 @@
(encryption)
:ai
(settings-ai)
(if (util/electron?)
(settings-ai)
(settings-advanced))
nil)]]]))

View File

@@ -133,6 +133,16 @@
(defonce *last-header-action-target (atom nil))
(defn- header-dropdown-click-should-hide?
[target]
(let [menu-item (some-> target (.closest "[role='menuitem']"))
submenu-trigger? (= "menu" (some-> menu-item (.getAttribute "aria-haspopup")))]
(boolean
(and target
(not (util/input? target))
menu-item
(not submenu-trigger?)))))
(defn header-cp
[{:keys [view-entity column-set-sorting! state]} column]
(let [sorting (:sorting state)
@@ -194,6 +204,10 @@
:align "start"
:as-dropdown? true
:dropdown-menu? true
:content-props {:on-click (fn [^js e]
(when-let [target (.-target e)]
(when (header-dropdown-click-should-hide? target)
(shui/popup-hide! popup-id))))}
:on-before-hide (fn []
(reset! *last-header-action-target el)
(js/setTimeout #(reset! *last-header-action-target nil) 128))})))))}
@@ -1863,7 +1877,8 @@
(cond->
{:page (:block/uuid page)
:properties properties
:edit-block? false}
:edit-block? false
:outliner-op :create-view}
auto-triggered?
(assoc :custom-uuid view-block-id)))]
(db/entity [:block/uuid (:block/uuid result)]))))

View File

@@ -49,20 +49,71 @@
(goog-define ENABLE-DB-SYNC-LOCAL false)
(defonce db-sync-local? ENABLE-DB-SYNC-LOCAL)
(defonce db-sync-ws-url
(defonce default-db-sync-ws-url
(if db-sync-local?
"ws://127.0.0.1:8787/sync/%s"
"wss://api-staging.logseq.io/sync/%s"
;; "wss://api-staging.logseq.io/sync/%s"
))
(defonce db-sync-http-base
(defonce default-db-sync-http-base
(if db-sync-local?
"http://127.0.0.1:8787"
"https://api-staging.logseq.io"
;; "https://api-staging.logseq.io"
))
(defn get-custom-sync-server-url
"Read the user-configured custom sync server URL from localStorage.
Returns nil when not set or empty."
[]
(when-not util/node-test?
(let [v (.getItem js/localStorage "sync-server-url")]
(when (and (string? v) (not (string/blank? v)))
v))))
(defn set-custom-sync-server-url!
"Persist the custom sync server URL to localStorage. Pass nil or empty string to clear."
[url]
(when-not util/node-test?
(if (or (nil? url) (string/blank? url))
(.removeItem js/localStorage "sync-server-url")
(.setItem js/localStorage "sync-server-url" (string/trim url)))))
(defn valid-sync-server-url?
"Return true when `url` looks like a valid HTTP(S) base URL."
[url]
(and (string? url)
(re-find #"^https?://" url)))
(defn custom-url->ws-url
"Derive a WebSocket sync URL from a custom HTTP base URL. Pure function."
[custom-url]
(let [scheme (if (string/starts-with? custom-url "https") "wss" "ws")
base (-> custom-url
(string/replace #"^https?://" "")
(string/replace #"/+$" ""))]
(str scheme "://" base "/sync/%s")))
(defn custom-url->http-base
"Normalize a custom HTTP base URL by stripping trailing slashes. Pure function."
[custom-url]
(string/replace custom-url #"/+$" ""))
(defn db-sync-ws-url
"Return the WebSocket sync URL. Uses custom server when configured, otherwise the default."
[]
(if-let [custom (get-custom-sync-server-url)]
(custom-url->ws-url custom)
default-db-sync-ws-url))
(defn db-sync-http-base
"Return the HTTP base URL for sync. Uses custom server when configured, otherwise the default."
[]
(if-let [custom (get-custom-sync-server-url)]
(custom-url->http-base custom)
default-db-sync-http-base))
;; Feature flags
;; =============

View File

@@ -51,6 +51,87 @@
(state/<invoke-db-worker :thread-api/get-bidirectional-properties (state/get-current-repo)
{:target-id target-id})))
(defn- worker-get-blocks-requests
[requests]
(mapv (fn [{:keys [id opts]}]
{:id id
:opts (select-keys opts [:children? :properties :include-collapsed-children?])})
requests))
(defn- <invoke-worker-get-blocks
[graph requests]
(p/let [result-transit-str
(state/<invoke-db-worker-direct-pass :thread-api/get-blocks
graph
(ldb/write-transit-str requests))]
(some-> result-transit-str ldb/read-transit-str)))
(defonce ^:private *get-blocks-batch-enabled? (atom true))
(defonce ^:private *get-blocks-batch-state
(atom {:scheduled? false
:queue []}))
(declare flush-get-blocks-batch!)
(defn- schedule-get-blocks-batch-flush!
[]
(let [should-schedule? (not (:scheduled? @*get-blocks-batch-state))]
(when should-schedule?
(swap! *get-blocks-batch-state assoc :scheduled? true)
(util/schedule flush-get-blocks-batch!))))
(defn- enqueue-get-blocks-request!
[graph request]
(let [result (p/deferred)]
(swap! *get-blocks-batch-state
(fn [state]
(update state :queue conj {:graph graph
:request request
:result result})))
(schedule-get-blocks-batch-flush!)
result))
(defn- resolve-batched-get-blocks!
[entries responses]
(doseq [[idx {:keys [result]}] (map-indexed vector entries)]
(p/resolve! result (nth responses idx nil))))
(defn- reject-batched-get-blocks!
[entries error]
(doseq [{:keys [result]} entries]
(p/reject! result error)))
(defn- flush-get-blocks-batch!
[]
(let [queue (:queue @*get-blocks-batch-state)]
(swap! *get-blocks-batch-state
(fn [state] (assoc state :scheduled? false :queue [])))
(doseq [[graph entries] (group-by :graph queue)]
(let [requests (->> entries (map :request) worker-get-blocks-requests)]
(->
(p/let [result (<invoke-worker-get-blocks graph requests)
result (if (= (count result) (count requests))
result
nil)
result (or result
;; Safety fallback: retry once if response length is unexpected.
(<invoke-worker-get-blocks graph requests))]
(resolve-batched-get-blocks! entries result))
(p/catch (fn [error]
(reject-batched-get-blocks! entries error))))))))
(defn- <fetch-blocks-from-worker-batched
[graph requests]
(when (seq requests)
(if @*get-blocks-batch-enabled?
(-> (p/all (mapv #(enqueue-get-blocks-request! graph %) requests))
(p/catch (fn [_]
;; Fail-open: disable batching for this runtime and fall back to direct fetch.
(reset! *get-blocks-batch-enabled? false)
(<invoke-worker-get-blocks graph (worker-get-blocks-requests requests)))))
(<invoke-worker-get-blocks graph (worker-get-blocks-requests requests)))))
(defn <get-block
[graph id-uuid-or-name & {:keys [children? include-collapsed-children? skip-transact? skip-refresh? properties]
:or {children? true}
@@ -80,9 +161,7 @@
:else
(->
(p/let [result-transit-str (state/<invoke-db-worker-direct-pass :thread-api/get-blocks graph
(ldb/write-transit-str [{:id id :opts opts}]))
result (ldb/read-transit-str result-transit-str)
(p/let [result (<fetch-blocks-from-worker-batched graph [{:id id :opts opts}])
{:keys [block children]} (first result)]
(when-not skip-transact?
(let [conn (db/get-db graph false)
@@ -110,19 +189,15 @@
[graph ids* & {:as opts}]
(let [ids (remove (fn [id] (:block.temp/load-status (db/entity id))) ids*)]
(when (seq ids)
(p/let [result-transit-str
(state/<invoke-db-worker-direct-pass :thread-api/get-blocks graph
(ldb/write-transit-str
(map
(fn [id]
{:id id :opts (assoc opts :children? false)})
ids)))
result (ldb/read-transit-str result-transit-str)]
(p/let [result (<fetch-blocks-from-worker-batched graph
(mapv (fn [id]
{:id id :opts (assoc opts :children? false)})
ids))]
(let [conn (db/get-db graph false)
result' (map :block result)]
result' (keep :block result)]
(when (seq result')
(d/transact! conn result'))
result')))))
result)))))
(defn <get-block-parents
[graph id depth]

View File

@@ -114,12 +114,11 @@
(defn- <create-cards-block!
[]
(let [cards-tag-id (:db/id (db/entity :logseq.class/Cards))]
(editor-handler/api-insert-new-block! ""
{:page (date/today)
:properties {:block/tags #{cards-tag-id}}
:sibling? false
:end? true})))
(editor-handler/api-insert-new-block! ""
{:page (date/today)
:properties {:block/tags #{:logseq.class/Cards}}
:sibling? false
:end? true}))
(defn- btn-with-shortcut [{:keys [shortcut id btn-text due on-click class]}]
(let [bg-class (case id

View File

@@ -77,7 +77,7 @@
(:db/id color))) colors)]
(when color-id
(let [properties (cond->
{:block/tags #{(:db/id (db/entity :logseq.class/Pdf-annotation))}
{:block/tags #{:logseq.class/Pdf-annotation}
:block/collapsed? image?
:logseq.property/ls-type :annotation
:logseq.property.pdf/hl-color color-id

View File

@@ -250,7 +250,7 @@
(p/do!
(ui-outliner-tx/transact!
{:outliner-op :move-blocks
:real-outliner-op :indent-outdent}
:source-outliner-op :indent-outdent}
(when save-current-block (save-current-block))
(outliner-op/indent-outdent-blocks! (get-top-level-blocks blocks')
indent?

View File

@@ -95,11 +95,30 @@
(string/replace #"[\\/]+" "_")
(str "_checksum_" (quot (util/time-ms) 1000))))
(defn- client-server-checksum-mismatch?
[local-checksum remote-checksum]
(and (string? local-checksum)
(string? remote-checksum)
(not= local-checksum remote-checksum)))
(defn- client-ops-export-file-name
[repo]
(-> (or repo "graph")
(string/replace #"^/+" "")
(string/replace #"[\\/]+" "_")
(str "_client_ops_" (quot (util/time-ms) 1000))))
(defn- ->uint8array
[data]
(cond
(instance? js/Uint8Array data)
data
(js/ArrayBuffer.isView data)
(js/Uint8Array. (.-buffer data) (.-byteOffset data) (.-byteLength data))
(instance? js/ArrayBuffer data)
(js/Uint8Array. data)
(array? data)
(js/Uint8Array. data)
:else
nil))
(defn- <fetch-server-checksum-diagnostics
[repo]
@@ -164,7 +183,9 @@
:server-checksum (:checksum server-diagnostics)
:different-blocks diff-blocks}]
(pprint/pprint diff-data)
(js/console.warn "Checksum mismatch between client and server. Diff data:" diff-data)))
(when (seq diff-blocks)
(js/console.warn "Checksum mismatch between client and server. Diff data:" diff-data))))
(defn ^:export recompute-checksum-diagnostics
[]
@@ -184,11 +205,10 @@
content (with-out-str (pprint/pprint export-edn))
blob (js/Blob. #js [content] (clj->js {:type "text/edn;charset=utf-8"}))
filename (checksum-export-file-name repo)]
(p/let [_ (when (client-server-checksum-mismatch? local-checksum remote-checksum)
(-> (<log-checksum-mismatch-diff! repo export-edn)
(p/catch (fn [error]
(js/console.error "checksum mismatch diff fetch failed:" error)
nil))))]
(p/let [_ (-> (<log-checksum-mismatch-diff! repo export-edn)
(p/catch (fn [error]
(js/console.error "checksum mismatch diff fetch failed:" error)
nil)))]
(utils/saveToFile blob filename "edn")
(notification/show!
(str "Checksum recomputed. Recomputed: " recomputed-checksum
@@ -204,6 +224,29 @@
(notification/show! "Failed to compute graph checksum diagnostics." :error))))
(notification/show! "No graph found" :warning)))
(defn ^:export export-client-ops-sqlite
[]
(if-let [repo (state/get-current-repo)]
(-> (state/<invoke-db-worker-direct-pass :thread-api/export-client-ops-db repo)
(p/then (fn [data]
(if-let [payload (->uint8array data)]
(let [filename (client-ops-export-file-name repo)
blob (js/Blob. #js [payload] (clj->js {:type "application/octet-stream"}))]
(utils/saveToFile blob filename "sqlite")
(notification/show!
(str "Client ops SQLite exported: " filename ".sqlite")
:success
false))
(notification/show!
(str "Client ops SQLite export failed: invalid payload type "
(pr-str (type data))
".")
:warning))))
(p/catch (fn [error]
(js/console.error "export-client-ops-sqlite failed:" error)
(notification/show! "Failed to export client ops SQLite." :error))))
(notification/show! "No graph found" :warning)))
(defn import-chosen-graph
[repo]
(p/let [_ (persist-db/<close-db repo)]

View File

@@ -36,6 +36,22 @@
(rest parts)))
(string/join " #"))))
(defn- find-page-add-button
[page-id]
(when page-id
(->> (dom/sel ".block-add-button")
(filter #(= (str page-id) (dom/attr % "parentblockid")))
first)))
(defn- click-page-add-button-with-retry!
[page-id]
(letfn [(poll! [remaining-ms]
(if-let [block-add-button (find-page-add-button page-id)]
(.click block-add-button)
(when (pos? remaining-ms)
(js/setTimeout #(poll! (- remaining-ms 100)) 100))))]
(poll! 500)))
(defn <create!
([title]
(<create! title {}))
@@ -67,7 +83,7 @@
:else
(when-not (string/blank? page-title)
(p/let [existing-page (when-not class? (db/get-page page-title))]
(if existing-page
(if (and existing-page (not (ldb/recycled? existing-page)))
existing-page
(p/let [options' (cond-> (update options :tags concat (:block/tags parsed-result))
(nil? (:split-namespace? options))
@@ -79,13 +95,7 @@
(when redirect?
(route-handler/redirect-to-page! page-uuid)
(when-not today-journal?
(js/setTimeout
(fn []
(when-let [block-add-button (->> (dom/sel ".block-add-button")
(filter #(= (str (:db/id page)) (dom/attr % "parentblockid")))
first)]
(.click block-add-button)))
200)))
(click-page-add-button-with-retry! (:db/id page))))
page)))))))))
;; favorite fns
@@ -147,8 +157,8 @@
(config-handler/set-config! :feature/enable-journals? true)
(notification/show! "Journals enabled" :success)))
(-> (p/let [res (ui-outliner-tx/transact!
{:outliner-op :delete-page}
(outliner-op/delete-page! page-uuid))]
{:outliner-op :delete-page}
(outliner-op/delete-page! page-uuid))]
(if res
(when ok-handler (ok-handler))
(when error-handler (error-handler))))

View File

@@ -48,7 +48,9 @@
(defn wrap-parse-block
[{:block/keys [title level] :as block}]
(let [block (or (and (:db/id block) (db/entity (:db/id block))) block)
(let [block (or (and (:db/id block) (db/entity (:db/id block)))
(and (:block/uuid block) (db/entity [:block/uuid (:block/uuid block)]))
block)
block (if (nil? title)
block
(let [ast (mldoc/->edn (string/trim title) :markdown)

View File

@@ -52,8 +52,9 @@
(ldb/built-in? page-entity)
(notification/show! "Built-in pages can't be used as tags" :error)
:else
;; FIXME: should move to worker
(let [txs [(db-class/build-new-class (db/get-db)
{:db/id (:db/id page-entity)
{:block/uuid (:block/uuid page-entity)
:block/title (:block/title page-entity)
:block/created-at (:block/created-at page-entity)})
[:db/retract (:db/id page-entity) :block/tags :logseq.class/Page]]]

View File

@@ -28,8 +28,8 @@
base)))
(defn http-base []
(or config/db-sync-http-base
(ws->http-base config/db-sync-ws-url)))
(or (config/db-sync-http-base)
(ws->http-base (config/db-sync-ws-url))))
(defn- auth-headers []
(when-let [token (state/get-auth-id-token)]

View File

@@ -250,8 +250,7 @@
(defn- save-block-inner!
[block value opts]
(let [block {:db/id (:db/id block)
:block/uuid (:block/uuid block)
(let [block {:block/uuid (:block/uuid block)
:block/title value}
block' (-> (wrap-parse-block block)
;; :block/uuid might be changed when backspace/delete
@@ -320,7 +319,6 @@
{:outliner-op :insert-blocks}
(save-current-block! {:current-block current-block})
(outliner-op/insert-blocks! [new-block'] current-block {:sibling? sibling?
:right-sibling-id (:db/id (:right-sibling config))
:keep-uuid? keep-uuid?
:ordered-list? ordered-list?
:replace-empty-target? replace-empty-target?
@@ -595,12 +593,14 @@
(into new-block properties)
new-block)]
(ui-outliner-tx/transact!
{:outliner-op :insert-blocks}
(cond->
{:outliner-op :insert-blocks}
(not= outliner-op :insert-blocks)
(assoc :source-outliner-op outliner-op))
(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?
@@ -1083,8 +1083,6 @@
(:db/id page)
:page))))))
(declare save-current-block!)
;; FIXME: shortcut `mod+.` doesn't work on Web (Chrome)
(defn zoom-in! []
(if (state/editing?)
@@ -1348,8 +1346,7 @@
(notification/show! [:div "Asset size shouldn't be larger than 100M"]
:warning
false)
(throw (ex-info "Asset size shouldn't be larger than 100M" {:file-name file-name})))
asset (db/entity :logseq.class/Asset)]
(throw (ex-info "Asset size shouldn't be larger than 100M" {:file-name file-name})))]
(p/do!
(when file
(let [file-path (str block-id "." ext)]
@@ -1360,7 +1357,9 @@
:logseq.property.asset/external-url external-url
:logseq.property.asset/size size
:logseq.property.asset/checksum checksum
:block/tags #{(:db/id asset)}})))))
;; Use stable class ident in tx payload to avoid leaking numeric eids
;; into outliner history ops shared with the worker sync pipeline.
:block/tags #{:logseq.class/Asset}})))))
(defn db-based-save-assets!
"Save incoming(pasted) assets to assets directory.
@@ -1821,7 +1820,7 @@
(content-update-fn (:block/title block))
(:block/title block))]
(merge (apply dissoc block (conj (if-not keep-uuid? [:block/_refs] [])))
{:block/page {:db/id (:db/id page)}
{:block/page {:db/id [:block/uuid (:block/uuid page)]}
:block/title new-content})))
(defn- edit-last-block-after-inserted!
@@ -1840,11 +1839,12 @@
(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]])
(let [root-id [:block/uuid (:block/uuid root)]]
[[:db/retract root-id :logseq.property/deleted-at]
[:db/retract root-id :logseq.property/deleted-by-ref]
[:db/retract root-id :logseq.property.recycle/original-parent]
[:db/retract root-id :logseq.property.recycle/original-page]
[:db/retract root-id :logseq.property.recycle/original-order]]))
(defn paste-blocks
"Given a vec of blocks, insert them into the target page.

View File

@@ -62,6 +62,12 @@
[error]
(string/includes? (or (ex-message error) (str error)) "decrypt-aes-key"))
(defn- <build-search-index!
[repo]
(-> (state/<invoke-db-worker :thread-api/search-build-blocks-indice-in-worker repo)
(p/catch (fn [error]
(js/console.error "Search index build error:" error)))))
(defn- schedule-search-index-build!
[repo]
(when-let [timeout-id @*search-index-build-timeout]
@@ -76,9 +82,7 @@
(state/input-idle? repo :diff 5000)
(do
(reset! *search-index-build-timeout nil)
(-> (state/<invoke-db-worker :thread-api/search-build-blocks-indice-in-worker repo)
(p/catch (fn [error]
(js/console.error "Search index build error:" error)))))
(<build-search-index! repo))
:else
(schedule-search-index-build! repo)))
@@ -110,7 +114,7 @@
(p/do!
(p/delay 5000)
(p/let [repo (state/get-current-repo)
_ (state/<invoke-db-worker :thread-api/search-build-blocks-indice-in-worker repo)]
_ (<build-search-index! repo)]
(when state/lsp-enabled?
(doseq [service (state/get-all-plugin-services-with-type :search)]
(search-plugin/call-service! service "search:rebuildPagesIndice" {})

View File

@@ -81,6 +81,11 @@
(plugin/user-proxy-settings-container agent-opts)
{:id :https-proxy-panel :center? true :class "lg:max-w-2xl"}))
(defmethod events/handle :go/sync-server-settings [[_]]
(shui/dialog-open!
(settings/sync-server-url-settings-container)
{:id :sync-server-panel :center? true :class "lg:max-w-2xl"}))
(defmethod events/handle :redirect-to-home [_]
(page-handler/create-today-journal!)
(when (util/capacitor?)
@@ -300,8 +305,7 @@
{:id :selection-action-bar
:root-props {:modal false}
:content-props {:side "top"
:class "!py-0 !px-0 !border-none"
:modal? false}
:class "!py-0 !px-0 !border-none"}
:auto-side? false
:align :start}))))
@@ -331,8 +335,8 @@
(state/pub-event! [:rtc/sync-app-state])
(state/<invoke-db-worker :thread-api/set-db-sync-config
{:enabled? true
:ws-url config/db-sync-ws-url
:http-base config/db-sync-http-base})
:ws-url (config/db-sync-ws-url)
:http-base (config/db-sync-http-base)})
(state/<invoke-db-worker :thread-api/db-sync-ensure-user-rsa-keys))
(p/catch (fn [error]
(log/error :db-sync/ensure-user-rsa-keys-failed error)

View File

@@ -57,6 +57,16 @@
(outliner-op/transact! tx-data nil))
true))))
(defn delete-recycled-permanently!
[root-uuid]
(when-let [root (db/entity [:block/uuid root-uuid])]
(when (seq (outliner-recycle/permanently-delete-tx-data (db/get-db) root))
(p/do!
(ui-outliner-tx/transact!
{:outliner-op :recycle-delete-permanently}
(outliner-op/recycle-delete-permanently! root-uuid))
true))))
(defn <unfavorite-page!
[page-name]
(p/do!
@@ -125,7 +135,7 @@
(defn update-public-attribute!
[page value]
(db-property-handler/set-block-property! [:block/uuid (:block/uuid page)] :logseq.property/publishing-public? value))
(db-property-handler/set-block-property! (:block/uuid page) :logseq.property/publishing-public? value))
(defn get-page-ref-text
[page]

View File

@@ -12,6 +12,7 @@
[frontend.fs :as fs]
[frontend.handler.common.plugin :as plugin-common-handler]
[frontend.handler.notification :as notification]
[frontend.handler.plugin-config :as plugin-config-handler]
[frontend.idb :as idb]
[frontend.modules.shortcut.utils :as shortcut-utils]
[frontend.state :as state]
@@ -35,6 +36,13 @@
(uuid? x) (str x)
:else x)) input))))
(defn- normalize-user-key-without-ns
[k]
(some-> k (name)
(string/replace "/" "$")
(string/replace " " "_")
(string/replace #"^[:_\s]+" "")))
(defn invoke-exported-api
[type & args]
(try
@@ -53,7 +61,99 @@
(defonce central-endpoint "https://raw.githubusercontent.com/logseq/marketplace/master/")
(defonce plugins-url (str central-endpoint "plugins.json"))
(defonce stats-url (str central-endpoint "stats.json"))
(declare select-a-plugin-theme)
(declare select-a-plugin-theme get-ls-dotdir-root)
(def ^:private illegal-plugin-package-error-pattern
#"^Parse package config error #(.+)[/\\]package\.json$")
(defn- illegal-plugin-package-error->data
[^js e]
(let [name (some-> e (aget "name"))
message (some-> e (aget "message"))
url (or (some-> e (aget "url"))
(some->> message (re-matches illegal-plugin-package-error-pattern) second))
url (some-> url util/node-path.normalize)
package-json-path (or (some-> e (aget "packageJsonPath"))
(some-> url (util/node-path.join "package.json")))]
(when (and (= "IllegalPluginPackageError" name)
(not (string/blank? url)))
{:url url
:package-json-path package-json-path})))
(defn- problematic-plugin-source
[url]
(let [url (util/node-path.normalize url)
dotroot (some-> (get-ls-dotdir-root) util/node-path.normalize)
dotplugins-root (some-> dotroot (util/node-path.join "plugins"))]
(if (and dotplugins-root
(string/starts-with? url dotplugins-root))
{:type :installed
:id (util/node-path.basename url)
:url url}
{:type :external
:url url})))
(defn- remove-external-plugin-path!
[url]
(p/let [prefs (invoke-exported-api :load_user_preferences)
prefs (if prefs (js->clj prefs :keywordize-keys true) {})
url (util/node-path.normalize url)
externals (mapv util/node-path.normalize (or (:externals prefs) []))
updated-externals (vec (remove #(= % url) externals))
removed? (not= externals updated-externals)
_ (when removed?
(invoke-exported-api :save_user_preferences
(clj->js (assoc prefs :externals updated-externals))))]
removed?))
(defn- remove-problematic-plugin!
[url]
(let [{:keys [type id] :as source} (problematic-plugin-source url)]
(case type
:installed
(p/let [_ (when (util/electron?)
(ipc/ipc :uninstallMarketPlugin id))
_ (-> (plugin-config-handler/remove-plugin id)
(p/catch (fn [error]
(log/warn :remove-broken-plugin-config-error error))))]
source)
:external
(p/let [removed? (remove-external-plugin-path! url)]
(assoc source :removed? removed?)))))
(defn show-illegal-plugin-package-notification!
[^js e]
(if-let [{:keys [url package-json-path]} (illegal-plugin-package-error->data e)]
(let [{:keys [type id]} (problematic-plugin-source url)
uid (keyword (str "plugin-illegal-package-error-" (hash url)))]
(notification/show!
[:div.flex.flex-col.gap-2
[:div "Failed to parse the plugin package config."]
[:div.text-xs.opacity-70.break-all package-json-path]
(when (= type :external)
[:div.text-xs.opacity-70
"Removing it only detaches the plugin from Logseq and keeps the source folder untouched."])
[:div.flex.items-center.gap-2.pt-1
(shui/button
{:size :sm
:on-click (fn []
(-> (remove-problematic-plugin! url)
(p/then (fn [_]
(notification/clear! uid)
(notification/show!
(if (= type :installed)
(str "Removed broken plugin \"" id "\".")
"Removed the broken plugin from the plugin list.")
:success)))
(p/catch (fn [error]
(notification/show!
(str "Failed to remove the broken plugin.\n" error)
:error)))))}
(t :plugin/uninstall))]]
:error false uid)
true)
(notification/show! "Illegal Logseq plugin package." :error)))
(defn setup-global-apis-for-web!
[]
@@ -71,8 +171,8 @@
[theme]
(when theme
(cond-> theme
(util/electron?)
(update :url #(some-> % (string/replace-first "assets://" "file://"))))))
(util/electron?)
(update :url #(some-> % (string/replace-first "assets://" "file://"))))))
(defn load-plugin-preferences
[]
@@ -108,7 +208,7 @@
(if-let [res (and res (bean/->clj res))]
(let [pkgs (:packages res)
pkgs (if (util/electron?) pkgs
(some->> pkgs (filterv #(or (true? (:web %)) (not (true? (:effect %)))))))]
(some->> pkgs (filterv #(or (true? (:web %)) (not (true? (:effect %)))))))]
(state/set-state! :plugin/marketplace-pkgs pkgs)
(resolve pkgs))
(reject nil)))]
@@ -131,8 +231,8 @@
:plugin/marketplace-stats
(into {} (map (fn [[k stat]]
[k (assoc stat
:total_downloads
(reduce (fn [a b] (+ a (get b 2))) 0 (:releases stat)))])
:total_downloads
(reduce (fn [a b] (+ a (get b 2))) 0 (:releases stat)))])
res)))
(resolve nil))
(reject nil)))]
@@ -293,8 +393,8 @@
(defn- normalize-plugin-metadata
[metadata]
(cond-> metadata
(not (string? (:author metadata)))
(assoc :author (or (get-in metadata [:author :name]) ""))))
(not (string? (:author metadata)))
(assoc :author (or (get-in metadata [:author :name]) ""))))
(defn register-plugin
[plugin-metadata]
@@ -431,7 +531,7 @@
(defn- create-local-renderer-register
[type *providers]
(fn [pid key {subs' :subs :keys [render] :as opts}]
(when-let [key (and key (keyword key))]
(when-let [key (some-> key (normalize-user-key-without-ns) (keyword))]
(register-plugin-resources pid type
(merge opts {:key key :subs subs' :render render}))
(swap! *providers conj pid)
@@ -442,7 +542,7 @@
([type *providers many?]
(fn [key]
(when (seq @*providers)
(if key
(if-let [key (some-> key (normalize-user-key-without-ns) (keyword))]
(when-let [rs (->> @*providers
(map (fn [pid] (state/get-plugin-resource pid type key)))
(remove nil?)
@@ -495,6 +595,24 @@
(create-local-renderer-getter
:daemon-renderers *daemon-renderer-providers true))
(defonce *hosted-renderer-providers (atom #{}))
(def register-hosted-renderer
;; [pid key payload]
(create-local-renderer-register
:hosted-renderers *hosted-renderer-providers))
(def get-hosted-renderers
;; [key]
(create-local-renderer-getter
:hosted-renderers *hosted-renderer-providers true))
(defn resolve-hosted-render
[pid key type]
(some->> (get-hosted-renderers)
(medley/find-first #(and (some-> (:pid %) (name) (= pid))
(or (some-> (:key %) (name) (= key))
(some-> (:key %) (str) (string/includes? (str "." key))))
(some->> type (name) (= (:type %)))))))
(defn select-a-plugin-theme
[pid]
(when-let [themes (get (group-by :pid (:plugin/installed-themes @state/state)) pid)]
@@ -562,7 +680,7 @@
(let [repo (:repo item)]
(if (nil? repo)
;; local
(-> (p/let [content (invoke-exported-api "load_plugin_readme" url)
(-> (p/let [content (invoke-exported-api :load_plugin_readme url)
content (parse-user-md-content content item)]
(and (string/blank? (string/trim content)) (throw (js/Error. "blank readme content")))
(state/set-state! :plugin/active-readme [content item])
@@ -822,8 +940,8 @@
:theme theme?
:web-pkg (cond-> package
(not github?)
(assoc :installedFromUserWebUrl url))}}))
(not github?)
(assoc :installedFromUserWebUrl url))}}))
url)))
;; components
@@ -851,8 +969,8 @@
clear-commands! (fn [pid]
;; commands
(unregister-plugin-slash-command pid)
(invoke-exported-api "unregister_plugin_simple_command" pid)
(invoke-exported-api "uninstall_plugin_hook" pid)
(invoke-exported-api :unregister_plugin_simple_command pid)
(invoke-exported-api :uninstall_plugin_hook pid)
(unregister-plugin-ui-items pid)
(unregister-plugin-resources pid)
(unregister-plugin-search-services pid))
@@ -863,6 +981,11 @@
(register-plugin
(bean/->clj (.parse js/JSON (.stringify js/JSON pl))))))
(.on "error"
(fn [^js e]
(when (illegal-plugin-package-error->data e)
(show-illegal-plugin-package-notification! e))))
(.on "beforeload"
(fn [^js pl]
(let [text (when (util/electron?)
@@ -916,7 +1039,10 @@
(.on "reset-custom-theme" (fn [^js themes]
(let [themes (bean/->clj themes)
custom-theme (dissoc themes :mode)
mode (:mode themes)]
;; Fall back to the user's current theme so that
;; installing a non-theme plugin does not flash
;; the UI back to light mode (#12434).
mode (or (:mode themes) (:ui/theme @state/state))]
(state/set-custom-theme! {:light (if (nil? (:light custom-theme)) {:mode "light"} (:light custom-theme))
:dark (if (nil? (:dark custom-theme)) {:mode "dark"} (:dark custom-theme))})
(state/set-theme-mode! mode))))
@@ -978,8 +1104,8 @@
(init-plugins!)))
(comment
{:pending (count (:plugin/updates-pending @state/state))
:auto-checking? (boolean (:plugin/updates-auto-checking? @state/state))
:coming (count (:plugin/updates-coming @state/state))
:installing (:plugin/installing @state/state)
:downloading? (boolean (:plugin/updates-downloading? @state/state))})
{:pending (count (:plugin/updates-pending @state/state))
:auto-checking? (boolean (:plugin/updates-auto-checking? @state/state))
:coming (count (:plugin/updates-coming @state/state))
:installing (:plugin/installing @state/state)
:downloading? (boolean (:plugin/updates-downloading? @state/state))})

View File

@@ -7,16 +7,14 @@
(defn remove-block-property!
[block-id property-id-or-key]
(assert (some? property-id-or-key) "remove-block-property! remove-block-property! is nil")
(let [eid (if (uuid? block-id) [:block/uuid block-id] block-id)]
(db-property-handler/remove-block-property! eid property-id-or-key)))
(db-property-handler/remove-block-property! block-id property-id-or-key))
(defn set-block-property!
[block-id key v]
(assert (some? key) "set-block-property! key is nil")
(let [eid (if (uuid? block-id) [:block/uuid block-id] block-id)]
(if (or (nil? v) (and (coll? v) (empty? v)))
(db-property-handler/remove-block-property! eid key)
(db-property-handler/set-block-property! eid key v))))
(if (or (nil? v) (and (coll? v) (empty? v)))
(db-property-handler/remove-block-property! block-id key)
(db-property-handler/set-block-property! block-id key v)))
(defn batch-remove-block-property!
[block-ids key]

View File

@@ -13,7 +13,7 @@
[promesa.core :as p]))
(defn search
"The aggretation of search results"
"The aggregation of search results"
([q]
(search (state/get-current-repo) q))
([repo q]

View File

@@ -355,7 +355,8 @@
(defn rtc-group?
[]
(boolean (seq (set/intersection (state/user-groups) #{"team" "rtc_2025_07_10"}))))
(boolean (or (some? (config/get-custom-sync-server-url))
(seq (set/intersection (state/user-groups) #{"team" "rtc_2025_07_10"})))))
(defn alpha-user?
[]

View File

@@ -1,7 +1,8 @@
(ns frontend.modules.outliner.op
"Build outliner ops"
(:require [datascript.impl.entity :as de]
[frontend.handler.user :as user-handler]))
[frontend.handler.user :as user-handler]
[frontend.db.utils :as db-utils]))
(defn- current-user-delete-opts
[opts]
@@ -21,116 +22,141 @@
(conj! *outliner-ops* result)
result)
(defn- ->block-id
[block-or-id]
(cond
(de/entity? block-or-id)
(:block/uuid block-or-id)
(map? block-or-id)
(:block/uuid block-or-id)
(number? block-or-id)
(:block/uuid (db-utils/entity block-or-id))
:else
block-or-id))
(defn- ->property-id
[property-id]
(cond
(number? property-id)
(:db/ident (db-utils/entity property-id))
:else
property-id))
(defn save-block!
[block & {:as opts}]
(op-transact!
(when-let [block' (if (de/entity? block)
(assoc (.-kv ^js block) :db/id (:db/id block))
(dissoc (.-kv ^js block) :db/id)
block)]
[:save-block [block' opts]])))
(defn insert-blocks!
[blocks target-block opts]
(op-transact!
(let [id (:db/id target-block)]
(let [id (->block-id target-block)]
[:insert-blocks [blocks id opts]])))
(defn apply-template!
[template-id target-block opts]
(op-transact!
(let [id (:db/id target-block)]
[:apply-template [template-id id opts]])))
(let [template-id' (->block-id template-id)
id (->block-id target-block)]
[:apply-template [template-id' id opts]])))
(defn delete-blocks!
[blocks opts]
(op-transact!
(let [ids (map :db/id blocks)]
(let [ids (map ->block-id blocks)]
(when (seq ids)
[:delete-blocks [ids (current-user-delete-opts opts)]]))))
(defn move-blocks!
[blocks target-block opts]
(op-transact!
(let [ids (map :db/id blocks)
target-id (:db/id target-block)]
(let [ids (map ->block-id blocks)
target-id (->block-id target-block)]
[:move-blocks [ids target-id opts]])))
(defn move-blocks-up-down!
[blocks up?]
(op-transact!
(let [ids (map :db/id blocks)]
(let [ids (map ->block-id blocks)]
[:move-blocks-up-down [ids up?]])))
(defn indent-outdent-blocks!
[blocks indent? & {:as opts}]
(op-transact!
(let [ids (map :db/id blocks)]
(let [ids (map ->block-id blocks)]
[:indent-outdent-blocks [ids indent? opts]])))
(defn upsert-property!
[property-id schema property-opts]
(op-transact!
[:upsert-property [property-id schema property-opts]]))
[:upsert-property [(->property-id property-id) schema property-opts]]))
(defn set-block-property!
[block-eid property-id value]
(op-transact!
[:set-block-property [block-eid property-id value]]))
[:set-block-property [(->block-id block-eid) (->property-id property-id) value]]))
(defn remove-block-property!
[block-eid property-id]
(op-transact!
[:remove-block-property [block-eid property-id]]))
[:remove-block-property [(->block-id block-eid) (->property-id property-id)]]))
(defn delete-property-value!
[block-eid property-id property-value]
(op-transact!
[:delete-property-value [block-eid property-id property-value]]))
[:delete-property-value [(->block-id block-eid) (->property-id property-id) property-value]]))
(defn batch-delete-property-value!
[block-eids property-id property-value]
(op-transact!
[:batch-delete-property-value [block-eids property-id property-value]]))
[:batch-delete-property-value [(mapv ->block-id block-eids) (->property-id property-id) property-value]]))
(defn create-property-text-block!
[block-id property-id value opts]
(op-transact!
[:create-property-text-block [block-id property-id value opts]]))
[:create-property-text-block [(->block-id block-id) (->property-id property-id) value opts]]))
(defn batch-set-property!
[block-ids property-id value opts]
(op-transact!
[:batch-set-property [block-ids property-id value opts]]))
[:batch-set-property [(mapv ->block-id block-ids) (->property-id property-id) value opts]]))
(defn batch-remove-property!
[block-ids property-id]
(op-transact!
[:batch-remove-property [block-ids property-id]]))
[:batch-remove-property [(mapv ->block-id block-ids) (->property-id property-id)]]))
(defn class-add-property!
[class-id property-id]
(op-transact!
[:class-add-property [class-id property-id]]))
[:class-add-property [(->block-id class-id) (->property-id property-id)]]))
(defn class-remove-property!
[class-id property-id]
(op-transact!
[:class-remove-property [class-id property-id]]))
[:class-remove-property [(->block-id class-id) (->property-id property-id)]]))
(defn upsert-closed-value!
[property-id closed-value-config]
(op-transact!
[:upsert-closed-value [property-id closed-value-config]]))
[:upsert-closed-value [(->property-id property-id) closed-value-config]]))
(defn delete-closed-value!
[property-id value-block-id]
(op-transact!
[:delete-closed-value [property-id value-block-id]]))
[:delete-closed-value [(->property-id property-id) (->block-id value-block-id)]]))
(defn add-existing-values-to-closed-values!
[property-id values]
(op-transact!
[:add-existing-values-to-closed-values [property-id values]]))
[:add-existing-values-to-closed-values [(->property-id property-id) values]]))
(defn toggle-reaction!
[target-uuid emoji-id user-uuid]
@@ -163,3 +189,8 @@
([page-uuid opts]
(op-transact!
[:delete-page [page-uuid (current-user-delete-opts opts)]])))
(defn recycle-delete-permanently!
[root-uuid]
(op-transact!
[:recycle-delete-permanently [root-uuid]]))

View File

@@ -511,6 +511,9 @@
:dev/recompute-checksum {:binding []
:inactive (not (state/developer-mode?))
:fn :frontend.handler.common.developer/recompute-checksum-diagnostics}
:dev/export-client-ops-sqlite {:binding []
:inactive (not (state/developer-mode?))
:fn :frontend.handler.common.developer/export-client-ops-sqlite}
:dev/rtc-stop {:binding []
:inactive (not (state/developer-mode?))
:fn :frontend.handler.common.developer/rtc-stop}
@@ -714,6 +717,7 @@
:dev/replace-graph-with-db-file
:dev/validate-db
:dev/recompute-checksum
:dev/export-client-ops-sqlite
:dev/gc-graph
:dev/rtc-stop
:dev/rtc-start
@@ -878,6 +882,7 @@
:dev/replace-graph-with-db-file
:dev/validate-db
:dev/recompute-checksum
:dev/export-client-ops-sqlite
:dev/gc-graph
:dev/rtc-stop
:dev/rtc-start

View File

@@ -21,6 +21,38 @@
[repo diff]
(state/input-idle? repo :diff diff))
(def-thread-api :thread-api/search-index-build-progress
[repo {:keys [status progress processed total]}]
(let [prev-state (get @state/state :search/index-build)
current-repo (state/get-current-repo)
visible-repo? (or (= repo current-repo)
(= repo (:repo prev-state)))]
(when visible-repo?
(case status
:idle
(state/set-state! :search/index-build
(assoc (or prev-state {})
:running? false
:repo repo))
:running
(state/set-state! :search/index-build
{:running? true
:repo repo
:progress (or progress 0)
:processed (or processed 0)
:total (or total 0)})
:completed
(state/set-state! :search/index-build
{:running? false
:repo repo
:progress (or progress 0)
:processed (or processed 0)
:total (or total 0)})
nil))
nil))
(defn- ask-persist-permission!
[]
(p/let [persistent? (.persist js/navigator.storage)]
@@ -132,8 +164,8 @@
(-> (p/let [_ (state/<invoke-db-worker :thread-api/init)
_ (state/<invoke-db-worker :thread-api/set-db-sync-config
{:enabled? true
:ws-url config/db-sync-ws-url
:http-base config/db-sync-http-base})
:ws-url (config/db-sync-ws-url)
:http-base (config/db-sync-http-base)})
_ (state/pub-event! [:rtc/sync-app-state])
_ (log/info "init worker spent" (str (- (util/time-ms) t1) "ms"))
_ (sync-ui-state!)

View File

@@ -28,6 +28,7 @@
(s/def :copy/export-block-text-indent-style string?)
(s/def :copy/export-block-text-remove-options set?)
(s/def :copy/export-block-text-other-options map?)
(s/def ::sync-server-url string?)
;; Dynamic keys which aren't as easily validated:
;; :ls-pdf-last-page-*
;; :ls-js-allowed-*
@@ -66,4 +67,5 @@
:ui/shortcut-tooltip?
:copy/export-block-text-indent-style
:copy/export-block-text-remove-options
:copy/export-block-text-other-options]))
:copy/export-block-text-other-options
::sync-server-url]))

View File

@@ -89,6 +89,11 @@
:search/result nil
:search/graph-filters []
:search/engines {}
:search/index-build {:running? false
:repo nil
:progress 0
:processed 0
:total 0}
;; modals
:modal/dropdowns {}

View File

@@ -298,8 +298,6 @@
(notification/clear! uid))
:icon "x"})]]]]]])))
(declare button)
(rum/defc notification-clear-all
[]
[:div.ui__notifications-content

View File

@@ -1314,16 +1314,20 @@
(.catch (fn [err]
(js/console.error "Web clipboard failed" err)))))))))
#?(:cljs
(defn copy-image-blob-to-clipboard
[blob]
(if (= (.-type blob) "image/png")
(write-blob-to-clipboard blob)
(image-blob->png blob write-blob-to-clipboard))))
#?(:cljs
(defn copy-image-to-clipboard
[src]
(-> (js/fetch src)
(.then (fn [data]
(-> (.blob data)
(.then (fn [blob]
(if (= (.-type blob) "image/png")
(write-blob-to-clipboard blob)
(image-blob->png blob write-blob-to-clipboard))))
(.then copy-image-blob-to-clipboard)
(.catch js/console.error)))))))
(defn memoize-last

View File

@@ -124,6 +124,7 @@
nil)))
(def repo-path "/db.sqlite")
(def client-ops-repo-path (str "client-ops" repo-path))
(defn- resolve-db-path
[repo pool path]
@@ -141,6 +142,44 @@
(let [storage (platform/storage (platform/current))]
((:export-file storage) pool path))))))
(defn- ->uint8array
[data]
(cond
(instance? js/Uint8Array data)
data
(js/ArrayBuffer.isView data)
(js/Uint8Array. (.-buffer data) (.-byteOffset data) (.-byteLength data))
(instance? js/ArrayBuffer data)
(js/Uint8Array. data)
(array? data)
(js/Uint8Array. data)
:else
data))
(defn- <export-db-file-with-paths
[repo path-candidates]
(let [paths (->> path-candidates
(filter string?)
(remove string/blank?)
distinct
vec)]
(letfn [(try-export [remaining-paths]
(if-let [path (first remaining-paths)]
(-> (<export-db-file repo path)
(p/then (fn [result]
(let [payload (->uint8array result)]
(if (instance? js/Uint8Array payload)
payload
(try-export (subvec remaining-paths 1))))))
(p/catch (fn [_]
(try-export (subvec remaining-paths 1)))))
(p/resolved nil)))]
(try-export paths))))
(defn- <import-db
[^js pool data]
(let [storage (platform/storage (platform/current))]
@@ -514,6 +553,18 @@
[repo]
(db-sync/upload-graph! repo))
(def-thread-api :thread-api/db-sync-stop-upload
[repo]
(db-sync/stop-upload! repo))
(def-thread-api :thread-api/db-sync-resume-upload
[repo]
(db-sync/resume-upload! repo))
(def-thread-api :thread-api/db-sync-upload-stopped?
[repo]
(db-sync/upload-stopped? repo))
(def-thread-api :thread-api/db-sync-download-graph
[repo]
(sync-download/download-graph! repo))
@@ -778,6 +829,26 @@
(p/let [data (<export-db-file repo)]
(platform/transfer (platform/current) data #js [(.-buffer data)])))
(def-thread-api :thread-api/export-client-ops-db
[repo]
(when-let [^js db (worker-state/get-sqlite-conn repo :client-ops)]
(.exec db "PRAGMA wal_checkpoint(2)"))
(let [^js client-ops-db (worker-state/get-sqlite-conn repo :client-ops)
^js pool (get-storage-pool repo)
db-filename (some-> client-ops-db .-filename)
resolved-client-ops-path (when pool
(resolve-db-path repo pool (str "client-ops-" repo-path)))
export-paths [db-filename
resolved-client-ops-path
client-ops-repo-path
(str "/" client-ops-repo-path)
(str "client-ops" repo-path)
(str "/client-ops" repo-path)
(str "client-ops-" repo-path)]]
(p/let [payload (<export-db-file-with-paths repo export-paths)]
(when (instance? js/Uint8Array payload)
(platform/transfer (platform/current) payload #js [(.-buffer payload)])))))
(def-thread-api :thread-api/export-db-base64
[repo]
(when-let [^js db (worker-state/get-sqlite-conn repo :db)]

View File

@@ -6,6 +6,7 @@
[frontend.worker.search :as search]
[frontend.worker.shared-service :as shared-service]
[frontend.worker.state :as worker-state]
[frontend.worker-common.util :as worker-util]
[frontend.worker.sync :as db-sync]
[promesa.core :as p]))
@@ -70,14 +71,16 @@
(d/unlisten! conn ::listen-db-changes!)
(d/listen! conn ::listen-db-changes!
(fn listen-db-changes!-inner
[{:keys [tx-data] :as tx-report}]
(when-not (:batch-tx? @conn)
(when (seq tx-data)
[{:keys [tx-data tx-meta] :as tx-report}]
(when (seq tx-data)
(when (and worker-util/dev-or-test?
(not (:batch-final-tx-report? tx-meta)))
(db-sync/update-local-sync-checksum! repo tx-report))
(when-not (:batch-tx? @conn)
(let [tx-report' (if sync-db-to-main-thread?
(sync-db-to-main-thread repo conn tx-report)
tx-report)
opt {:repo repo}]
(when tx-report'
(db-sync/update-local-sync-checksum! repo tx-report')
(doseq [[k handler-fn] handlers]
(handler-fn k opt tx-report'))))))))))

View File

@@ -20,6 +20,7 @@
[logseq.graph-parser.exporter :as gp-exporter]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.datascript-report :as ds-report]
[logseq.outliner.template :as outliner-template]
[logseq.outliner.pipeline :as outliner-pipeline]))
(def ^:private rtc-tx-or-download-graph?
@@ -67,43 +68,51 @@
journal-page (some (fn [d] (when (and (= :block/journal-day (:a d)) (:added d))
(d/entity db (:e d))))
(:tx-data tx-report))
journal-template? (some (fn [d] (and (:added d) (= (:a d) :block/tags) (= (:v d) journal-id))) (:tx-data tx-report))
tx-data (some->> (:tx-data tx-report)
(filter (fn [d] (and (= (:a d) :block/tags) (:added d))))
(group-by :e)
(mapcat (fn [[e datoms]]
(let [object (d/entity db e)
template-blocks (->> (mapcat (fn [id]
(let [tag (d/entity db id)
parents (ldb/get-class-extends tag)
templates (mapcat :logseq.property/_template-applied-to (conj parents tag))]
(cond->> templates
journal-page
(map (fn [t] (assoc t :journal journal-page))))))
(set (map :v datoms)))
distinct
(sort-by :block/created-at)
(mapcat (fn [template]
(let [template-blocks (rest (ldb/get-block-and-children db (:block/uuid template)
{:include-property-block? true}))
blocks (->>
(cons (assoc (first template-blocks) :logseq.property/used-template (:db/id template))
(rest template-blocks))
(map (fn [e]
(cond->
(assoc (into {} e) :db/id (:db/id e))
(:journal template)
(assoc :block/uuid
(common-uuid/gen-journal-template-block (:block/uuid (:journal template))
(:block/uuid e)))))))]
blocks))))]
(when (seq template-blocks)
(let [result (outliner-core/insert-blocks
db template-blocks object
{:sibling? false
:keep-uuid? journal-template?
:outliner-op :insert-template-blocks})]
(:tx-data result)))))))]
journal-template? (some (fn [d] (and (:added d)
(= (:a d) :block/tags)
(= (:v d) journal-id)))
(:tx-data tx-report))
tag->templates (fn [id]
(let [tag (d/entity db id)
parents (ldb/get-class-extends tag)
templates (mapcat :logseq.property/_template-applied-to (conj parents tag))]
(cond->> templates
journal-page
(map (fn [t] (assoc t :journal journal-page))))))
template->blocks (fn [object template]
(let [template-children (rest (ldb/get-block-and-children db (:block/uuid template)
{:include-property-block? true}))
blocks (->> (cons (assoc (first template-children)
:logseq.property/used-template (:db/id template))
(rest template-children))
(map (fn [block]
(cond->
(assoc (into {} block) :db/id (:db/id block))
(:journal template)
(assoc :block/uuid
(common-uuid/gen-journal-template-block
(:block/uuid (:journal template))
(:block/uuid block)))))))]
(outliner-template/resolve-dynamic-template-blocks db object blocks)))
tag-additions (->> (:tx-data tx-report)
(filter (fn [d] (and (= (:a d) :block/tags) (:added d))))
(group-by :e))
tx-data (mapcat
(fn [[e datoms]]
(let [object (d/entity db e)
templates (->> (set (map :v datoms))
(mapcat tag->templates)
distinct
(sort-by :block/created-at))
blocks-to-insert (mapcat (partial template->blocks object) templates)]
(when (seq blocks-to-insert)
(let [result (outliner-core/insert-blocks
db blocks-to-insert object
{:sibling? false
:keep-uuid? journal-template?
:outliner-op :insert-template-blocks})]
(:tx-data result)))))
tag-additions)]
tx-data))
(defn- fix-page-tags
@@ -125,9 +134,12 @@
(not (ldb/inline-tag? (:block/raw-title entity) tag))
(not (:db/ident entity)))
(let [eid (:db/id entity)]
[[:db/add eid :db/ident (db-class/create-user-class-ident-from-name db-after (:block/title entity))]
[:db/add eid :logseq.property.class/extends :logseq.class/Root]
[:db/retract eid :block/tags :logseq.class/Page]])
(if (:block/page entity)
;; Built-in #Tag should never turn a page child block into a class.
[[:db/retract eid :block/tags :logseq.class/Tag]]
[[:db/add eid :db/ident (db-class/create-user-class-ident-from-name db-after (:block/title entity))]
[:db/add eid :logseq.property.class/extends :logseq.class/Root]
[:db/retract eid :block/tags :logseq.class/Page]]))
;; remove #Page from tags/journals etc.
(= (:db/id page-tag) (:v datom))
@@ -439,7 +451,7 @@
display-blocks-tx-data (add-missing-properties-to-typed-display-blocks db-after tx-data tx-meta)
ensure-query-tx-data (ensure-query-property-on-tag-additions tx-report)
commands-tx (when-not (or (:undo? tx-meta)
(contains? #{:rebase} (:outliner-op tx-meta))
(= :rebase (:outliner-op tx-meta))
(rtc-tx-or-download-graph? tx-meta))
(commands/run-commands tx-report))
insert-templates-tx (when-not (rtc-tx-or-download-graph? tx-meta)

View File

@@ -19,6 +19,8 @@
(s/def ::objects (s/tuple #(= ::objects %) int?))
;; get block reactions
(s/def ::block-reactions (s/tuple #(= ::block-reactions %) int?))
;; recycle roots list
(s/def ::recycle-roots (s/tuple #(= ::recycle-roots %)))
;; custom react-query
(s/def ::custom any?)
@@ -27,13 +29,21 @@
:refs ::refs
:objects ::objects
:block-reactions ::block-reactions
:recycle-roots ::recycle-roots
:custom ::custom))
(s/def ::affected-keys (s/coll-of ::react-query-keys))
(defn- journal-page?
[db eid journal-tag-id]
(when (and db eid journal-tag-id)
(some (fn [tag]
(= journal-tag-id (:db/id tag)))
(:block/tags (d/entity db eid)))))
(defn get-affected-queries-keys
"Get affected queries through transaction datoms."
[{:keys [tx-data db-after]}]
[{:keys [tx-data db-before db-after]}]
{:post [(s/valid? ::affected-keys %)]}
(let [blocks (->> (filter (fn [datom] (contains? #{:block/parent :block/page} (:a datom))) tx-data)
(map :v)
@@ -47,16 +57,26 @@
tags (->> (filter (fn [datom] (= :block/tags (:a datom))) tx-data)
(map :v)
(distinct))
journals? (some (fn [datom]
(and
(= :block/tags (:a datom))
(= (:db/id (d/entity db-after :logseq.class/Journal))
(:v datom))))
tx-data)
journal-tag-id (:db/id (d/entity db-after :logseq.class/Journal))
touched-eids (->> tx-data (map :e) distinct)
journals? (or (some (fn [datom]
(and (= :block/tags (:a datom))
(= journal-tag-id (:v datom))))
tx-data)
(some (fn [datom]
(= :block/journal-day (:a datom)))
tx-data)
(some (fn [eid]
(or (journal-page? db-before eid journal-tag-id)
(journal-page? db-after eid journal-tag-id)))
touched-eids))
reaction-targets (->> (filter (fn [datom]
(= :logseq.property.reaction/target (:a datom))) tx-data)
(map :v)
(distinct))
recycle-roots? (some (fn [datom]
(= :logseq.property/deleted-at (:a datom)))
tx-data)
other-blocks (->> (filter (fn [datom] (= "block" (namespace (:a datom)))) tx-data)
(map :e))
blocks (-> (concat blocks other-blocks) distinct)
@@ -100,6 +120,9 @@
(when tag [::objects tag]))
tags)
(when recycle-roots?
[[::recycle-roots]])
(when journals?
[[::journals]]))]
(->>

View File

@@ -102,22 +102,44 @@ DROP TRIGGER IF EXISTS blocks_au;
(str "(" (->> (map (fn [id] (str "'" id "'")) ids)
(string/join ", ")) ")"))
(def ^:private upsert-blocks-batch-size 2000)
(def ^:private upsert-blocks-sql
(memoize
(fn [row-count]
(str "INSERT INTO blocks (id, title, page) VALUES "
(string/join ", " (repeat row-count "(?, ?, ?)"))
" ON CONFLICT (id) DO UPDATE SET (title, page) = (excluded.title, excluded.page)"))))
(defn- valid-upsert-block?
[item]
(and (common-util/uuid-string? (.-id item))
(common-util/uuid-string? (.-page item))))
(defn- throw-upsert-blocks-error!
[item]
(js/console.error "Upsert blocks wrong data: ")
(js/console.dir item)
(throw (ex-info "Search upsert-blocks wrong data: "
(bean/->clj item))))
(defn- upsert-bind-params
[batch]
(into-array
(mapcat (fn [item]
[(.-id item) (.-title item) (.-page item)])
batch)))
(defn upsert-blocks!
[^Object db blocks]
(assert db ::upsert-blocks!)
(.transaction db (fn [tx]
(doseq [item blocks]
(if (and (common-util/uuid-string? (.-id item))
(common-util/uuid-string? (.-page item)))
(.exec tx #js {:sql "INSERT INTO blocks (id, title, page) VALUES ($id, $title, $page) ON CONFLICT (id) DO UPDATE SET (title, page) = ($title, $page)"
:bind #js {:$id (.-id item)
:$title (.-title item)
:$page (.-page item)}})
(do
(js/console.error "Upsert blocks wrong data: ")
(js/console.dir item)
(throw (ex-info "Search upsert-blocks wrong data: "
(bean/->clj item)))))))))
(doseq [batch (partition-all upsert-blocks-batch-size blocks)]
(doseq [item blocks]
(when-not (valid-upsert-block? item)
(throw-upsert-blocks-error! item)))
(.exec tx #js {:sql (upsert-blocks-sql (count batch))
:bind (upsert-bind-params batch)})))))
(defn delete-blocks!
[db ids]

View File

@@ -13,6 +13,7 @@
[frontend.worker.sync.transport :as sync-transport]
[frontend.worker.sync.upload :as sync-upload]
[frontend.worker.sync.util :as sync-util]
[frontend.worker-common.util :as worker-util]
[lambdaisland.glogi :as log]
[logseq.common.util :as common-util]
[logseq.db-sync.checksum :as sync-checksum]
@@ -50,10 +51,22 @@
(defn update-local-sync-checksum!
[repo tx-report]
(when (worker-state/get-client-ops-conn repo)
(client-op/update-local-checksum
repo
(sync-checksum/update-checksum (client-op/get-local-checksum repo) tx-report))))
(when (and worker-util/dev-or-test?
(worker-state/get-client-ops-conn repo))
(let [current-checksum (client-op/get-local-checksum repo)
new-checksum (sync-checksum/update-checksum current-checksum tx-report)]
;; (let [full-checksum (sync-checksum/recompute-checksum (:db-after tx-report))]
;; (when (not= new-checksum full-checksum)
;; (prn :debug
;; "checksum-doesn't match"
;; {:current-checksum current-checksum
;; :new-checksum new-checksum
;; :full-checksum full-checksum
;; :db-before (ldb/write-transit-str (:db-before tx-report))
;; :db-after (ldb/write-transit-str (:db-after tx-report))
;; :tx-data (ldb/write-transit-str (:tx-data tx-report))
;; :tx-meta (ldb/write-transit-str (:tx-meta tx-report))})))
(client-op/update-local-checksum repo new-checksum))))
(defn- broadcast-rtc-state!
[client]
@@ -129,6 +142,21 @@
[ws message]
(sync-transport/send! sync-transport/coerce-ws-client-message ws message))
(defn- enqueue-receive-message!
[client task]
(if-let [queue (:receive-queue client)]
(swap! queue
(fn [prev]
(-> (or prev (p/resolved nil))
;; Keep queue alive even if one message handler fails.
(p/catch (fn [_] nil))
(p/then (fn [_] (task)))
(p/catch (fn [error]
(log/error :db-sync/ws-handle-message-failed
{:repo (:repo client)
:error error}))))))
(task)))
(defn update-presence!
[editing-block-uuid]
(when-let [client @worker-state/*db-sync-client]
@@ -147,7 +175,9 @@
[repo]
{:repo repo
:send-queue (atom (p/resolved nil))
:receive-queue (atom (p/resolved nil))
:asset-queue (atom (p/resolved nil))
:pending-pull-since (atom nil)
:inflight (atom [])
:last-sync-error (atom nil)
:reconnect (atom {:attempt 0 :timer nil})
@@ -186,7 +216,9 @@
(set! (.-onmessage ws)
(fn [event]
(touch-last-ws-message! client)
(sync-handle-message/handle-message! repo client (.-data event))))
(enqueue-receive-message! client
(fn []
(sync-handle-message/handle-message! repo client (.-data event))))))
(set! (.-onerror ws) (fn [error] (log/error :db-sync/ws-error error)))
(set! (.-onclose ws)
(fn [_]
@@ -360,3 +392,15 @@
(sync-upload/upload-graph! repo))
(def list-remote-graphs! sync-upload/list-remote-graphs!)
(defn stop-upload!
[repo]
(sync-apply/set-upload-stopped! repo true))
(defn resume-upload!
[repo]
(sync-apply/set-upload-stopped! repo false))
(defn upload-stopped?
[repo]
(sync-apply/upload-stopped? repo))

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
(ns frontend.worker.sync.client-op
"Store client-ops in a persisted datascript"
"Store client sync metadata and ops in sqlite tables.
DataScript client-op storage is deprecated and unsupported."
(:require [datascript.core :as d]
[frontend.worker.state :as worker-state]
[goog.object :as gobj]
[lambdaisland.glogi :as log]
[logseq.db :as ldb]
[logseq.db.sqlite.util :as sqlite-util]
[malli.core :as ma]
[malli.transform :as mt]))
@@ -29,85 +31,255 @@
(defonce *repo->pending-local-tx-count (atom {}))
(def schema-in-db
"TODO: rename this db-name from client-op to client-metadata+op.
and move it to its own namespace."
{:block/uuid {:db/unique :db.unique/identity}
:db-ident {:db/unique :db.unique/identity}
:db-ident-or-block-uuid {:db/unique :db.unique/identity}
;; local-tx is the latest remote-tx that local db persists
:local-tx {:db/index true}
:graph-uuid {:db/index true}
:db-sync/checksum {:db/index true}
:db-sync/tx-id {:db/unique :db.unique/identity}
:db-sync/created-at {:db/index true}
:db-sync/pending? {:db/index true}
:db-sync/outliner-op {}
:db-sync/forward-outliner-ops {}
:db-sync/inverse-outliner-ops {}
:db-sync/inferred-outliner-ops? {}
:db-sync/normalized-tx-data {}
:db-sync/reversed-tx-data {}
:db-sync/asset-op? {:db/index true}})
(def ^:private sqlite-schema-ready-key "__logseq_client_ops_schema_ready")
(def ^:private sqlite-mode-key "__logseq_client_ops_sqlite_mode")
(def ^:private sync-meta-table-sql
"create table if not exists sync_meta (key text primary key, value text)")
(def ^:private client-ops-table-sql
(str "create table if not exists client_ops ("
"id integer primary key autoincrement,"
"kind text not null,"
"created_at integer not null,"
"tx_id text unique,"
"pending integer not null default 0,"
"failed integer not null default 0,"
"outliner_op text,"
"undo_redo text,"
"forward_outliner_ops text,"
"inverse_outliner_ops text,"
"inferred_outliner_ops integer,"
"normalized_tx_data text,"
"reversed_tx_data text,"
"asset_uuid text,"
"asset_op text,"
"asset_t integer,"
"asset_value text"
")"))
(def ^:private pending-index-sql
"create index if not exists idx_client_ops_pending_created on client_ops(kind, pending, created_at, id)")
(def ^:private asset-index-sql
"create index if not exists idx_client_ops_asset_uuid on client_ops(kind, asset_uuid)")
(defn- client-ops-store
[repo]
(worker-state/get-client-ops-conn repo))
(declare ensure-sqlite-schema!)
(defn- detect-sqlite-mode
[^js db]
(or (gobj/get db sqlite-mode-key)
(let [mode
(cond
(not db) nil
(fn? (gobj/get db "prepare"))
(try
(let [^js stmt (.prepare db "select 1")]
(try
(if (fn? (gobj/get stmt "run"))
:better-sqlite
:sqlite-wasm)
(finally
(when (fn? (gobj/get stmt "finalize"))
(.finalize stmt)))))
(catch :default _
:sqlite-wasm))
(fn? (gobj/get db "exec")) :sqlite-wasm
:else nil)]
(when mode
(try
(gobj/set db sqlite-mode-key mode)
(catch :default _
nil)))
mode)))
(defn- sqlite-db?
[conn]
(some? (detect-sqlite-mode conn)))
(defn- sqlite-store-or-throw
[repo]
(when-let [store (client-ops-store repo)]
(if (sqlite-db? store)
(do
(ensure-sqlite-schema! store)
store)
(throw (ex-info "Legacy DataScript client-op storage is unsupported. Please back up the graph and re-download it."
{:type :db-sync/legacy-client-ops-storage
:repo repo})))))
(defn- parse-uuid-str
[v]
(when (string? v)
(try
(uuid v)
(catch :default _
nil))))
(defn- kw->str
[v]
(cond
(keyword? v) (name v)
(string? v) v
:else nil))
(defn- str->kw
[v]
(when (string? v)
(keyword v)))
(defn- bool->int [v] (if v 1 0))
(defn- int->bool [v] (not (or (nil? v) (= 0 v) (= false v))))
(defn- normalize-op-entries
[ops]
(let [ops' (some-> ops seq vec)]
(cond
(nil? ops')
nil
(and (keyword? (first ops'))
(vector? (second ops')))
[ops']
:else
ops')))
(defn- sqlite-run!
[^js db sql params]
(case (detect-sqlite-mode db)
:better-sqlite
(let [^js stmt (.prepare db sql)
run-fn (gobj/get stmt "run")]
(if (seq params)
(.apply run-fn stmt (to-array params))
(.call run-fn stmt)))
:sqlite-wasm
(.exec db #js {:sql sql
:bind (into-array params)})
nil))
(defn- sqlite-rows
[^js db sql params]
(case (detect-sqlite-mode db)
:better-sqlite
(let [^js stmt (.prepare db sql)
all-fn (gobj/get stmt "all")]
(vec (if (seq params)
(.apply all-fn stmt (to-array params))
(.call all-fn stmt))))
:sqlite-wasm
(let [^js result (.exec db #js {:sql sql
:bind (into-array params)
:rowMode "object"
:returnValue "resultRows"})]
(cond
(nil? result) []
(array? result) (vec result)
(fn? (gobj/get result "toArray")) (vec (.toArray result))
:else []))
[]))
(defn- sqlite-row
[db sql params]
(first (sqlite-rows db sql params)))
(defn- sqlite-with-tx!
[^js db f]
(case (detect-sqlite-mode db)
:better-sqlite
(let [tx-fn (.transaction db (fn [] (f db)))]
(tx-fn))
:sqlite-wasm
(if (fn? (gobj/get db "transaction"))
(.transaction db (fn [tx] (f tx)))
(f db))
(f db)))
(defn ensure-sqlite-schema!
[db]
(when (sqlite-db? db)
(when-not (true? (gobj/get db sqlite-schema-ready-key))
(sqlite-with-tx!
db
(fn [tx]
(sqlite-run! tx sync-meta-table-sql [])
(sqlite-run! tx client-ops-table-sql [])
(sqlite-run! tx pending-index-sql [])
(sqlite-run! tx asset-index-sql [])))
(try
(gobj/set db sqlite-schema-ready-key true)
(catch :default _
nil)))))
(defn- sqlite-get-meta
[db k]
(some-> (sqlite-row db "select value from sync_meta where key = ?" [(name k)])
(aget "value")))
(defn- sqlite-set-meta!
[db k v]
(sqlite-run! db
(str "insert into sync_meta (key, value) values (?, ?)"
" on conflict(key) do update set value = excluded.value")
[(name k) (str v)]))
(defn- sqlite-delete-meta!
[db k]
(sqlite-run! db "delete from sync_meta where key = ?" [(name k)]))
(defn update-graph-uuid
[repo graph-uuid]
{:pre [(some? graph-uuid)]}
(when-let [conn (worker-state/get-client-ops-conn repo)]
(let [old-datoms (d/datoms @conn :avet :graph-uuid)
retractions (mapv (fn [datom]
[:db/retract (:e datom) :graph-uuid (:v datom)])
old-datoms)]
(ldb/transact! conn (conj retractions [:db/add "e" :graph-uuid graph-uuid])))))
(when-let [store (sqlite-store-or-throw repo)]
(sqlite-set-meta! store :graph-uuid graph-uuid)))
(defn get-graph-uuid
[repo]
(when-let [conn (worker-state/get-client-ops-conn repo)]
(:v (first (d/datoms @conn :avet :graph-uuid)))))
(some-> (sqlite-store-or-throw repo)
(sqlite-get-meta :graph-uuid)))
(defn update-local-tx
[repo t]
{:pre [(some? t)]}
(let [conn (worker-state/get-client-ops-conn repo)]
(assert (some? conn) repo)
(let [tx-data
(if-let [datom (first (d/datoms @conn :avet :local-tx))]
[:db/add (:e datom) :local-tx t]
(if-let [datom (first (d/datoms @conn :avet :db-sync/checksum))]
[:db/add (:e datom) :local-tx t]
[:db/add "e" :local-tx t]))]
(ldb/transact! conn [tx-data]))))
(let [store (sqlite-store-or-throw repo)]
(assert (some? store) repo)
(sqlite-set-meta! store :local-tx t)))
(defn update-local-checksum
[repo checksum]
{:pre [(some? checksum)]}
(let [conn (worker-state/get-client-ops-conn repo)]
(assert (some? conn) repo)
(let [tx-data
(if-let [datom (first (d/datoms @conn :avet :db-sync/checksum))]
[:db/add (:e datom) :db-sync/checksum checksum]
(if-let [datom (first (d/datoms @conn :avet :local-tx))]
[:db/add (:e datom) :db-sync/checksum checksum]
[:db/add "e" :db-sync/checksum checksum]))]
(ldb/transact! conn [tx-data]))))
(let [store (sqlite-store-or-throw repo)]
(assert (some? store) repo)
(sqlite-set-meta! store :db-sync/checksum checksum)))
(defn remove-local-tx
[repo]
(when-let [conn (worker-state/get-client-ops-conn repo)]
(when-let [datom (first (d/datoms @conn :avet :local-tx))]
(ldb/transact! conn [[:db/retract (:e datom) :local-tx]]))))
(when-let [store (sqlite-store-or-throw repo)]
(sqlite-delete-meta! store :local-tx)))
(defn get-local-tx
[repo]
(when-let [conn (worker-state/get-client-ops-conn repo)]
(:v (first (d/datoms @conn :avet :local-tx)))))
(when-let [store (sqlite-store-or-throw repo)]
(some-> (sqlite-get-meta store :local-tx)
(js/parseInt 10))))
(defn get-pending-local-tx-count
[repo]
(if-let [cached (get @*repo->pending-local-tx-count repo)]
cached
(let [count' (if-let [conn (worker-state/get-client-ops-conn repo)]
(count (d/datoms @conn :avet :db-sync/pending? true))
(let [count' (if-let [store (sqlite-store-or-throw repo)]
(or (some-> (sqlite-row store
"select count(*) as c from client_ops where kind = 'tx' and pending = 1"
[])
(aget "c"))
0)
0)]
(swap! *repo->pending-local-tx-count assoc repo count')
count')))
@@ -122,9 +294,9 @@
(defn get-local-checksum
[repo]
(let [conn (worker-state/get-client-ops-conn repo)]
(assert (some? conn) repo)
(:v (first (d/datoms @conn :avet :db-sync/checksum)))))
(let [store (sqlite-store-or-throw repo)]
(assert (some? store) repo)
(sqlite-get-meta store :db-sync/checksum)))
(defn rtc-db-graph?
"Is RTC enabled"
@@ -132,12 +304,188 @@
(or (exists? js/process)
(some? (get-graph-uuid repo))))
(defn- row->pending-local-tx
[row]
(let [tx-id (parse-uuid-str (aget row "tx_id"))]
(when tx-id
{:tx-id tx-id
:outliner-op (str->kw (aget row "outliner_op"))
:forward-outliner-ops (or (normalize-op-entries
(sqlite-util/read-transit-str (aget row "forward_outliner_ops")))
[])
:inverse-outliner-ops (or (normalize-op-entries
(sqlite-util/read-transit-str (aget row "inverse_outliner_ops")))
[])
:inferred-outliner-ops? (int->bool (aget row "inferred_outliner_ops"))
:db-sync/undo-redo (str->kw (aget row "undo_redo"))
:tx (sqlite-util/read-transit-str (aget row "normalized_tx_data"))
:reversed-tx (sqlite-util/read-transit-str (aget row "reversed_tx_data"))})))
(defn upsert-local-tx-entry!
[repo {:keys [tx-id created-at pending? failed? outliner-op undo-redo
forward-outliner-ops inverse-outliner-ops inferred-outliner-ops?
normalized-tx-data reversed-tx-data]
:or {pending? true failed? false}}]
{:pre [(some? tx-id)]}
(let [store (sqlite-store-or-throw repo)]
(assert (some? store) repo)
(let [tx-id-str (str tx-id)
existing (sqlite-row store
"select pending, created_at from client_ops where kind = 'tx' and tx_id = ?"
[tx-id-str])
should-inc-pending? (not= 1 (some-> existing (aget "pending")))
forward-outliner-ops' (or (normalize-op-entries forward-outliner-ops) [])
inverse-outliner-ops' (or (normalize-op-entries inverse-outliner-ops) [])
created-at' (or (some-> existing (aget "created_at"))
created-at
(.now js/Date))]
(sqlite-run! store
(str "insert into client_ops ("
"kind, created_at, tx_id, pending, failed, outliner_op, undo_redo, "
"forward_outliner_ops, inverse_outliner_ops, inferred_outliner_ops, "
"normalized_tx_data, reversed_tx_data"
") values ('tx', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
" on conflict(tx_id) do update set "
"created_at = excluded.created_at,"
"pending = excluded.pending,"
"failed = excluded.failed,"
"outliner_op = excluded.outliner_op,"
"undo_redo = excluded.undo_redo,"
"forward_outliner_ops = excluded.forward_outliner_ops,"
"inverse_outliner_ops = excluded.inverse_outliner_ops,"
"inferred_outliner_ops = excluded.inferred_outliner_ops,"
"normalized_tx_data = excluded.normalized_tx_data,"
"reversed_tx_data = excluded.reversed_tx_data")
[created-at'
tx-id-str
(bool->int pending?)
(bool->int failed?)
(kw->str outliner-op)
(kw->str undo-redo)
(sqlite-util/write-transit-str forward-outliner-ops')
(sqlite-util/write-transit-str inverse-outliner-ops')
(bool->int inferred-outliner-ops?)
(sqlite-util/write-transit-str (or normalized-tx-data []))
(sqlite-util/write-transit-str (or reversed-tx-data []))])
{:tx-id tx-id
:created-at created-at'
:should-inc-pending? should-inc-pending?})))
(defn get-local-tx-entry
[repo tx-id]
(when (uuid? tx-id)
(when-let [store (sqlite-store-or-throw repo)]
(some-> (sqlite-row store
(str "select tx_id, outliner_op, undo_redo, "
"forward_outliner_ops, inverse_outliner_ops, inferred_outliner_ops, "
"normalized_tx_data, reversed_tx_data "
"from client_ops where kind = 'tx' and tx_id = ? limit 1")
[(str tx-id)])
(row->pending-local-tx)))))
(defn get-pending-local-txs
[repo & {:keys [limit]}]
(when-let [store (sqlite-store-or-throw repo)]
(let [sql (str "select tx_id, outliner_op, undo_redo, "
"forward_outliner_ops, inverse_outliner_ops, inferred_outliner_ops, "
"normalized_tx_data, reversed_tx_data "
"from client_ops where kind = 'tx' and pending = 1 "
"order by created_at asc, id asc"
(when (number? limit) " limit ?"))
rows (sqlite-rows store sql (if (number? limit) [limit] []))]
(->> rows
(keep row->pending-local-tx)
vec))))
(defn- pending-tx-id?
[store tx-id]
(let [row (sqlite-row store
"select pending from client_ops where kind = 'tx' and tx_id = ?"
[(str tx-id)])]
(= 1 (some-> row (aget "pending")))))
(defn mark-pending-txs-false!
[repo tx-ids]
(when-let [store (sqlite-store-or-throw repo)]
(let [tx-ids (->> tx-ids (filter uuid?) vec)
pending-to-remove (->> tx-ids
(filter (fn [tx-id]
(pending-tx-id? store tx-id)))
count)]
(when (seq tx-ids)
(doseq [tx-id tx-ids]
(sqlite-run! store
"update client_ops set pending = 0 where kind = 'tx' and tx_id = ?"
[(str tx-id)])))
pending-to-remove)))
(defn mark-failed-txs!
[repo tx-ids]
(when-let [store (sqlite-store-or-throw repo)]
(let [tx-ids (->> tx-ids (filter uuid?) vec)
pending-to-remove (->> tx-ids
(filter (fn [tx-id]
(pending-tx-id? store tx-id)))
count)]
(when (seq tx-ids)
(doseq [tx-id tx-ids]
(sqlite-run! store
"update client_ops set pending = 0, failed = 1 where kind = 'tx' and tx_id = ?"
[(str tx-id)])))
pending-to-remove)))
(defn history-action-ops-by-tx-id
[repo tx-id]
(when-let [entry (get-local-tx-entry repo tx-id)]
{:db-sync/forward-outliner-ops (some-> (:forward-outliner-ops entry) seq vec)
:db-sync/inverse-outliner-ops (some-> (:inverse-outliner-ops entry) seq vec)}))
(defn- local-asset-op-map
[op-type t value]
(let [asset-uuid (:block-uuid value)]
(case op-type
:update-asset {:block/uuid asset-uuid
:update-asset [:update-asset t value]}
:remove-asset {:block/uuid asset-uuid
:remove-asset [:remove-asset t value]}
nil)))
(defn- sqlite-asset-op-by-uuid
[store block-uuid]
(when-let [row (sqlite-row store
(str "select asset_uuid, asset_op, asset_t, asset_value "
"from client_ops where kind = 'asset' and asset_uuid = ? limit 1")
[(str block-uuid)])]
(let [op-type (str->kw (aget row "asset_op"))
t (aget row "asset_t")
value (or (some-> (aget row "asset_value") sqlite-util/read-transit-str)
{:block-uuid block-uuid})]
(local-asset-op-map op-type t value))))
(defn- sqlite-upsert-asset-op!
[store op-type t value]
(let [block-uuid (:block-uuid value)]
(sqlite-with-tx!
store
(fn [tx]
(sqlite-run! tx "delete from client_ops where kind = 'asset' and asset_uuid = ?"
[(str block-uuid)])
(sqlite-run! tx
(str "insert into client_ops ("
"kind, created_at, asset_uuid, asset_op, asset_t, asset_value"
") values ('asset', ?, ?, ?, ?, ?)")
[(.now js/Date)
(str block-uuid)
(kw->str op-type)
t
(sqlite-util/write-transit-str value)])))))
;;; asset ops
(defn add-asset-ops
[repo asset-ops]
(let [conn (worker-state/get-client-ops-conn repo)
(let [store (sqlite-store-or-throw repo)
ops (ops-coercer asset-ops)]
(assert (some? conn) repo)
(assert (some? store) repo)
(letfn [(already-removed? [remove-op t]
(some-> remove-op second (> t)))
(update-after-remove? [update-op t]
@@ -145,26 +493,20 @@
(doseq [op ops]
(let [[op-type t value] op
{:keys [block-uuid]} value
exist-block-ops-entity (d/entity @conn [:block/uuid block-uuid])
e (:db/id exist-block-ops-entity)]
(when-let [tx-data
(not-empty
(case op-type
:update-asset
(let [remove-asset-op (get exist-block-ops-entity :remove-asset)]
(when-not (already-removed? remove-asset-op t)
(cond-> [{:block/uuid block-uuid
:db-sync/asset-op? true
:update-asset op}]
remove-asset-op (conj [:db.fn/retractAttribute e :remove-asset]))))
:remove-asset
(let [update-asset-op (get exist-block-ops-entity :update-asset)]
(when-not (update-after-remove? update-asset-op t)
(cond-> [{:block/uuid block-uuid
:db-sync/asset-op? true
:remove-asset op}]
update-asset-op (conj [:db.fn/retractAttribute e :update-asset]))))))]
(ldb/transact! conn tx-data)))))))
existing-op (sqlite-asset-op-by-uuid store block-uuid)]
(case op-type
:update-asset
(let [remove-asset-op (:remove-asset existing-op)]
(when-not (already-removed? remove-asset-op t)
(sqlite-upsert-asset-op! store :update-asset t value)))
:remove-asset
(let [update-asset-op (:update-asset existing-op)]
(when-not (update-after-remove? update-asset-op t)
(sqlite-upsert-asset-op! store :remove-asset t value)))
nil)))
nil)))
(defn add-all-exists-asset-as-ops
[repo]
@@ -172,54 +514,61 @@
_ (assert (some? conn))
asset-block-uuids (->> (d/datoms @conn :avet :logseq.property.asset/type)
(keep (fn [d]
(:block/uuid (d/entity @conn (:e d))))))
ops (map
(fn [block-uuid] [:update-asset 1 {:block-uuid block-uuid}])
asset-block-uuids)]
(:block/uuid (d/entity @conn (:e d)))))
distinct)
ops (map (fn [block-uuid] [:update-asset 1 {:block-uuid block-uuid}])
asset-block-uuids)]
(add-asset-ops repo ops)))
(defn- get-all-asset-ops*
[db]
(->> (d/datoms db :avet :db-sync/asset-op?)
(map (fn [d]
(let [op (d/entity db (:e d))]
[(:e d) (into {} op)])))
(into {})))
(defn get-unpushed-asset-ops-count
[repo]
(when-let [conn (worker-state/get-client-ops-conn repo)]
(count (get-all-asset-ops* @conn))))
(when-let [store (sqlite-store-or-throw repo)]
(or (some-> (sqlite-row store
"select count(*) as c from client_ops where kind = 'asset'"
[])
(aget "c"))
0)))
(defn get-all-asset-ops
[repo]
(when-let [conn (worker-state/get-client-ops-conn repo)]
(vals (get-all-asset-ops* @conn))))
(when-let [store (sqlite-store-or-throw repo)]
(->> (sqlite-rows store
"select asset_op, asset_t, asset_value from client_ops where kind = 'asset' order by id asc"
[])
(keep (fn [row]
(let [op-type (str->kw (aget row "asset_op"))
t (aget row "asset_t")
value (some-> (aget row "asset_value") sqlite-util/read-transit-str)]
(when (and op-type (map? value) (:block-uuid value))
(local-asset-op-map op-type t value)))))
vec)))
(defn remove-asset-op
[repo asset-uuid]
(when-let [conn (worker-state/get-client-ops-conn repo)]
(let [ent (d/entity @conn [:block/uuid asset-uuid])]
(when-let [e (:db/id ent)]
(ldb/transact! conn [[:db/retractEntity e]])))))
(when-let [store (sqlite-store-or-throw repo)]
(sqlite-run! store
"delete from client_ops where kind = 'asset' and asset_uuid = ?"
[(str asset-uuid)])))
(defn cleanup-finished-history-ops!
[repo protected-tx-ids]
(if-let [conn (worker-state/get-client-ops-conn repo)]
(if-let [store (sqlite-store-or-throw repo)]
(let [protected-tx-ids (set protected-tx-ids)
tx-ent-ids (->> (d/datoms @conn :avet :db-sync/tx-id)
(keep (fn [datom]
(let [tx-id (:v datom)
ent (d/entity @conn (:e datom))]
(when (and (uuid? tx-id)
(false? (:db-sync/pending? ent))
(not (contains? protected-tx-ids tx-id)))
(:db/id ent)))))
vec)]
(when (seq tx-ent-ids)
(ldb/transact! conn
(mapv (fn [ent-id]
[:db/retractEntity ent-id])
tx-ent-ids)))
(count tx-ent-ids))
tx-id-rows (sqlite-rows store
(str "select tx_id from client_ops "
"where kind = 'tx' and pending = 0 and tx_id is not null")
[])
removable-tx-ids (->> tx-id-rows
(keep (fn [row]
(let [tx-id (parse-uuid-str (aget row "tx_id"))]
(when (and (uuid? tx-id)
(not (contains? protected-tx-ids tx-id)))
tx-id))))
vec)]
(when (seq removable-tx-ids)
(doseq [tx-id removable-tx-ids]
(sqlite-run! store
"delete from client_ops where kind = 'tx' and tx_id = ?"
[(str tx-id)])))
(count removable-tx-ids))
0))

View File

@@ -25,6 +25,13 @@
[platform']
(get-in platform' [:env :runtime]))
(defn- read-transit-safe
[value]
(try
(ldb/read-transit-str value)
(catch :default _
invalid-transit)))
(defn- browser-runtime?
[platform']
(= :browser (runtime platform')))
@@ -569,12 +576,17 @@
(defn <decrypt-text-value
[aes-key value]
(assert (string? value) (str "encrypted value should be a string, value: " value))
(let [decoded (ldb/read-transit-str value)]
(let [decoded (read-transit-safe value)]
(if (= decoded invalid-transit)
(p/resolved value)
(p/let [value (crypt/<decrypt-text-if-encrypted aes-key decoded)
value' (ldb/read-transit-str value)]
value'))))
(p/let [value (or (crypt/<decrypt-text-if-encrypted aes-key decoded)
decoded)
value' (if (string? value)
(read-transit-safe value)
value)]
(if (= value' invalid-transit)
value
value')))))
(defn- encrypt-tx-item
[aes-key item]

View File

@@ -60,22 +60,53 @@
(<decompress-gzip-bytes chunk)
chunk)))
(defn- response-body-stream
[^js resp]
(let [encoding (some-> resp .-headers (.get "content-encoding"))]
(cond
(nil? (.-body resp))
nil
(defn- <stream-starts-with-gzip?
[^js stream]
(let [reader (.getReader stream)]
(-> (.read reader)
(p/then (fn [result]
(if (.-done result)
false
(gzip-bytes? (->uint8 (.-value result))))))
(p/catch (fn [_] false))
(p/finally (fn []
(try
(.releaseLock reader)
(catch :default _)))))))
;; NOTE: Some runtimes (e.g. Node fetch) may auto-decompress while still
;; keeping `content-encoding: gzip` in headers. Avoid stream-level manual
;; decompression here to prevent double-decompression failures. For gzip,
;; fall back to arrayBuffer path where we detect gzip by magic bytes.
(defn- <response-body-stream
[^js resp]
(let [body (.-body resp)
encoding (some-> resp .-headers (.get "content-encoding"))]
(cond
(nil? body)
(p/resolved nil)
;; Never trust `content-encoding` alone.
;; Some runtimes (e.g. Node/undici fetch) may auto-decompress body while
;; still exposing `content-encoding: gzip` in headers.
;; We only stream-decompress when the first bytes are actual gzip magic.
(and (= "gzip" encoding)
(exists? js/DecompressionStream)
(fn? (.-tee body)))
(let [branches (.tee body)
probe (aget branches 0)
payload (aget branches 1)]
(-> (<stream-starts-with-gzip? probe)
(p/then (fn [gzip?]
(if gzip?
(.pipeThrough payload (js/DecompressionStream. "gzip"))
payload)))
;; If probing fails, keep original payload stream.
(p/catch (fn [_] payload))))
;; If we cannot safely probe (no tee support), do not guess.
;; Fall back to the arrayBuffer path where we inspect magic bytes first.
(= "gzip" encoding)
nil
(p/resolved nil)
:else
(.-body resp))))
(p/resolved body))))
(defn- <flush-row-batches!
[rows batch-size on-batch]
@@ -89,29 +120,30 @@
(defn- <stream-snapshot-row-batches!
[^js resp batch-size on-batch]
(if-let [stream (response-body-stream resp)]
(let [reader (.getReader stream)]
(p/loop [buffer nil
pending []]
(p/let [result (.read reader)]
(if (.-done result)
(let [pending (if (and buffer (pos? (.-byteLength buffer)))
(into pending (snapshot/finalize-framed-buffer buffer))
pending)]
(if (seq pending)
(p/let [_ (on-batch pending)]
{:chunk-count 1})
{:chunk-count 0}))
(let [{rows :rows next-buffer :buffer} (snapshot/parse-framed-chunk buffer (->uint8 (.-value result)))
pending (into pending rows)]
(p/let [pending (<flush-row-batches! pending batch-size on-batch)]
(p/recur next-buffer pending)))))))
(p/let [snapshot-bytes (<snapshot-response-bytes resp)
rows (vec (snapshot/finalize-framed-buffer snapshot-bytes))]
(if (seq rows)
(p/let [_ (on-batch rows)]
{:chunk-count 1})
{:chunk-count 0}))))
(p/let [stream (<response-body-stream resp)]
(if stream
(let [reader (.getReader stream)]
(p/loop [buffer nil
pending []]
(p/let [result (.read reader)]
(if (.-done result)
(let [pending (if (and buffer (pos? (.-byteLength buffer)))
(into pending (snapshot/finalize-framed-buffer buffer))
pending)]
(if (seq pending)
(p/let [_ (on-batch pending)]
{:chunk-count 1})
{:chunk-count 0}))
(let [{rows :rows next-buffer :buffer} (snapshot/parse-framed-chunk buffer (->uint8 (.-value result)))
pending (into pending rows)]
(p/let [pending (<flush-row-batches! pending batch-size on-batch)]
(p/recur next-buffer pending)))))))
(p/let [snapshot-bytes (<snapshot-response-bytes resp)
rows (vec (snapshot/finalize-framed-buffer snapshot-bytes))]
(if (seq rows)
(p/let [_ (on-batch rows)]
{:chunk-count 1})
{:chunk-count 0})))))
(defn- with-auth-headers
[opts]
@@ -184,6 +216,14 @@
(close-import-state! state)
(reset! *import-state nil))))
(defn close-import-state-for-repo!
[repo]
(when-let [state @*import-state]
(when (= repo (:repo state))
(close-import-state! state)
(reset! *import-state nil)))
nil)
(defn- require-import-state!
[repo graph-id import-id]
(let [state @*import-state]
@@ -262,13 +302,13 @@
(p/let [datoms-batch (if graph-e2ee?
(sync-crypt/<decrypt-snapshot-datoms-batch aes-key datoms)
datoms)
ident-tx-data (into [] (comp (filter #(= :db/ident (:a %)))
(map datom->tx))
datoms-batch)
regular-tx-data (into [] (comp (remove #(= :db/ident (:a %)))
schema-tx-data (into [] (comp (filter #(= "db" (namespace (:a %))))
(map datom->tx))
datoms-batch)
regular-tx-data (into [] (comp (remove #(= "db" (namespace (:a %))))
(map datom->tx))
datoms-batch)
tx-data (into ident-tx-data regular-tx-data)]
tx-data (into schema-tx-data regular-tx-data)]
(when (seq tx-data)
(d/transact! conn tx-data {:sync-download-graph? true}))))
@@ -346,6 +386,7 @@
[repo reset? graph-id graph-e2ee? & [total-datoms]]
(let [graph-e2ee? (if (nil? graph-e2ee?) true (true? graph-e2ee?))]
(-> (p/let [close-db-f (require-thread-api-f! :thread-api/db-sync-close-db)
unlink-db-f (require-thread-api-f! :thread-api/unsafe-unlink-db)
invalidate-search-db-f (require-thread-api-f! :thread-api/db-sync-invalidate-search-db)
create-or-open-db-f (require-thread-api-f! :thread-api/create-or-open-db)
_ (when-let [state @*import-state]
@@ -353,6 +394,7 @@
(close-db-f (:repo state)))
_ (reset! *import-state nil)
_ (when reset? (close-db-f repo))
_ (when reset? (unlink-db-f repo))
_ (when reset? (invalidate-search-db-f repo))
import-id (str (random-uuid))
aes-key (when graph-e2ee?

View File

@@ -1,13 +1,13 @@
(ns frontend.worker.sync.handle-message
"WebSocket message handlers for db sync."
(:require [datascript.core :as d]
[frontend.worker.shared-service :as shared-service]
(:require [frontend.worker.shared-service :as shared-service]
[frontend.worker.state :as worker-state]
[frontend.worker.sync.apply-txs :as sync-apply]
[frontend.worker.sync.assets :as sync-assets]
[frontend.worker.sync.auth :as sync-auth]
[frontend.worker.sync.client-op :as client-op]
[frontend.worker.sync.crypt :as sync-crypt]
[frontend.worker.sync.log-and-state :as sync-log-state]
[frontend.worker.sync.presence :as sync-presence]
[frontend.worker.sync.transport :as sync-transport]
[frontend.worker.sync.util :as sync-util]
@@ -21,10 +21,6 @@
(log/error tag data)
(throw (ex-info (name tag) data)))
(defn- client-ops-conn
[repo]
(sync-presence/client-ops-conn worker-state/get-client-ops-conn repo))
(defn- sync-counts
[repo]
(sync-presence/sync-counts
@@ -73,6 +69,20 @@
(fn [prev]
(p/then prev (fn [_] (task)))))))
(defn- enqueue-send-task!
[client task]
(if-let [queue (:send-queue client)]
(swap! queue
(fn [prev]
(-> (or prev (p/resolved nil))
(p/catch (fn [_] nil))
(p/then (fn [_] (task)))
(p/catch (fn [error]
(log/error :db-sync/send-queue-task-failed
{:repo (:repo client)
:error error}))))))
(task)))
(defn- current-client
[repo]
(sync-presence/current-client worker-state/*db-sync-client repo))
@@ -93,18 +103,37 @@
(when-not (sequential? value)
(fail-fast :db-sync/invalid-field (assoc context :value value))))
(defn- require-uuid
[value context]
(when-not (uuid? value)
(fail-fast :db-sync/invalid-field (assoc context :value value))))
(defn- parse-transit
[value context]
(sync-transport/parse-transit fail-fast value context))
(defn- request-pull!
[client since]
(when (and (:ws client) (ws-open? (:ws client)))
(enqueue-send-task!
client
(fn []
(when (and (:ws client) (ws-open? (:ws client)))
(if-let [*pending (:pending-pull-since client)]
(let [pending @*pending]
(when (or (nil? pending) (< since pending))
(reset! *pending since)
(send! (:ws client) {:type "pull" :since since})))
(send! (:ws client) {:type "pull" :since since})))))))
(defn- clear-pending-pull!
[client]
(when-let [*pending (:pending-pull-since client)]
(reset! *pending nil)))
(defn- pending-local-tx?
[repo]
(when-let [conn (client-ops-conn repo)]
(boolean
(some (fn [datom]
(let [ent (d/entity @conn (:e datom))]
(not= false (:db-sync/pending? ent))))
(d/datoms @conn :avet :db-sync/created-at)))))
(pos? (or (client-op/get-pending-local-tx-count repo) 0)))
(defn- checksum-compare-ready?
[repo client local-t remote-t]
@@ -124,48 +153,77 @@
(defn- verify-sync-checksum!
[repo client local-tx remote-tx remote-checksum context]
(when (and (string? remote-checksum)
(checksum-compare-ready? repo client local-tx remote-tx))
(let [local-checksum (local-sync-checksum repo)]
(when-not (= local-checksum remote-checksum)
(when worker-util/dev?
(log/warn :db-sync/checksum-mismatch
(merge context
{:type :db-sync/checksum-mismatch
:repo repo
:message-type (:type context)
:local-tx local-tx
:remote-tx remote-tx
:local-checksum local-checksum
:remote-checksum remote-checksum})))))))
(when worker-util/dev-or-test?
(when (and (string? remote-checksum)
(checksum-compare-ready? repo client local-tx remote-tx))
(let [local-checksum (local-sync-checksum repo)]
(when-not (= local-checksum remote-checksum)
(let [mismatch-data (merge context
{:type :db-sync/checksum-mismatch
:repo repo
:message-type (:type context)
:local-tx local-tx
:remote-tx remote-tx
:local-checksum local-checksum
:remote-checksum remote-checksum})]
(sync-log-state/rtc-log :rtc.log/checksum-mismatch mismatch-data)
(log/warn :db-sync/checksum-mismatch mismatch-data)))))))
(defn- handle-tx-reject!
[repo client message local-tx]
(let [reason (:reason message)
remote-tx (:t message)]
remote-tx (:t message)
success-tx-ids (:success-tx-ids message)
failed-tx-id (:failed-tx-id message)]
(when (nil? reason)
(fail-fast :db-sync/missing-field
{:repo repo :type "tx/reject" :field :reason}))
(when (contains? message :t)
(require-non-negative remote-tx {:repo repo :type "tx/reject"}))
(when (contains? message :success-tx-ids)
(require-seq success-tx-ids {:repo repo :type "tx/reject" :field :success-tx-ids})
(doseq [tx-id success-tx-ids]
(require-uuid tx-id {:repo repo :type "tx/reject" :field :success-tx-ids})))
(when (contains? message :failed-tx-id)
(require-uuid failed-tx-id {:repo repo :type "tx/reject" :field :failed-tx-id}))
(case reason
"stale"
(when (and (:ws client) (ws-open? (:ws client)))
(send! (:ws client) {:type "pull" :since local-tx}))
(request-pull! client local-tx)
(let [data (when-let [raw-data (:data message)]
(let [inflight @(:inflight client)
inflight-set (set inflight)
successful-tx-ids (->> (or success-tx-ids [])
(filter inflight-set)
vec)
failed-tx-id (when (and failed-tx-id (contains? inflight-set failed-tx-id))
failed-tx-id)
data (when-let [raw-data (:data message)]
(parse-transit raw-data
{:repo repo
:type "tx/reject"
:reason reason
:field :data}))]
:field :data}))
rejected-data (cond-> {:type :db-sync/tx-rejected
:repo repo
:message-type "tx/reject"
:reason reason}
(contains? message :t) (assoc :t remote-tx)
(seq successful-tx-ids) (assoc :success-tx-ids successful-tx-ids)
(some? failed-tx-id) (assoc :failed-tx-id failed-tx-id)
(some? data) (assoc :data data))]
(if (or (contains? message :success-tx-ids)
(contains? message :failed-tx-id))
(do
(sync-apply/mark-pending-txs-false! repo successful-tx-ids)
(when failed-tx-id
(sync-apply/mark-failed-txs! repo [failed-tx-id])))
;; Backward compatibility for older servers without per-tx reject metadata.
(sync-apply/mark-failed-txs! repo inflight))
(reset! (:inflight client) [])
(broadcast-rtc-state! client)
(sync-log-state/rtc-log :rtc.log/tx-rejected rejected-data)
(fail-fast :db-sync/tx-rejected
(cond-> {:type :db-sync/tx-rejected
:repo repo
:message-type "tx/reject"
:reason reason}
(contains? message :t) (assoc :t remote-tx)
(some? data) (assoc :data data)))))))
rejected-data)))))
(defn- handle-hello!
[repo client local-tx remote-tx remote-checksum]
@@ -173,14 +231,14 @@
(verify-sync-checksum! repo client local-tx remote-tx remote-checksum {:type "hello"})
(broadcast-rtc-state! client)
(when (> remote-tx local-tx)
(send! (:ws client) {:type "pull" :since local-tx}))
(request-pull! client local-tx))
(sync-assets/enqueue-asset-sync!
repo client
{:enqueue-asset-task-f enqueue-asset-task!
:current-client-f current-client
:broadcast-rtc-state!-f broadcast-rtc-state!
:fail-fast-f fail-fast})
(sync-apply/flush-pending! repo client))
(sync-apply/enqueue-flush-pending! repo client))
(defn- handle-online-users!
[repo client message]
@@ -199,16 +257,64 @@
(defn- handle-tx-batch-ok!
[repo client remote-tx remote-checksum]
(require-non-negative remote-tx {:repo repo :type "tx/batch/ok"})
(client-op/update-local-tx repo remote-tx)
(sync-util/clear-last-sync-error! client)
(broadcast-rtc-state! client)
(sync-apply/remove-pending-txs! repo @(:inflight client))
(reset! (:inflight client) [])
(verify-sync-checksum! repo client remote-tx remote-tx remote-checksum {:type "tx/batch/ok"})
(sync-apply/flush-pending! repo client))
(let [current-local-tx (or (client-op/get-local-tx repo) 0)
next-local-tx (max current-local-tx remote-tx)]
(client-op/update-local-tx repo next-local-tx)
(sync-util/clear-last-sync-error! client)
(broadcast-rtc-state! client)
(sync-apply/mark-pending-txs-false! repo @(:inflight client))
(reset! (:inflight client) [])
(verify-sync-checksum! repo client next-local-tx remote-tx remote-checksum {:type "tx/batch/ok"})
(sync-apply/enqueue-flush-pending! repo client)))
(defn- update-latest-remote-state!
[repo message]
(let [remote-tx (:t message)
remote-checksum (:checksum message)
has-checksum? (contains? message :checksum)
latest-remote-tx (get @sync-apply/*repo->latest-remote-tx repo)
stale-remote-tx? (and (number? remote-tx)
(number? latest-remote-tx)
(< remote-tx latest-remote-tx))]
(when (number? remote-tx)
(swap! sync-apply/*repo->latest-remote-tx
update repo
(fn [prev]
(if (number? prev)
(max prev remote-tx)
remote-tx))))
(when (and has-checksum? (not stale-remote-tx?))
(swap! sync-apply/*repo->latest-remote-checksum assoc repo remote-checksum))
{:stale-remote-tx? stale-remote-tx?
:latest-remote-tx-before latest-remote-tx}))
(declare handle-pull-ok! handle-changed!)
(defn handle-message!
[repo client raw]
(let [message (-> raw
sync-transport/parse-message
sync-transport/coerce-ws-server-message)]
(when-not (map? message)
(fail-fast :db-sync/response-parse-failed {:repo repo :raw raw}))
(let [local-tx (or (client-op/get-local-tx repo) 0)
remote-tx (:t message)
remote-checksum (:checksum message)]
(update-latest-remote-state! repo message)
(case (:type message)
"hello" (handle-hello! repo client local-tx remote-tx remote-checksum)
"online-users" (handle-online-users! repo client message)
"presence" (handle-presence! client message)
"tx/batch/ok" (handle-tx-batch-ok! repo client remote-tx remote-checksum)
"pull/ok" (handle-pull-ok! repo client local-tx remote-tx remote-checksum message)
"changed" (handle-changed! repo client local-tx remote-tx)
"tx/reject" (handle-tx-reject! repo client message local-tx)
(fail-fast :db-sync/invalid-field
{:repo repo :type (:type message)})))))
(defn- handle-pull-ok!
[repo client local-tx remote-tx remote-checksum message]
(clear-pending-pull! client)
(when (> remote-tx local-tx)
(let [txs (:txs message)]
(require-non-negative remote-tx {:repo repo :type "pull/ok"})
@@ -239,7 +345,7 @@
(client-op/update-local-tx repo remote-tx)
(broadcast-rtc-state! client)
(verify-sync-checksum! repo client remote-tx remote-tx remote-checksum {:type "pull/ok"})
(sync-apply/flush-pending! repo client))
(sync-apply/enqueue-flush-pending! repo client))
(p/then (fn [_]
(sync-util/clear-last-sync-error! client)))
(p/catch (fn [error]
@@ -250,29 +356,4 @@
(require-non-negative remote-tx {:repo repo :type "changed"})
(broadcast-rtc-state! client)
(when (< local-tx remote-tx)
(send! (:ws client) {:type "pull" :since local-tx})))
(defn handle-message!
[repo client raw]
(let [message (-> raw
sync-transport/parse-message
sync-transport/coerce-ws-server-message)]
(when-not (map? message)
(fail-fast :db-sync/response-parse-failed {:repo repo :raw raw}))
(let [local-tx (or (client-op/get-local-tx repo) 0)
remote-tx (:t message)
remote-checksum (:checksum message)]
(when remote-tx
(swap! sync-apply/*repo->latest-remote-tx assoc repo remote-tx))
(when (contains? message :checksum)
(swap! sync-apply/*repo->latest-remote-checksum assoc repo remote-checksum))
(case (:type message)
"hello" (handle-hello! repo client local-tx remote-tx remote-checksum)
"online-users" (handle-online-users! repo client message)
"presence" (handle-presence! client message)
"tx/batch/ok" (handle-tx-batch-ok! repo client remote-tx remote-checksum)
"pull/ok" (handle-pull-ok! repo client local-tx remote-tx remote-checksum message)
"changed" (handle-changed! repo client local-tx remote-tx)
"tx/reject" (handle-tx-reject! repo client message local-tx)
(fail-fast :db-sync/invalid-field
{:repo repo :type (:type message)})))))
(request-pull! client local-tx)))

View File

@@ -15,6 +15,8 @@
(defkeywords
:rtc.log/upload {:doc "rtc log type for upload-graph."}
:rtc.log/download {:doc "rtc log type for upload-graph."}
:rtc.log/checksum-mismatch {:doc "local/remote checksum mismatch detected"}
:rtc.log/tx-rejected {:doc "tx/reject received from server"}
:rtc.asset.log/upload-assets {:doc "upload local assets to remote"}
:rtc.asset.log/download-assets {:doc "download assets from remote"}
:rtc.asset.log/remove-assets {:doc "remove remote assets"}

View File

@@ -1,7 +1,6 @@
(ns frontend.worker.sync.presence
"Presence and rtc state helpers for db sync."
(:require [datascript.core :as d]
[logseq.common.util :as common-util]
(:require [logseq.common.util :as common-util]
[frontend.worker.state :as worker-state]))
(defn current-client
@@ -16,7 +15,6 @@
(defn sync-counts
[{:keys [get-datascript-conn
get-client-ops-conn
get-pending-local-tx-count
get-unpushed-asset-ops-count
get-local-tx
@@ -28,8 +26,7 @@
(when (get-datascript-conn repo)
(let [pending-local (if get-pending-local-tx-count
(get-pending-local-tx-count repo)
(when-let [conn (client-ops-conn get-client-ops-conn repo)]
(count (d/datoms @conn :avet :db-sync/pending? true))))
0)
pending-asset (get-unpushed-asset-ops-count repo)
local-tx (get-local-tx repo)
remote-tx (get latest-remote-tx repo)

View File

@@ -32,7 +32,7 @@
first)]
(let [[content addresses] (bean/->clj result)
addresses (when addresses (js/JSON.parse addresses))
data (sqlite-util/transit-read content)]
data (sqlite-util/read-transit-str content)]
(if (and addresses (map? data))
(assoc data :addresses addresses)
data))))
@@ -48,7 +48,7 @@
(when-let [addresses (:addresses data)]
(js/JSON.stringify (bean/->js addresses))))]
#js {:$addr addr
:$content (sqlite-util/transit-write data')
:$content (sqlite-util/write-transit-str data')
:$addresses addresses}))
addr+data-seq)]
(upsert-addr-content! db data)))

View File

@@ -53,9 +53,23 @@
(defn coerce-ws-server-message
[message]
(when message
(let [coerced (coerce db-sync-schema/ws-server-message-coercer message {:schema :ws/server})]
(when-not (= coerced invalid-coerce)
coerced))))
(letfn [(uuid-like->string [value]
(cond
(uuid? value) (str value)
(and (map? value) (string? (:uuid value))) (:uuid value)
:else value))
(normalize-legacy-tx-reject [m]
(if (= "tx/reject" (:type m))
(cond-> m
(contains? m :failed-tx-id) (update :failed-tx-id uuid-like->string)
(contains? m :success-tx-ids) (update :success-tx-ids
(fn [ids]
(mapv uuid-like->string (or ids [])))))
m))]
(let [message* (normalize-legacy-tx-reject message)
coerced (coerce db-sync-schema/ws-server-message-coercer message* {:schema :ws/server})]
(when-not (= coerced invalid-coerce)
coerced)))))
(defn parse-transit
[fail-fast-f value context]
@@ -82,5 +96,14 @@
[coerce-ws-client-message-f ws message]
(when (ws-open? ws)
(if-let [coerced (coerce-ws-client-message-f message)]
(.send ws (js/JSON.stringify (clj->js coerced)))
(let [message* (if (= "tx/batch" (:type coerced))
(update coerced :txs
(fn [txs]
(mapv (fn [tx-entry]
(if-let [tx-id (:tx-id tx-entry)]
(assoc tx-entry :tx-id (str tx-id))
tx-entry))
txs)))
coerced)]
(.send ws (js/JSON.stringify (clj->js message*))))
(log/error :db-sync/ws-request-invalid {:message message}))))

View File

@@ -100,7 +100,7 @@
:process-batch-f
(fn [batch]
(p/let [datoms* (sync-large-title/offload-large-titles-in-datoms-batch
repo graph-id batch aes-key sync-large-title/upload-large-title!)
repo graph-id batch aes-key sync-apply/upload-large-title!)
encrypted-datoms (if aes-key
(sync-crypt/<encrypt-datoms aes-key datoms*)
datoms*)

View File

@@ -2,6 +2,7 @@
"Undo redo new implementation"
(:require [datascript.core :as d]
[frontend.worker.state :as worker-state]
[frontend.worker.sync.client-op :as client-op]
[lambdaisland.glogi :as log]
[logseq.common.defkeywords :refer [defkeywords]]
[malli.core :as m]
@@ -45,8 +46,10 @@
[:added-ids [:set :int]]
[:retracted-ids [:set :int]]
[:db-sync/tx-id {:optional true} :uuid]
[:db-sync/forward-outliner-ops {:optional true} [:sequential :any]]
[:db-sync/inverse-outliner-ops {:optional true} [:sequential :any]]]]]
[:db-sync/forward-outliner-ops {:optional true}
[:maybe [:sequential :any]]]
[:db-sync/inverse-outliner-ops {:optional true}
[:maybe [:sequential :any]]]]]]
[::record-editor-info
[:cat :keyword
@@ -206,7 +209,7 @@
[repo undo?]
(undo-redo-aux repo undo?))
(defn- run-worker-path
(defn- apply-history-action
[repo conn undo? op tx-meta' tx-id]
(if-let [apply-action @*apply-history-action!]
(try
@@ -253,14 +256,10 @@
(second %))
op)]
(let [tx-id (:db-sync/tx-id data)
forward-outliner-ops (:db-sync/forward-outliner-ops data)
inverse-outliner-ops (:db-sync/inverse-outliner-ops data)
tx-meta' (-> (undo-redo-action-meta data undo?)
(assoc :forward-outliner-ops forward-outliner-ops
:inverse-outliner-ops inverse-outliner-ops
:db-sync/forward-outliner-ops forward-outliner-ops
:db-sync/inverse-outliner-ops inverse-outliner-ops))]
(run-worker-path repo conn undo? op tx-meta' tx-id))))
tx-meta' (merge (undo-redo-action-meta data undo?)
(select-keys data [:db-sync/forward-outliner-ops
:db-sync/inverse-outliner-ops]))]
(apply-history-action repo conn undo? op tx-meta' tx-id))))
(defn- undo-redo-aux
[repo undo?]
@@ -302,10 +301,7 @@
(defn- pending-history-action-ops
[repo tx-id]
(when (uuid? tx-id)
(when-let [conn (get @worker-state/*client-ops-conns repo)]
(when-let [ent (d/entity @conn [:db-sync/tx-id tx-id])]
{:db-sync/forward-outliner-ops (some-> (:db-sync/forward-outliner-ops ent) seq vec)
:db-sync/inverse-outliner-ops (some-> (:db-sync/inverse-outliner-ops ent) seq vec)}))))
(client-op/history-action-ops-by-tx-id repo tx-id)))
(defn gen-undo-ops!
[repo {:keys [tx-data tx-meta db-after db-before]} tx-id
@@ -319,8 +315,7 @@
outliner-op
(not (false? (:gen-undo-ops? tx-meta)))
(not (:create-today-journal? tx-meta))
(seq forward-outliner-ops)
(seq inverse-outliner-ops))
(not (contains? #{:create-view} (:source-outliner-op tx-meta))))
(let [all-ids (distinct (map :e tx-data))
retracted-ids (set
(filter

View File

@@ -23,7 +23,11 @@
(do ~@body))))
#?(:cljs
(def dev? js/goog.DEBUG))
(do
(goog-define NODETEST false)
(def dev? js/goog.DEBUG)
(def node-test? NODETEST)
(def dev-or-test? (or dev? node-test?))))
#?(:cljs
(do

View File

@@ -83,6 +83,7 @@
(def ^:export clear_right_sidebar_blocks api-app/clear_right_sidebar_blocks)
(def ^:export push_state api-app/push_state)
(def ^:export replace_state api-app/replace_state)
(def ^:export get_current_route api-app/get_current_route)
;; db
(def ^:export q api-db/q)
@@ -181,7 +182,9 @@
;; search
(defn ^:export search
[q' & [opts]]
(-> (search-handler/search (state/get-current-repo) q' (if opts (js->clj opts :keywordize-keys true) {}))
(-> (search-handler/search
(state/get-current-repo) q'
(if opts (js->clj opts :keywordize-keys true) {}))
(p/then #(bean/->js (sdk-utils/normalize-keyword-for-json %)))))
;; helpers

View File

@@ -172,3 +172,9 @@
(if-let [page-name (and page? (:name params))]
(route-handler/redirect-to-page! page-name {:anchor (:anchor query) :push false})
(rfe/replace-state k params query)))))
(def get_current_route
(fn []
(some-> (state/get-route-match)
(dissoc :data)
(bean/->js))))

View File

@@ -295,17 +295,21 @@
(resolve-eid this prop-uuid-or-ident-or-title
api-block/resolve-property-prefix-for-db))
(defn- get-tags [name]
(some->> (entity-util/get-pages-by-name (db-conn/get-db) name)
(map #(some-> % (first) (db/entity)))
(filter ldb/class?)))
(defn get-tag [class-uuid-or-ident-or-title]
(this-as this
(let [eid (resolve-tag-eid this class-uuid-or-ident-or-title)
tag (db/entity eid)]
tag (db/entity eid)
tag (or tag (some-> (get-tags class-uuid-or-ident-or-title) first))]
(when (ldb/class? tag)
(sdk-utils/result->js tag)))))
(defn get-tags-by-name [name]
(when-let [tags (some->> (entity-util/get-pages-by-name (db-conn/get-db) name)
(map #(some-> % (first) (db/entity)))
(filter ldb/class?))]
(when-let [tags (get-tags name)]
(sdk-utils/result->js tags)))
(defn tag-add-property [tag-id property-id-or-name]

View File

@@ -56,7 +56,7 @@
(defn import-edn
"Given EDN data as a transitized string, converts to EDN and imports it."
[edn-data*]
(p/let [edn-data (sqlite-util/transit-read edn-data*)
(p/let [edn-data (sqlite-util/read-transit-str edn-data*)
{:keys [error]} (ui-outliner-tx/transact!
{:outliner-op :batch-import-edn}
(outliner-op/batch-import-edn! edn-data {}))]
@@ -72,5 +72,5 @@
result (state/<invoke-db-worker :thread-api/export-edn (state/get-current-repo) options)]
(when (:export-edn-error result)
(throw (ex-info (str "Export EDN Error: " (:export-edn-error result)) {})))
{:export-body (sqlite-util/transit-write result)
:graph (string/replace-first (state/get-current-repo) common-config/db-version-prefix "")}))
{:export-body (sqlite-util/write-transit-str result)
:graph (string/replace-first (state/get-current-repo) common-config/db-version-prefix "")}))

View File

@@ -27,6 +27,7 @@
[logseq.api.block :as api-block]
[logseq.api.db-based :as db-based-api]
[logseq.common.path :as path]
[logseq.outliner.property :as outliner-property]
[logseq.common.util.date-time :as date-time-util]
[logseq.db :as ldb]
[logseq.sdk.core]
@@ -439,35 +440,56 @@
key (api-block/get-db-ident-from-property-name key this)]
(property-handler/remove-block-property! block-uuid key))))))
(defn- get-block-classes-properties-has-default-value
[block-id]
(when-let [classes-properties
(some-> (outliner-property/get-block-classes-properties (db/get-db) block-id)
:classes-properties)]
(let [properties (->> classes-properties
(filter :logseq.property/default-value)
(map (fn [property]
[(:db/ident property)
(:logseq.property/default-value property)])))
properties (into {} properties)]
properties)))
(defn- get-all-block-properties
[id]
(p/let [block (<get-block id {:children? false})]
(when-let [own-properties (:block/properties block)]
(let [classes-properties (get-block-classes-properties-has-default-value (:db/id block))
properties (if (seq classes-properties)
(merge classes-properties own-properties)
own-properties)]
properties))))
(defn get_block_property
[id key]
(this-as this
(p/let [block (<get-block id {:children? false})]
(when-let [properties (some-> block (:block/uuid) (db-model/get-block-by-uuid) (:block/properties))]
(when (seq properties)
(let [property-name (api-block/sanitize-user-property-name key)
ident (api-block/get-db-ident-from-property-name property-name this)
property-value (or (get properties property-name)
(get properties (keyword property-name))
(get properties ident))
property-value (if-let [property-id (:db/id property-value)]
(db/pull property-id) property-value)
property-value (cond-> property-value
(map? property-value)
(assoc
:value (or (:logseq.property/value property-value)
(:block/title property-value))
:ident ident))
parsed-value (api-block/parse-property-json-value-if-need ident property-value)]
(or parsed-value
(bean/->js (sdk-utils/normalize-keyword-for-json property-value)))))))))
(p/let [properties (get-all-block-properties id)]
(when (seq properties)
(let [property-name (api-block/sanitize-user-property-name key)
ident (api-block/get-db-ident-from-property-name property-name this)
property-value (or (get properties property-name)
(get properties (keyword property-name))
(get properties ident))
property-value (if-let [property-id (:db/id property-value)]
(db/pull property-id) property-value)
property-value (cond-> property-value
(map? property-value)
(assoc
:value (or (:logseq.property/value property-value)
(:block/title property-value))
:ident ident))
parsed-value (api-block/parse-property-json-value-if-need ident property-value)]
(or parsed-value
(sdk-utils/result->js property-value)))))))
(def get_block_properties
(fn [id]
(p/let [block (<get-block id {:children? false})]
(when block
(let [properties (api-block/into-readable-db-properties (:block/properties block))]
(sdk-utils/result->js properties))))))
(p/let [properties (get-all-block-properties id)]
(when-let [properties (some-> properties (api-block/into-readable-db-properties))]
(sdk-utils/result->js properties)))))
(defn get_page_properties
[id-or-page-name]

View File

@@ -8,3 +8,26 @@
(get @state/state (keyword path))
@state/state)
(bean/->js)))
(defn- current-repo-or-throw
[]
(if-let [repo (state/get-current-repo)]
repo
(throw (ex-info "No current repo" {:type :debug/no-current-repo}))))
(defn ^:export syncStopUpload
[]
(state/<invoke-db-worker :thread-api/db-sync-stop-upload (current-repo-or-throw)))
(defn ^:export syncResumeUpload
[]
(state/<invoke-db-worker :thread-api/db-sync-resume-upload (current-repo-or-throw)))
(defn ^:export syncUploadStopped
[]
(state/<invoke-db-worker :thread-api/db-sync-upload-stopped? (current-repo-or-throw)))
;; Snake_case aliases for consistency with existing debug exports.
(defn ^:export sync_stop_upload [] (syncStopUpload))
(defn ^:export sync_resume_upload [] (syncResumeUpload))
(defn ^:export sync_upload_stopped [] (syncUploadStopped))

View File

@@ -39,6 +39,13 @@
(keyword pid) key (reduce #(assoc %1 %2 (aget opts (name %2))) {}
[:before :subs :render]))))
(defn ^:export register_hosted_renderer
[pid key ^js opts]
(when-let [^js _pl (plugin-handler/get-plugin-inst pid)]
(plugin-handler/register-hosted-renderer
(keyword pid) key (reduce #(assoc %1 %2 (aget opts (name %2))) {}
[:title :type :mode :subs :render]))))
(defn ^:export register_extensions_enhancer
[pid type enhancer]
(when-let [^js _pl (and (fn? enhancer) (plugin-handler/get-plugin-inst pid))]

View File

@@ -142,6 +142,14 @@
(shui/popup-show! nil (fn [] (log)) {}))}
[:span.text-base "Check log"]]
[:div.mobile-setting-item
{:on-click #(state/pub-event! [:go/sync-server-settings])}
[:span.text-base "Sync server URL"]
[:span.text-sm.opacity-70
(if-let [custom (config/get-custom-sync-server-url)]
custom
"Logseq sync (default)")]]
(when login?
[:div.mobile-setting-item
{:on-click (fn []

View File

@@ -102,7 +102,6 @@
:page/logseq-is-having-a-problem "Logseq يواجه مشكلة. لمحاولة إعادته إلى حالة العمل، يرجى اتباع الخطوات الآمنة التالية بالترتيب:"
:page/step "الخطوة {1}"
:page/try "حاول"
:page/delete-confirmation "هل أنت متأكد من رغبتك في حذف هذه الصفحة؟"
:page/db-delete-confirmation "هل أنت متأكد من رغبتك في حذف هذه الصفحة؟"
:page/make-public "اجعلها متاحة للنشر"
:page/make-private "اجعله خاصاً"

View File

@@ -196,7 +196,6 @@
:page/created-at "Erstellt am"
:page/db-delete-confirmation "Diese Seite wirklich entfernen?"
:page/delete "Seite löschen"
:page/delete-confirmation "Diese Seite wirklich entfernen?"
:page/logseq-is-having-a-problem "Logseq hat ein Problem festgestellt. Versuche zurückzukehren ..."
:page/make-private "Privat machen"
:page/make-public "Beim Export in HTML veröffentlichen"

View File

@@ -102,8 +102,9 @@
:page/logseq-is-having-a-problem "Logseq is having a problem. To try to get it back to a working state, please try the following safe steps in order:"
:page/step "Step {1}"
:page/try "Try"
:page/delete-confirmation "Are you sure you want to delete this page?"
:page/batch-delete-confirmation "Are you sure you want to delete these page(s)? Properties and tags will be permanently deleted and pages will be recycled."
:page/db-delete-confirmation "Are you sure you want to delete this page?"
:page/permanently-delete-confirmation "Are you sure you want to permanently delete this page?"
:page/make-public "Make it public for publishing"
:page/make-private "Make it private"
:page/delete "Delete page"
@@ -127,6 +128,7 @@
:linked-references/filter-excludes "Excludes: "
:editor/block-search "Search for a block"
:text/image "Image"
:asset/show-file-in-folder "Show file in folder"
:asset/show-in-folder "Show image in folder"
:asset/open-in-browser "Open image in browser"
:asset/delete "Delete image"
@@ -222,6 +224,12 @@
:settings-page/plugin-system "Plugins"
:settings-page/enable-flashcards "Flashcards"
:settings-page/network-proxy "Network proxy"
:settings-page/sync-server-url "Sync server URL"
:settings-page/sync-server-url-desc "Set a custom HTTPS sync server URL for self-hosted sync. Your Logseq authentication tokens will be sent to this server, so only use a trusted URL. Leave empty to use the official Logseq sync."
:settings-page/sync-server-url-saved "Sync server URL saved."
:settings-page/sync-server-url-cleared "Sync server URL cleared. Using official Logseq sync."
:settings-page/sync-server-url-default "Logseq sync (default)"
:settings-page/sync-server-url-reset "Reset to default"
:settings-page/login-prompt "To access new features before anyone else you must be an Open Collective Sponsor or Backer of Logseq and therefore log in first."
:settings-page/native-titlebar "Native title bar"
:settings-page/native-titlebar-desc "Enables the native window title bar on Windows and Linux."
@@ -581,6 +589,7 @@
:dev/replace-graph-with-db-file "(Dev) Replace graph with its db.sqlite file"
:dev/validate-db "(Dev) Validate current graph"
:dev/recompute-checksum "(Dev) Recompute graph checksum"
:dev/export-client-ops-sqlite "(Dev) Export client ops sqlite"
:dev/gc-graph "(Dev) Garbage collect graph (remove unused data in SQLite)"
:dev/rtc-stop "(Dev) RTC Stop"
:dev/rtc-start "(Dev) RTC Start"

View File

@@ -101,7 +101,6 @@
:page/logseq-is-having-a-problem "Logseq está com um problema. Para tentar fazê-lo voltar a funcionar, siga as etapas seguras a seguir:"
:page/step "Etapa {1}"
:page/try "Tentar"
:page/delete-confirmation "Você tem certeza que quer deletar essa página?"
:page/db-delete-confirmation "Você tem certeza que quer deletar essa página?"
:page/make-public "Torná-la pública para publicação"
:page/make-private "Tornar privado"

View File

@@ -264,7 +264,7 @@
:color/red "红色"
:color/yellow "黄色"
:updater/new-version-install "新版本已经准备就绪,重启应用即可更新。"
:updater/new-version-install "新版本已经准备就绪。"
:updater/quit-and-install "现在安装"
:notification/clear-all "清除全部通知"

View File

@@ -0,0 +1,35 @@
(ns frontend.components.property.property-test
(:require [cljs.test :refer [deftest is]]
[frontend.components.property :as property-component]))
(deftest sanitize-property-values-for-display-filters-recycled-entity-values-test
(let [active-value {:db/id 101
:block/title "Active"}
recycled-value {:db/id 102
:block/title "Recycled"
:logseq.property/deleted-at 1}
{:keys [properties recycled-only-property-ids]}
(#'property-component/sanitize-property-values-for-display
{:user.property/node #{active-value recycled-value}
:user.property/single recycled-value
:user.property/scalar "ok"})]
(is (= #{active-value}
(:user.property/node properties)))
(is (nil? (:user.property/single properties)))
(is (= "ok" (:user.property/scalar properties)))
(is (= #{:user.property/single}
recycled-only-property-ids))))
(deftest sanitize-property-values-for-display-marks-all-recycled-coll-as-hidden-test
(let [recycled-a {:db/id 201
:block/title "Recycled A"
:logseq.property/deleted-at 1}
recycled-b {:db/id 202
:block/title "Recycled B"
:logseq.property/deleted-at 2}
{:keys [properties recycled-only-property-ids]}
(#'property-component/sanitize-property-values-for-display
{:user.property/nodes [recycled-a recycled-b]})]
(is (nil? (:user.property/nodes properties)))
(is (= #{:user.property/nodes}
recycled-only-property-ids))))

View File

@@ -0,0 +1,67 @@
(ns frontend.components.property.value-test
(:require [cljs.test :refer [async deftest is]]
[frontend.components.property.value :as property-value]
[promesa.core :as p]))
(deftest resolve-journal-page-for-date-returns-existing-page-test
(async done
(let [existing-page {:db/id 100
:block/journal-day 20250102}
created?* (atom false)]
(-> (#'property-value/<resolve-journal-page-for-date
(js/Date. "2025-01-02T00:00:00Z")
(constantly "test-repo")
(fn [_repo _title _opts]
(p/resolved existing-page))
(fn [_title _opts]
(reset! created?* true)
(p/resolved {:db/id 999
:block/journal-day 20250102}))
(constantly "Jan 2nd, 2025"))
(p/then (fn [page]
(is (= existing-page page))
(is (false? @created?*))
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))
(deftest resolve-journal-page-for-date-creates-page-when-missing-test
(async done
(let [created-page {:db/id 200
:block/journal-day 20250102}
created-calls* (atom [])]
(-> (#'property-value/<resolve-journal-page-for-date
(js/Date. "2025-01-02T00:00:00Z")
(constantly "test-repo")
(fn [_repo _title _opts]
(p/resolved nil))
(fn [title opts]
(swap! created-calls* conj [title opts])
(p/resolved created-page))
(constantly "Jan 2nd, 2025"))
(p/then (fn [page]
(is (= created-page page))
(is (= [["Jan 2nd, 2025" {:redirect? false}]] @created-calls*))
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))
(deftest resolved-property-value-for-render-skips-default-for-placeholder-row-test
(let [property {:db/ident :logseq.property/status
:logseq.property/default-value {:db/id 3
:db/ident :logseq.property/status.todo
:block/title "Todo"}}
placeholder-block {:db/id 1}]
(is (nil? (#'property-value/resolved-property-value-for-render placeholder-block property false)))))
(deftest resolved-property-value-for-render-uses-default-for-loaded-block-test
(let [property {:db/ident :logseq.property/status
:logseq.property/default-value {:db/id 3
:db/ident :logseq.property/status.todo
:block/title "Todo"}}
loaded-block {:db/id 1
:block/uuid #uuid "11111111-1111-1111-1111-111111111111"}]
(is (= (:logseq.property/default-value property)
(#'property-value/resolved-property-value-for-render loaded-block property false)))))

View File

@@ -1,5 +1,5 @@
(ns frontend.config-test
(:require [cljs.test :refer [deftest is]]
(:require [cljs.test :refer [deftest is testing]]
[frontend.config :as config]
[frontend.state :as state]
[logseq.common.config :as common-config]))
@@ -8,3 +8,67 @@
(with-redefs [state/state (atom {:system/info {:home-dir "/tmp/home"}})]
(is (= "/tmp/home/logseq/graphs/foo~2Fbar"
(config/get-local-dir (str common-config/db-version-prefix "foo/bar"))))))
(deftest custom-url->ws-url-test
(testing "https URL becomes wss"
(is (= "wss://my-server.example.com/sync/%s"
(config/custom-url->ws-url "https://my-server.example.com"))))
(testing "http URL becomes ws"
(is (= "ws://localhost:8787/sync/%s"
(config/custom-url->ws-url "http://localhost:8787"))))
(testing "trailing slashes are stripped"
(is (= "wss://my-server.example.com/sync/%s"
(config/custom-url->ws-url "https://my-server.example.com/")))
(is (= "wss://my-server.example.com/sync/%s"
(config/custom-url->ws-url "https://my-server.example.com///"))))
(testing "preserves port in URL"
(is (= "wss://example.com:3000/sync/%s"
(config/custom-url->ws-url "https://example.com:3000"))))
(testing "preserves subpath in host"
;; Users should only provide a base URL, but verify trailing path doesn't break things
(is (= "wss://example.com/api/sync/%s"
(config/custom-url->ws-url "https://example.com/api")))))
(deftest custom-url->http-base-test
(testing "returns URL as-is when no trailing slash"
(is (= "https://my-server.example.com"
(config/custom-url->http-base "https://my-server.example.com"))))
(testing "strips trailing slashes"
(is (= "https://my-server.example.com"
(config/custom-url->http-base "https://my-server.example.com/")))
(is (= "https://my-server.example.com"
(config/custom-url->http-base "https://my-server.example.com///"))))
(testing "preserves http scheme"
(is (= "http://localhost:8787"
(config/custom-url->http-base "http://localhost:8787"))))
(testing "preserves port"
(is (= "https://example.com:3000"
(config/custom-url->http-base "https://example.com:3000/")))))
(deftest default-urls-are-returned-when-no-custom-url
(testing "db-sync-ws-url returns default when no custom URL is set"
;; In test environment, node-test? is true so get-custom-sync-server-url
;; always returns nil, meaning we always get the default
(is (string? (config/db-sync-ws-url)))
(is (= config/default-db-sync-ws-url (config/db-sync-ws-url))))
(testing "db-sync-http-base returns default when no custom URL is set"
(is (string? (config/db-sync-http-base)))
(is (= config/default-db-sync-http-base (config/db-sync-http-base)))))
(deftest valid-sync-server-url?-test
(testing "accepts http and https URLs"
(is (config/valid-sync-server-url? "https://my-server.example.com"))
(is (config/valid-sync-server-url? "http://localhost:8787")))
(testing "rejects non-URL strings"
(is (not (config/valid-sync-server-url? "not a url")))
(is (not (config/valid-sync-server-url? "")))
(is (not (config/valid-sync-server-url? nil)))))

View File

@@ -86,6 +86,20 @@
(is (= 1 (:db/id (db/entity test-db 1))))
(is (= 1 (:db/id (db/entity (conn/get-db test-db) 1)))))
(deftest get-latest-journals-should-exclude-recycled-journal-pages
(load-test-files
[{:page {:build/journal 20240101}}
{:page {:build/journal 20240102}}
{:page {:build/journal 20240103}}])
(let [journal-2024-01-02-id (ffirst (d/q '[:find ?e
:where
[?e :block/journal-day 20240102]]
(conn/get-db test-db)))]
(db/transact! test-db [{:db/id journal-2024-01-02-id
:logseq.property/deleted-at 1704196800000}]))
(is (= [20240103 20240101]
(mapv :block/journal-day (model/get-latest-journals test-db 10)))))
(deftest get-block-by-page-name-and-block-route-name
(load-test-files
[{:page {:block/title "foo"}

View File

@@ -0,0 +1,46 @@
(ns frontend.db.property-values-test
(:require [cljs.test :refer [deftest is use-fixtures]]
[datascript.core :as d]
[frontend.db :as db]
[frontend.test.helper :as test-helper]
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.common.view :as db-view]))
(def repo test-helper/test-db)
(defn start-and-destroy-db
[f]
(test-helper/start-and-destroy-db f))
(use-fixtures :each start-and-destroy-db)
(deftest get-property-values-filters-recycled-ref-values-test
(let [property-ident :block/tags
active-title "Active ref value"
recycled-title "Recycled ref value"]
(d/transact! (db/get-db repo false)
[[:db/add -2 :block/title active-title]
[:db/add -3 :block/title recycled-title]
[:db/add -3 :logseq.property/deleted-at 1]
[:db/add -10 property-ident -2]
[:db/add -11 property-ident -3]])
(let [result (db-view/get-property-values @(db/get-db repo false) property-ident {})]
(is (contains? (set (map :label result)) active-title))
(is (not (contains? (set (map :label result)) recycled-title))))))
(deftest property-closed-values-hide-recycled-values-test
(d/transact! (db/get-db repo false)
[{:db/id -1 :db/ident :user.property/closed-values-visibility}
{:db/id -2
:block/title "Visible closed value"
:block/order "a"
:block/closed-value-property -1}
{:db/id -3
:block/title "Recycled closed value"
:block/order "b"
:block/closed-value-property -1
:logseq.property/deleted-at 1}])
(let [db @(db/get-db repo false)
property (d/entity db :user.property/closed-values-visibility)
values (entity-plus/lookup-kv-then-entity property :property/closed-values)]
(is (= ["Visible closed value"] (map :block/title values)))))

View File

@@ -0,0 +1,36 @@
(ns frontend.handler.common.page-test
(:require [clojure.test :refer [async is use-fixtures]]
[datascript.core :as d]
[frontend.db :as db]
[frontend.handler.common.page :as page-common-handler]
[frontend.test.helper :as test-helper :include-macros true :refer [deftest-async]]
[logseq.db :as ldb]
[logseq.db.test.helper :as db-test]
[logseq.outliner.page :as outliner-page]
[promesa.core :as p]))
(use-fixtures :each
{:before (fn []
(async done
(test-helper/start-test-db!)
(done)))
:after test-helper/destroy-test-db!})
(deftest-async create-page-restores-recycled-page
(test-helper/load-test-files [{:page {:block/title "foo"}
:blocks [{:block/title "child block"}]}])
(p/let [conn (db/get-db test-helper/test-db false)
page (db-test/find-page-by-title @conn "foo")
page-uuid (:block/uuid page)
_ (outliner-page/delete! conn page-uuid {})
recycled-page (d/entity @conn [:block/uuid page-uuid])
_ (is (ldb/recycled? recycled-page)
"Page should be recycled after deletion")
restored-page (page-common-handler/<create! "foo" {:redirect? false})]
(is (= (:db/id restored-page) (:db/id page))
"create! should return the restored page")
(let [page' (d/entity @conn [:block/uuid page-uuid])]
(is (not (ldb/recycled? page'))
"Page should no longer be recycled after re-creation")
(is (= "foo" (get-in (db-test/find-block-by-content @conn "child block") [:block/page :block/title]))
"Restored page still has its block(s)"))))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -203,23 +203,28 @@
(p/resolved {:error error}))))
(defn- with-fake-create-or-open-db
[repo conn f]
(let [thread-apis-prev @thread-api/*thread-apis]
(vreset! thread-api/*thread-apis
(assoc thread-apis-prev
:thread-api/create-or-open-db
(fn [_repo _opts]
(swap! worker-state/*datascript-conns assoc repo conn)
(p/resolved nil))
:thread-api/db-sync-close-db
(fn [_repo] nil)
:thread-api/db-sync-invalidate-search-db
(fn [_repo] (p/resolved nil))
:thread-api/db-sync-rehydrate-large-titles
(fn [_repo _graph-id] (p/resolved nil))))
(-> (f)
(p/finally (fn []
(vreset! thread-api/*thread-apis thread-apis-prev))))))
([repo conn f]
(with-fake-create-or-open-db repo conn {} f))
([repo conn
{:keys [create-or-open-db-f close-db-f invalidate-search-db-f unlink-db-f]}
f]
(let [thread-apis-prev @thread-api/*thread-apis
create-or-open-db-f (or create-or-open-db-f
(fn [_repo _opts]
(swap! worker-state/*datascript-conns assoc repo conn)
(p/resolved nil)))
close-db-f (or close-db-f (fn [_repo] nil))
invalidate-search-db-f (or invalidate-search-db-f (fn [_repo] (p/resolved nil)))
unlink-db-f (or unlink-db-f (fn [_repo] nil))]
(vreset! thread-api/*thread-apis
(assoc thread-apis-prev
:thread-api/create-or-open-db create-or-open-db-f
:thread-api/db-sync-close-db close-db-f
:thread-api/db-sync-invalidate-search-db invalidate-search-db-f
:thread-api/unsafe-unlink-db unlink-db-f))
(-> (f)
(p/finally (fn []
(vreset! thread-api/*thread-apis thread-apis-prev)))))))
(deftest db-sync-import-prepare-replaces-active-import-state-test
(async done
@@ -243,6 +248,48 @@
(is false (str error))
(done)))))))))))
(deftest db-sync-import-prepare-reset-unlinks-db-before-reopen-test
(async done
(restoring-worker-state
(fn []
(let [prepare (@thread-api/*thread-apis :thread-api/db-sync-import-prepare)
conn (d/create-conn db-schema/schema)
calls (atom [])]
(with-fake-create-or-open-db
test-repo conn
{:close-db-f (fn [repo]
(swap! calls conj [:close repo])
nil)
:unlink-db-f (fn [repo]
(swap! calls conj [:unlink repo])
nil)
:invalidate-search-db-f (fn [repo]
(swap! calls conj [:invalidate-search repo])
(p/resolved nil))
:create-or-open-db-f (fn [repo opts]
(swap! calls conj [:create-or-open repo opts])
(swap! worker-state/*datascript-conns assoc repo conn)
(p/resolved nil))}
(fn []
(-> (prepare test-repo true "graph-1" false)
(p/then (fn [_]
(let [ops (mapv first @calls)
idx (fn [op]
(first (keep-indexed (fn [i v]
(when (= op v) i))
ops)))]
(is (some? (idx :close)))
(is (some? (idx :unlink)))
(is (some? (idx :invalidate-search)))
(is (some? (idx :create-or-open)))
(is (< (idx :close) (idx :unlink)))
(is (< (idx :unlink) (idx :invalidate-search)))
(is (< (idx :invalidate-search) (idx :create-or-open))))
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))))))
(deftest db-sync-import-finalize-rejects-stale-import-id-test
(async done
(restoring-worker-state
@@ -336,6 +383,23 @@
(is (< ident-idx data-idx))
(is (< cardinality-idx data-idx)))))
(deftest import-datoms-batch-transacts-all-db-schema-before-data-test
(async done
(let [conn (d/create-conn db-schema/schema)
attr-eid 8001
datoms [{:e attr-eid :a :db/ident :v :user.test/indexed}
{:e 100 :a :user.test/indexed :v "hello"}
{:e attr-eid :a :db/valueType :v :db.type/string}
{:e attr-eid :a :db/cardinality :v :db.cardinality/one}
{:e attr-eid :a :db/index :v true}]]
(-> (#'sync-download/import-datoms-batch! conn nil false datoms)
(p/then (fn [_]
(is (= 1 (count (d/datoms @conn :avet :user.test/indexed "hello"))))
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))
(deftest thread-api-validate-db-passes-sync-diagnostics-test
(restoring-worker-state
(fn []
@@ -386,3 +450,31 @@
(finally
(reset! db-sync/*repo->latest-remote-tx latest-tx-prev)
(reset! db-sync/*repo->latest-remote-checksum latest-checksum-prev)))))))
(deftest thread-api-export-client-ops-db-checkpoints-and-exports-client-ops-file-test
(async done
(restoring-worker-state
(fn []
(let [export-client-ops-db (@thread-api/*thread-apis :thread-api/export-client-ops-db)
sql-calls (atom [])
export-calls (atom [])
expected-data (js/Uint8Array. #js [1 2 3])
expected-buffer (.-buffer expected-data)
fake-pool #js {:exportFile (fn [path]
(swap! export-calls conj path)
expected-buffer)}]
(reset! worker-state/*opfs-pools {test-repo fake-pool})
(with-redefs [worker-state/get-sqlite-conn (fn [_repo which-db]
(when (= :client-ops which-db)
#js {:exec (fn [sql]
(swap! sql-calls conj sql))}))]
(-> (export-client-ops-db test-repo)
(p/then (fn [result]
(is (= ["PRAGMA wal_checkpoint(2)"] @sql-calls))
(is (= ["client-ops/db.sqlite"] @export-calls))
(is (instance? js/Uint8Array result))
(is (= [1 2 3] (vec result)))
(done)))
(p/catch (fn [error]
(is false (str error))
(done))))))))))

View File

@@ -3,6 +3,7 @@
[datascript.core :as d]
[frontend.worker.pipeline :as worker-pipeline]
[logseq.db :as ldb]
[logseq.db.common.order :as db-order]
[logseq.db.test.helper :as db-test]))
(deftest test-built-in-page-updates-that-should-be-reverted
@@ -143,3 +144,69 @@
:block/title "page1-renamed"}])]
(is (= "page1-renamed"
(:block/title (d/entity (:db-after result) (:db/id page1)))))))))
(deftest built-in-tag-must-not-convert-page-child-block-to-class-test
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks [{:page {:block/title "page1"}}]})
page1 (ldb/get-page @conn "page1")
now (js/Date.now)
bad-block-uuid (random-uuid)
new-tag-uuid (random-uuid)]
(ldb/register-transact-pipeline-fn! worker-pipeline/transact-pipeline)
(testing "page-child block with built-in #Tag stays a block"
(ldb/transact! conn [{:block/uuid bad-block-uuid
:block/title "charlie"
:block/created-at now
:block/updated-at now
:block/page (:db/id page1)
:block/parent (:db/id page1)
:block/order (db-order/gen-key)
:block/tags [:logseq.class/Tag]}])
(let [block (d/entity @conn [:block/uuid bad-block-uuid])]
(is (some? block))
(is (nil? (:db/ident block)))
(is (nil? (:logseq.property.class/extends block)))
(is (not (ldb/class? block)))
(is (= (:db/id page1) (:db/id (:block/parent block))))
(is (empty? (:block/tags block)))))
(testing "standalone candidate is still converted to a class page"
(ldb/transact! conn [{:block/uuid new-tag-uuid
:block/name "standalone-tag"
:block/title "standalone-tag"
:block/created-at now
:block/updated-at now
:block/tags [:logseq.class/Tag]}])
(let [tag-page (d/entity @conn [:block/uuid new-tag-uuid])]
(is (ldb/class? tag-page))
(is (keyword? (:db/ident tag-page)))
(is (= "user.class" (namespace (:db/ident tag-page))))
(is (= [:logseq.class/Root]
(map :db/ident (:logseq.property.class/extends tag-page))))))
;; return global fn back to previous behavior
(ldb/register-transact-pipeline-fn! identity)))
(deftest tag-template-insertion-resolves-dynamic-variable-test
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "Target Page"}
:blocks [{:block/title "target block"}]}
{:page {:block/title "Templates"}
:blocks [{:block/title "tag template root"
:build/children [{:block/title "auto <% current page %>"}]}]}]
:classes {:DiaryEntry {}}})
target-block (db-test/find-block-by-content @conn "target block")
template-root (db-test/find-block-by-content @conn "tag template root")
diary-entry (ldb/get-page @conn "DiaryEntry")]
(ldb/transact! conn [[:db/add (:db/id template-root)
:logseq.property/template-applied-to
(:db/id diary-entry)]])
(ldb/register-transact-pipeline-fn! worker-pipeline/transact-pipeline)
(try
(ldb/transact! conn [[:db/add (:db/id target-block) :block/tags (:db/id diary-entry)]])
(is (some? (db-test/find-block-by-content @conn "auto [[Target Page]]")))
(finally
;; return global fn back to previous behavior
(ldb/register-transact-pipeline-fn! identity)))))

View File

@@ -19,3 +19,14 @@
:logseq.property.reaction/target target-id}])
affected (worker-react/get-affected-queries-keys tx-report)]
(is (some #{[:frontend.worker.react/block-reactions target-id]} affected)))))
(deftest affected-keys-journals-when-journal-recycled
(testing "recycling a journal page should refresh journals query key"
(let [conn (db-test/create-conn-with-blocks
[{:page {:build/journal 20240101}}
{:page {:build/journal 20240102}}])
journal (db-test/find-journal-by-journal-day @conn 20240102)
tx-report (d/transact! conn [{:db/id (:db/id journal)
:logseq.property/deleted-at 1704196800000}])
affected (worker-react/get-affected-queries-keys tx-report)]
(is (some #{[:frontend.worker.react/journals]} affected)))))

View File

@@ -224,3 +224,38 @@
(is (some? ctor))
(is (= 1 (count result)))
(is (= "alpha beta" (get-in result [0 :item :title])))))))
(deftest upsert-blocks-batches-rows-into-single-sql-statement
(let [calls (atom [])
tx #js {:exec (fn [opts]
(swap! calls conj {:sql (aget opts "sql")
:bind (js->clj (aget opts "bind"))}))}
db #js {:transaction (fn [f] (f tx))}
blocks (clj->js [{:id "67e55044-10b1-426f-9247-bb680e5fe0c8"
:title "alpha"
:page "67e55044-10b1-426f-9247-bb680e5fe0c8"}
{:id "8f14e45f-ea6e-4be8-b53f-bf0f2ca8a5db"
:title "beta"
:page "8f14e45f-ea6e-4be8-b53f-bf0f2ca8a5db"}
{:id "9d5ed678-fe57-4bcf-bf4d-6f2fd5f8995d"
:title "gamma"
:page "9d5ed678-fe57-4bcf-bf4d-6f2fd5f8995d"}])]
(search/upsert-blocks! db blocks)
(is (= 1 (count @calls)))
(is (= "INSERT INTO blocks (id, title, page) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?) ON CONFLICT (id) DO UPDATE SET (title, page) = (excluded.title, excluded.page)"
(:sql (first @calls))))
(is (= ["67e55044-10b1-426f-9247-bb680e5fe0c8" "alpha" "67e55044-10b1-426f-9247-bb680e5fe0c8"
"8f14e45f-ea6e-4be8-b53f-bf0f2ca8a5db" "beta" "8f14e45f-ea6e-4be8-b53f-bf0f2ca8a5db"
"9d5ed678-fe57-4bcf-bf4d-6f2fd5f8995d" "gamma" "9d5ed678-fe57-4bcf-bf4d-6f2fd5f8995d"]
(:bind (first @calls))))))
(deftest upsert-blocks-throws-on-invalid-input
(let [tx #js {:exec (fn [_opts] nil)}
db #js {:transaction (fn [f] (f tx))}
error (try
(search/upsert-blocks! db (clj->js [{:id "not-uuid" :title "alpha" :page "not-uuid"}]))
nil
(catch :default e e))]
(is (some? error))
(is (re-find #"Search upsert-blocks wrong data"
(or (ex-message error) (str error))))))

View File

@@ -1,49 +1,115 @@
(ns frontend.worker.sync.client-op-test
(:require [cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[frontend.worker.state :as worker-state]
[frontend.worker.sync.client-op :as client-op]))
(deftest update-graph-uuid-replaces-existing-value-test
(let [repo "repo-1"
conn (d/create-conn client-op/schema-in-db)
(defn- new-memory-db
[]
(let [Database (js/require "better-sqlite3")]
(new Database ":memory:")))
(defn- with-client-ops-db
[repo f]
(let [db (new-memory-db)
prev-client-ops-conns @worker-state/*client-ops-conns]
(reset! worker-state/*client-ops-conns {repo conn})
(reset! worker-state/*client-ops-conns {repo db})
(try
(client-op/update-graph-uuid repo "graph-1")
(client-op/update-graph-uuid repo "graph-2")
(let [graph-uuid-datoms (vec (d/datoms @conn :avet :graph-uuid))]
(is (= 1 (count graph-uuid-datoms)))
(is (= #{"graph-2"} (set (map :v graph-uuid-datoms)))))
(f db)
(finally
(.close db)
(reset! worker-state/*client-ops-conns prev-client-ops-conns)))))
(defn- sqlite-count
[^js db sql & args]
(let [^js stmt (.prepare db sql)
^js row (if (seq args)
(.apply (.-get stmt) stmt (to-array args))
(.get stmt))]
(if row
(or (aget row "c")
(aget row "count"))
0)))
(deftest sqlite-sync-meta-roundtrip-test
(let [repo "repo-1"]
(with-client-ops-db
repo
(fn [_db]
(client-op/update-graph-uuid repo "graph-1")
(client-op/update-local-tx repo 9)
(client-op/update-local-checksum repo "checksum-1")
(client-op/update-graph-uuid repo "graph-2")
(client-op/update-local-tx repo 12)
(client-op/update-local-checksum repo "checksum-2")
(is (= "graph-2" (client-op/get-graph-uuid repo)))
(is (= 12 (client-op/get-local-tx repo)))
(is (= "checksum-2" (client-op/get-local-checksum repo)))))))
(deftest sqlite-asset-ops-coalescing-test
(let [repo "repo-asset"
asset-uuid (random-uuid)]
(with-client-ops-db
repo
(fn [_db]
(client-op/add-asset-ops repo [[:update-asset 10 {:block-uuid asset-uuid}]])
(is (= 1 (client-op/get-unpushed-asset-ops-count repo)))
(is (= [:update-asset 10 {:block-uuid asset-uuid}]
(:update-asset (first (client-op/get-all-asset-ops repo)))))
;; older remove should be ignored because a newer update already exists
(client-op/add-asset-ops repo [[:remove-asset 9 {:block-uuid asset-uuid}]])
(is (= [:update-asset 10 {:block-uuid asset-uuid}]
(:update-asset (first (client-op/get-all-asset-ops repo)))))
;; newer remove should replace update
(client-op/add-asset-ops repo [[:remove-asset 11 {:block-uuid asset-uuid}]])
(is (= [:remove-asset 11 {:block-uuid asset-uuid}]
(:remove-asset (first (client-op/get-all-asset-ops repo)))))
(client-op/remove-asset-op repo asset-uuid)
(is (= 0 (client-op/get-unpushed-asset-ops-count repo)))))))
(deftest cleanup-finished-history-ops-removes-only-unreferenced-finished-txs-test
(let [repo "repo-cleanup"
conn (d/create-conn client-op/schema-in-db)
prev-client-ops-conns @worker-state/*client-ops-conns
keep-tx-id (random-uuid)
remove-tx-id (random-uuid)
pending-tx-id (random-uuid)]
(reset! worker-state/*client-ops-conns {repo conn})
(try
(d/transact! conn
[{:db-sync/tx-id keep-tx-id
:db-sync/pending? false}
{:db-sync/tx-id remove-tx-id
:db-sync/pending? false}
{:db-sync/tx-id pending-tx-id
:db-sync/pending? true}
{:db-ident :metadata/local
:local-tx 99}])
(with-client-ops-db
repo
(fn [db]
(client-op/update-local-tx repo 99)
(client-op/upsert-local-tx-entry!
repo
{:tx-id keep-tx-id
:created-at 1
:pending? false
:failed? false
:normalized-tx-data []
:reversed-tx-data []})
(client-op/upsert-local-tx-entry!
repo
{:tx-id remove-tx-id
:created-at 2
:pending? false
:failed? false
:normalized-tx-data []
:reversed-tx-data []})
(client-op/upsert-local-tx-entry!
repo
{:tx-id pending-tx-id
:created-at 3
:pending? true
:failed? false
:normalized-tx-data []
:reversed-tx-data []})
(is (= 1 (client-op/cleanup-finished-history-ops! repo #{keep-tx-id})))
(is (some? (d/entity @conn [:db-sync/tx-id keep-tx-id])))
(is (nil? (d/entity @conn [:db-sync/tx-id remove-tx-id])))
(is (some? (d/entity @conn [:db-sync/tx-id pending-tx-id])))
(is (= 99 (:local-tx (d/entity @conn [:db-ident :metadata/local]))))
(finally
(reset! worker-state/*client-ops-conns prev-client-ops-conns)))))
(is (= 1 (client-op/cleanup-finished-history-ops! repo #{keep-tx-id})))
(is (= 1 (sqlite-count db "select count(*) as c from client_ops where tx_id = ?" (str keep-tx-id))))
(is (= 0 (sqlite-count db "select count(*) as c from client_ops where tx_id = ?" (str remove-tx-id))))
(is (= 1 (sqlite-count db "select count(*) as c from client_ops where tx_id = ?" (str pending-tx-id))))
(is (= 99 (client-op/get-local-tx repo)))))))
(deftest cleanup-finished-history-ops-no-conn-is-noop-test
(let [repo "repo-no-conn"

View File

@@ -595,3 +595,16 @@
(is (= "decrypt-aes-key" (ex-message e)))
(is (zero? @clear-user-rsa-cache-calls*))
(done)))))))
(deftest decrypt-text-value-legacy-plaintext-test
(async done
(-> (p/let [aes-key (crypt/<generate-aes-key)
plaintext "$$$favorites"
encrypted (crypt/<encrypt-uint8array aes-key (.encode (js/TextEncoder.) plaintext))
encrypted-str (ldb/write-transit-str encrypted)
decrypted (sync-crypt/<decrypt-text-value aes-key encrypted-str)]
(is (= plaintext decrypted))
(done))
(p/catch (fn [e]
(is false (str e))
(done))))))

View File

@@ -0,0 +1,44 @@
(ns frontend.worker.sync.download-test
(:require [cljs.test :refer [async deftest is]]
[frontend.worker.sync.download :as sync-download]
[logseq.db-sync.snapshot :as snapshot]
[promesa.core :as p]))
(defn- frame-bytes
[^js data]
(let [len (.-byteLength data)
out (js/Uint8Array. (+ 4 len))
view (js/DataView. (.-buffer out))]
(.setUint32 view 0 len false)
(.set out data 4)
out))
(defn- stream-from-payload
[^js payload]
(js/ReadableStream.
#js {:start (fn [controller]
(.enqueue controller payload)
(.close controller))}))
(deftest stream-snapshot-row-batches-ignores-stale-gzip-header-test
(async done
(let [rows [[1 "row-1" nil]
[2 "row-2" nil]]
payload (frame-bytes (snapshot/encode-rows rows))
resp (js/Response.
(stream-from-payload payload)
#js {:status 200
:headers #js {"content-encoding" "gzip"}})
batches* (atom [])]
(-> (#'sync-download/<stream-snapshot-row-batches!
resp
1000
(fn [batch]
(swap! batches* conj batch)
(p/resolved true)))
(p/then (fn [_]
(is (= [rows] @batches*))
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))

View File

@@ -14,6 +14,24 @@
(def ^:private test-repo "test-worker-undo-redo")
(defn- new-client-ops-db
[]
(let [Database (js/require "better-sqlite3")
db (new Database ":memory:")]
(client-op/ensure-sqlite-schema! db)
db))
(defn- delete-client-op-tx-row!
[^js db tx-id]
(let [^js stmt (.prepare db "delete from client_ops where kind = 'tx' and tx_id = ?")]
(.run stmt (str tx-id))))
(defn- client-op-tx-row-exists?
[^js db tx-id]
(let [^js stmt (.prepare db "select 1 as ok from client_ops where kind = 'tx' and tx_id = ? limit 1")
row (.get stmt (str tx-id))]
(some? row)))
(defn- local-tx-meta
[m]
(assoc m
@@ -31,7 +49,7 @@
:blocks [{:block/title "task"}
{:block/title "parent"
:build/children [{:block/title "child"}]}]}]})
client-ops-conn (d/create-conn client-op/schema-in-db)]
client-ops-conn (new-client-ops-db)]
(reset! worker-state/*datascript-conns {test-repo conn})
(reset! worker-state/*client-ops-conns {test-repo client-ops-conn})
(reset! worker-undo-redo/*apply-history-action! sync-apply/apply-history-action!)
@@ -44,6 +62,7 @@
(finally
(d/unlisten! conn ::gen-undo-ops)
(worker-undo-redo/clear-history! test-repo)
(.close client-ops-conn)
(reset! worker-undo-redo/*apply-history-action! apply-history-action-prev)
(reset! worker-state/*datascript-conns datascript-prev)
(reset! worker-state/*client-ops-conns client-ops-prev)))))
@@ -89,6 +108,121 @@
:outliner-ops [[:save-block [{:block/uuid block-uuid
:block/title title} {}]]]}))))
(defn- block-id->uuid
[db block-id]
(cond
(uuid? block-id)
block-id
(and (vector? block-id) (= :block/uuid (first block-id)))
(second block-id)
(number? block-id)
(or (some-> (d/entity db block-id) :block/uuid)
block-id)
:else
block-id))
(defn- property-id->ident
[db property-id]
(cond
(qualified-keyword? property-id)
property-id
(number? property-id)
(or (some-> (d/entity db property-id) :db/ident)
property-id)
:else
property-id))
(defn- normalize-op-block-ids
[db [op args :as op-entry]]
(let [id (fn [v] (block-id->uuid db v))
property-id (fn [v] (property-id->ident db v))
ids (fn [vs] (mapv id vs))]
(case op
:save-block
(let [[block opts] args
block' (cond-> block
(and (map? block)
(uuid? (:db/id block)))
((fn [m]
(cond-> (dissoc m :db/id)
(nil? (:block/uuid m))
(assoc :block/uuid (:db/id m)))))
(and (map? block)
(nil? (:block/uuid block))
(number? (:db/id block)))
(assoc :block/uuid (id (:db/id block))))]
[op [block' opts]])
:insert-blocks
[op [(first args) (id (second args)) (nth args 2)]]
:apply-template
[op [(id (first args)) (id (second args)) (nth args 2)]]
:delete-blocks
[op [(ids (first args)) (second args)]]
:move-blocks
[op [(ids (first args)) (id (second args)) (nth args 2)]]
:move-blocks-up-down
[op [(ids (first args)) (second args)]]
:indent-outdent-blocks
[op [(ids (first args)) (second args) (nth args 2)]]
:set-block-property
[op [(id (first args)) (property-id (second args)) (nth args 2)]]
:remove-block-property
[op [(id (first args)) (property-id (second args))]]
:delete-property-value
[op [(id (first args)) (property-id (second args)) (nth args 2)]]
:create-property-text-block
[op [(some-> (first args) id) (property-id (second args)) (nth args 2) (nth args 3)]]
:batch-set-property
[op [(ids (first args)) (property-id (second args)) (nth args 2) (nth args 3)]]
:batch-remove-property
[op [(ids (first args)) (property-id (second args))]]
:batch-delete-property-value
[op [(ids (first args)) (property-id (second args)) (nth args 2)]]
:class-add-property
[op [(id (first args)) (property-id (second args))]]
:class-remove-property
[op [(id (first args)) (property-id (second args))]]
:upsert-property
[op [(some-> (first args) property-id) (second args) (nth args 2)]]
:upsert-closed-value
[op [(property-id (first args)) (second args)]]
:delete-closed-value
[op [(property-id (first args)) (id (second args))]]
:add-existing-values-to-closed-values
[op [(property-id (first args)) (second args)]]
op-entry)))
(defn- apply-ops!
[conn ops opts]
(outliner-op/apply-ops! conn
(mapv #(normalize-op-block-ids @conn %) ops)
opts))
(deftest undo-redo-selection-editor-info-roundtrip-test
(testing "undo/redo result keeps block selection editor info when no cursor is recorded"
(worker-undo-redo/clear-history! test-repo)
@@ -140,6 +274,43 @@
(second %))
redo-op)))
(defn- move-retract-entity-ops-to-front
[tx-data]
(let [retract-entity-op? (fn [item]
(and (vector? item)
(= 2 (count item))
(= :db/retractEntity (first item))))
retract-ops (filter retract-entity-op? tx-data)
others (remove retract-entity-op? tx-data)]
(vec (concat retract-ops others))))
(defn- poison-history-tx-order!
[tx-id]
(when-let [entry (client-op/get-local-tx-entry test-repo tx-id)]
(client-op/upsert-local-tx-entry!
test-repo
{:tx-id tx-id
:pending? true
:failed? false
:outliner-op (:outliner-op entry)
:undo-redo (:db-sync/undo-redo entry)
:forward-outliner-ops (:forward-outliner-ops entry)
:inverse-outliner-ops (:inverse-outliner-ops entry)
:inferred-outliner-ops? (:inferred-outliner-ops? entry)
:normalized-tx-data (move-retract-entity-ops-to-front (:tx entry))
:reversed-tx-data (move-retract-entity-ops-to-front (:reversed-tx entry))})))
(defn- property-value-titles
[value]
(cond
(nil? value) []
(string? value) [value]
(map? value) [(:block/title value)]
(coll? value) (->> value
(mapcat property-value-titles)
vec)
:else [value]))
(deftest undo-missing-history-action-row-replays-from-inline-ops-test
(testing "undo/redo should replay from inline history ops when pending row is missing"
(worker-undo-redo/clear-history! test-repo)
@@ -168,8 +339,7 @@
:tx-data [(d/datom 1 :block/title "poisoned" 1 true)])]
item))
op)))))
(when-let [tx-ent (d/entity @client-ops-conn [:db-sync/tx-id tx-id-2])]
(ldb/transact! client-ops-conn [[:db/retractEntity (:db/id tx-ent)]]))
(delete-client-op-tx-row! client-ops-conn tx-id-2)
(let [undo-result (worker-undo-redo/undo test-repo)]
(is (not= ::worker-undo-redo/empty-undo-stack undo-result))
(is (= "v1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))
@@ -246,8 +416,7 @@
(assoc (second item) :tx-data [(d/datom 1 :block/title "poisoned" 1 true)])]
item))
op)))))
(when-let [tx-ent (d/entity @client-ops-conn [:db-sync/tx-id tx-id])]
(ldb/transact! client-ops-conn [[:db/retractEntity (:db/id tx-ent)]]))
(delete-client-op-tx-row! client-ops-conn tx-id)
(is (not= ::worker-undo-redo/empty-undo-stack
(worker-undo-redo/undo test-repo)))
(is (seq (get @worker-undo-redo/*redo-ops test-repo))))))
@@ -271,7 +440,7 @@
(let [undo-tx-id (:db-sync/tx-id (latest-undo-history-data))]
(is (uuid? undo-tx-id))
(is (not= source-tx-id undo-tx-id))
(is (some? (d/entity @client-ops-conn [:db-sync/tx-id undo-tx-id])))))))))
(is (client-op-tx-row-exists? client-ops-conn undo-tx-id))))))))
(deftest undo-records-only-local-txs-test
(testing "undo history records only local txs"
@@ -314,7 +483,7 @@
(get-in data [:db-sync/inverse-outliner-ops 0 1 0 :block/uuid])))))))
(deftest undo-history-allows-non-semantic-outliner-op-test
(testing "non-semantic outliner-op with transact placeholder is skipped by ops-only undo history"
(testing "non-semantic outliner-op with transact placeholder is persisted in undo history"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
@@ -322,9 +491,13 @@
[[:db/add [:block/uuid child-uuid] :block/title "restored child"]]
(local-tx-meta
{:client-id "test-client"
:outliner-op :restore-recycled
:outliner-ops [[:transact nil]]}))
(is (empty? (get @worker-undo-redo/*undo-ops test-repo))))))
:outliner-op :restore-recycled}))
(let [undo-op (last (get @worker-undo-redo/*undo-ops test-repo))
data (some #(when (= ::worker-undo-redo/db-transact (first %))
(second %))
undo-op)]
(is (some? data))
(is (nil? (:db-sync/inverse-outliner-ops data)))))))
(deftest undo-history-canonicalizes-insert-block-uuids-test
(testing "worker undo history uses the created block uuid for insert semantic ops"
@@ -360,7 +533,7 @@
(is (= inserted-uuid
(get-in data [:db-sync/forward-outliner-ops 0 1 0 0 :block/uuid])))
(is (= inserted-uuid
(second (first (get-in data [:db-sync/inverse-outliner-ops 0 1 0])))))))))
(get-in data [:db-sync/inverse-outliner-ops 0 1 0 0])))))))
(deftest undo-works-for-local-graph-test
(testing "worker undo/redo works for local changes on local graph"
@@ -375,12 +548,36 @@
(is (map? redo-result))
(is (= "local-1" (:block/title (d/entity @conn [:block/uuid child-uuid]))))))))
(deftest undo-cycle-todo-removes-task-class-test
(testing "undoing first status set should remove task class and status"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
block-uuid (:block/uuid (db-test/find-block-by-content @conn "task"))]
(apply-ops! conn
[[:set-block-property [block-uuid
:logseq.property/status
:logseq.property/status.todo]]]
(local-tx-meta {:client-id "test-client"}))
(let [block-after-set (d/entity @conn [:block/uuid block-uuid])]
(is (= :logseq.property/status.todo
(some-> (:logseq.property/status block-after-set) :db/ident)))
(is (contains? (set (map :db/ident (:block/tags block-after-set)))
:logseq.class/Task)))
(is (map? (worker-undo-redo/undo test-repo)))
(let [block-after-undo (d/entity @conn [:block/uuid block-uuid])]
(is (not (contains? (d/pull @conn [:logseq.property/status] [:block/uuid block-uuid])
:logseq.property/status)))
(is (not (contains? (set (map :db/ident (:block/tags block-after-undo)))
:logseq.class/Task)))))))
(deftest undo-delete-page-restores-page-out-of-recycle-test
(testing "undoing delete-page should restore page and clear recycle marker"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [page-uuid]} (seed-page-parent-child!)]
(outliner-op/apply-ops! conn
(apply-ops! conn
[[:delete-page [page-uuid {}]]]
(local-tx-meta {:client-id "test-client"}))
(let [deleted-page (d/entity @conn [:block/uuid page-uuid])]
@@ -401,14 +598,14 @@
(testing "undoing delete-page restores hard-retracted class/property pages and today page blocks"
(let [conn (worker-state/get-datascript-conn test-repo)
class-title "undo class page movie"
[_ class-uuid] (outliner-op/apply-ops! conn
[_ class-uuid] (apply-ops! conn
[[:create-page [class-title
{:class? true
:redirect? false
:split-namespace? true
:tags ()}]]]
(local-tx-meta {:client-id "test-client"}))
_ (outliner-op/apply-ops! conn
_ (apply-ops! conn
[[:upsert-property [:user.property/undo-rating
{:logseq.property/type :number}
{:property-name "undo-rating"}]]]
@@ -420,7 +617,7 @@
today-day
(:logseq.property.journal/title-format
(d/entity @conn :logseq.class/Journal)))
[_ today-page-uuid] (outliner-op/apply-ops! conn
[_ today-page-uuid] (apply-ops! conn
[[:create-page [today-title
{:today-journal? true
:redirect? false
@@ -429,7 +626,7 @@
(local-tx-meta {:client-id "test-client"}))
today-page-id (:db/id (d/entity @conn [:block/uuid today-page-uuid]))
today-child-uuid (random-uuid)
_ (outliner-op/apply-ops! conn
_ (apply-ops! conn
[[:insert-blocks [[{:block/uuid today-child-uuid
:block/title "today undo child"}]
today-page-id
@@ -440,7 +637,7 @@
property-ident-before (:db/ident (d/entity @conn [:block/uuid property-uuid]))]
(worker-undo-redo/clear-history! test-repo)
(outliner-op/apply-ops! conn
(apply-ops! conn
[[:delete-page [class-uuid {}]]]
(local-tx-meta {:client-id "test-client"}))
(is (nil? (d/entity @conn [:block/uuid class-uuid])))
@@ -449,7 +646,7 @@
(:db/ident (d/entity @conn [:block/uuid class-uuid]))))
(worker-undo-redo/clear-history! test-repo)
(outliner-op/apply-ops! conn
(apply-ops! conn
[[:delete-page [property-uuid {}]]]
(local-tx-meta {:client-id "test-client"}))
(is (nil? (d/entity @conn :user.property/undo-rating)))
@@ -458,7 +655,7 @@
(:db/ident (d/entity @conn [:block/uuid property-uuid]))))
(worker-undo-redo/clear-history! test-repo)
(outliner-op/apply-ops! conn
(apply-ops! conn
[[:delete-page [today-page-uuid {}]]]
(local-tx-meta {:client-id "test-client"}))
(is (some? (d/entity @conn [:block/uuid today-page-uuid])))
@@ -471,7 +668,7 @@
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
page-title "redo create page alpha"]
(outliner-op/apply-ops! conn
(apply-ops! conn
[[:create-page [page-title {:redirect? false
:split-namespace? true
:tags ()}]]]
@@ -500,7 +697,7 @@
template-a-uuid (random-uuid)
template-b-uuid (random-uuid)
empty-target-uuid (random-uuid)]
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [[{:block/uuid template-root-uuid
:block/title "template 1"
@@ -515,7 +712,7 @@
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [[{:block/uuid empty-target-uuid
:block/title ""}]
@@ -531,7 +728,7 @@
blocks-to-insert (cons (assoc (first template-blocks)
:logseq.property/used-template (:db/id template-root))
(rest template-blocks))]
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [blocks-to-insert
(:db/id empty-target)
@@ -566,7 +763,7 @@
template-a-uuid (random-uuid)
template-b-uuid (random-uuid)
empty-target-uuid (random-uuid)]
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [[{:block/uuid template-root-uuid
:block/title "template 1"
@@ -581,7 +778,7 @@
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [[{:block/uuid empty-target-uuid
:block/title ""}]
@@ -597,7 +794,7 @@
blocks-to-insert (cons (assoc (first template-blocks)
:logseq.property/used-template (:db/id template-root))
(rest template-blocks))]
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [blocks-to-insert
(:db/id empty-target)
@@ -636,7 +833,7 @@
empty-target-uuid (random-uuid)
inserted-root-uuid (random-uuid)
inserted-child-uuid (random-uuid)]
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [[{:block/uuid empty-target-uuid
:block/title ""}]
@@ -645,7 +842,7 @@
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(let [empty-target (d/entity @conn [:block/uuid empty-target-uuid])]
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [[{:block/uuid inserted-root-uuid
:block/title "insert root"}
@@ -688,7 +885,7 @@
template-a-uuid (random-uuid)
template-b-uuid (random-uuid)
empty-target-uuid (random-uuid)]
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [[{:block/uuid template-root-uuid
:block/title "template 1"
@@ -703,7 +900,7 @@
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [[{:block/uuid empty-target-uuid
:block/title ""}]
@@ -719,7 +916,7 @@
blocks-to-insert (cons (assoc (first template-blocks)
:logseq.property/used-template (:db/id template-root))
(rest template-blocks))]
(outliner-op/apply-ops!
(apply-ops!
conn
[[:apply-template [(:db/id template-root)
(:db/id empty-target)
@@ -757,7 +954,7 @@
template-a-uuid (random-uuid)
template-b-uuid (random-uuid)
empty-target-uuid (random-uuid)]
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [[{:block/uuid template-root-uuid
:block/title "template 1"
@@ -772,7 +969,7 @@
{:sibling? false
:keep-uuid? true}]]]
(local-tx-meta {:client-id "test-client"}))
(outliner-op/apply-ops!
(apply-ops!
conn
[[:insert-blocks [[{:block/uuid empty-target-uuid
:block/title ""}]
@@ -798,7 +995,7 @@
[?b :block/title "a"]]
@conn
template-root-uuid))]
(outliner-op/apply-ops!
(apply-ops!
conn
[[:apply-template [(:db/id template-root)
(:db/id empty-target)
@@ -831,7 +1028,7 @@
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(outliner-op/apply-ops! conn
(apply-ops! conn
[[:save-block [{:block/uuid child-uuid
:block/title "saved via apply-ops"} {}]]]
(local-tx-meta {:client-id "test-client"}))
@@ -898,7 +1095,7 @@
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(doseq [title ["foo" "foo bar"]]
(outliner-op/apply-ops! conn
(apply-ops! conn
[[:save-block [{:block/uuid child-uuid
:block/title title} {}]]]
(local-tx-meta {:client-id "test-client"})))
@@ -908,6 +1105,40 @@
(worker-undo-redo/redo test-repo)
(is (= "foo bar" (:block/title (d/entity @conn [:block/uuid child-uuid])))))))
(deftest repeated-set-block-property-text-value-undo-redo-test
(testing "set-block-property text value survives repeated undo/redo for one and many cardinalities"
(worker-undo-redo/clear-history! test-repo)
(let [conn (worker-state/get-datascript-conn test-repo)
{:keys [child-uuid]} (seed-page-parent-child!)]
(doseq [[suffix cardinality] [[:one :one] [:many :many]]]
(let [property-id (keyword (str "user.property/p1-undo-redo-" (name suffix)))]
(apply-ops! conn
[[:upsert-property [property-id
{:logseq.property/type :default
:db/cardinality cardinality}
{}]]]
(local-tx-meta {:client-id "test-client"}))
(worker-undo-redo/clear-history! test-repo)
(apply-ops! conn
[[:set-block-property [child-uuid
property-id
"value-1"]]]
(local-tx-meta {:client-id "test-client"}))
(let [history (latest-undo-history-data)]
(is (empty? (:db-sync/forward-outliner-ops history))))
(dotimes [_ 3]
(when-let [undo-tx-id (:db-sync/tx-id (latest-undo-history-data))]
(poison-history-tx-order! undo-tx-id))
(is (map? (worker-undo-redo/undo test-repo)))
(is (empty? (property-value-titles
(get (d/entity @conn [:block/uuid child-uuid]) property-id))))
(when-let [redo-tx-id (:db-sync/tx-id (latest-redo-history-data))]
(poison-history-tx-order! redo-tx-id))
(is (map? (worker-undo-redo/redo test-repo)))
(let [titles (property-value-titles
(get (d/entity @conn [:block/uuid child-uuid]) property-id))]
(is (contains? (set titles) "value-1")))))))))
(deftest save-two-blocks-undo-targets-latest-block-test
(testing "undo after saving two blocks reverts the latest saved block first"
(worker-undo-redo/clear-history! test-repo)