Fase B8+B9: stateful code kernel and live notes bound to external URLs
B8 - executable code blocks with a real kernel: - worker/handler/kernel.cljs: one persistent SCI interpreter session per page, reused across block evaluations so defs/state survive between runs. Deliberately scoped to the same privileges the existing client-side single-shot evaluator already had (no shell/fs access, no call-api). - Run button + result display wired into block.cljs's code-block UI. - New :logseq.property.code/result and /executed-at properties (65.38). B9 - live notes: pages tagged #LiveNote that fetch external content on a refresh schedule and render it via the render-resource pipeline: - worker/handler/live_notes.cljs: per-block credential storage, scheduled refresh (Node-only), thread-apis for manual refresh and auth. - worker/handler/render_resource/live_note.cljs: reads back fetched content, watching [:entity block-uuid] for reactive invalidation. - New :logseq.class/LiveNote + :logseq.property.livenote/* properties (65.39). Bugs found and fixed along the way while actually exercising page creation through page-handler/create! for the first time in a test (previously only pure-fn tests existed for CalDAV/Event/Contact): - logseq-property-namespaces (property.cljs) was missing the citation/event/ contact/caldav/livenote namespaces, causing auto-create-ontology to treat already-built-in properties as new user idents. - outliner/page.cljs's build-page-tx always added the generic #Page tag even when the caller tagged with a built-in class that extends #Page, which malli-schema rejects as conflicting; it also lost the property type when building ref-typed property values for new pages, breaking non-:default types like :number. - entity-util/page? never recognized Event/Contact/LiveNote as pages (only Journal/Whiteboard/Tag/Property/Page had dedicated recognition), so pages tagged with those classes alone weren't treated as pages anywhere in the app; added built-in-page-child? alongside the existing per-class checks. - caldav.cljs's upsert-page! passed a bare keyword as a tag where an entity was required, and two error logs passed raw error objects to glogi that triggered runaway recursion in this test environment; both fixed.
This commit is contained in:
7
deps/db/src/logseq/db/frontend/class.cljs
vendored
7
deps/db/src/logseq/db/frontend/class.cljs
vendored
@@ -134,6 +134,13 @@
|
||||
:schema {:properties [:logseq.property.contact/email :logseq.property.contact/phone
|
||||
:logseq.property.contact/organization]}}
|
||||
|
||||
:logseq.class/LiveNote
|
||||
{:title "Live Note"
|
||||
:properties {:logseq.property.class/extends :logseq.class/Page
|
||||
:logseq.property/icon {:type :tabler-icon :id "refresh"}}
|
||||
:schema {:properties [:logseq.property.livenote/url :logseq.property.livenote/refresh-minutes]
|
||||
:required-properties [:logseq.property.livenote/url]}}
|
||||
|
||||
;; TODO: Add more classes such as :book, :paper, :movie, :music)
|
||||
)))
|
||||
|
||||
|
||||
17
deps/db/src/logseq/db/frontend/entity_util.cljs
vendored
17
deps/db/src/logseq/db/frontend/entity_util.cljs
vendored
@@ -42,12 +42,27 @@
|
||||
[entity]
|
||||
(has-tag? entity :logseq.class/Journal))
|
||||
|
||||
;; Built-in classes that extend :logseq.class/Page (logseq.db.frontend.class/
|
||||
;; page-children-classes) but, like Journal, are recognized as pages by their
|
||||
;; own specific tag alone -- they must not also carry the generic #Page tag
|
||||
;; (malli-schema's block-tags rule forbids combining #Page with another
|
||||
;; built-in page tag). Can't derive this set from class.cljs's
|
||||
;; page-children-classes here: class.cljs requires this namespace, so the
|
||||
;; reverse require would be circular.
|
||||
(def ^:private built-in-page-child-tags
|
||||
#{:logseq.class/Event :logseq.class/Contact :logseq.class/LiveNote})
|
||||
|
||||
(defn built-in-page-child?
|
||||
[entity]
|
||||
(some (fn [tag-ident] (has-tag? entity tag-ident)) built-in-page-child-tags))
|
||||
|
||||
(defn page?
|
||||
[entity]
|
||||
(or (internal-page? entity)
|
||||
(journal? entity)
|
||||
(class? entity)
|
||||
(property? entity)))
|
||||
(property? entity)
|
||||
(built-in-page-child? entity)))
|
||||
|
||||
(defn asset?
|
||||
"Given an entity or map, check if it is an asset block"
|
||||
|
||||
35
deps/db/src/logseq/db/frontend/property.cljs
vendored
35
deps/db/src/logseq/db/frontend/property.cljs
vendored
@@ -151,6 +151,16 @@
|
||||
:hide? true
|
||||
:view-context :block}
|
||||
:queryable? true}
|
||||
:logseq.property.code/result {:title "Code result"
|
||||
:schema {:type :string
|
||||
:public? false
|
||||
:hide? true
|
||||
:view-context :block}}
|
||||
:logseq.property.code/executed-at {:title "Code executed at"
|
||||
:schema {:type :datetime
|
||||
:public? false
|
||||
:hide? true
|
||||
:view-context :block}}
|
||||
:logseq.property/default-value {:title "Default value"
|
||||
:schema {:type :entity
|
||||
:public? false
|
||||
@@ -452,6 +462,27 @@
|
||||
:hide? true
|
||||
:public? false}}
|
||||
|
||||
:logseq.property.livenote/url {:title "Source URL"
|
||||
:schema {:type :url
|
||||
:public? true}}
|
||||
:logseq.property.livenote/refresh-minutes {:title "Refresh every (minutes)"
|
||||
:schema {:type :number
|
||||
:public? true}
|
||||
:properties {:logseq.property/default-value 60}}
|
||||
;; Live note bookkeeping, not user-facing
|
||||
:logseq.property.livenote/content {:title "Live note content"
|
||||
:schema {:type :string
|
||||
:hide? true
|
||||
:public? false}}
|
||||
:logseq.property.livenote/last-fetched-at {:title "Live note last fetched at"
|
||||
:schema {:type :datetime
|
||||
:hide? true
|
||||
:public? false}}
|
||||
:logseq.property.livenote/last-error {:title "Live note last error"
|
||||
:schema {:type :string
|
||||
:hide? true
|
||||
:public? false}}
|
||||
|
||||
:logseq.property/icon {:title "Icon"
|
||||
:schema {:type :map}}
|
||||
:logseq.property/publishing-public? {:title "Publishing Public?"
|
||||
@@ -804,7 +835,9 @@
|
||||
"logseq.property.journal" "logseq.property.class" "logseq.property.view"
|
||||
"logseq.property.user" "logseq.property.history"
|
||||
"logseq.property.reaction" "logseq.property.sync" "logseq.property.publish"
|
||||
"logseq.property.recycle" "logseq.property.comments" "logseq.property.agent"})
|
||||
"logseq.property.recycle" "logseq.property.comments" "logseq.property.agent"
|
||||
"logseq.property.citation" "logseq.property.event" "logseq.property.contact"
|
||||
"logseq.property.caldav" "logseq.property.livenote"})
|
||||
|
||||
(defn logseq-property?
|
||||
"Determines if keyword is a logseq property"
|
||||
|
||||
2
deps/db/src/logseq/db/frontend/schema.cljs
vendored
2
deps/db/src/logseq/db/frontend/schema.cljs
vendored
@@ -30,7 +30,7 @@
|
||||
(map (juxt :major :minor)
|
||||
[(parse-schema-version x) (parse-schema-version y)])))
|
||||
|
||||
(def version (parse-schema-version "65.37"))
|
||||
(def version (parse-schema-version "65.39"))
|
||||
|
||||
(defn major-version
|
||||
"Return a number.
|
||||
|
||||
31
deps/outliner/src/logseq/outliner/page.cljs
vendored
31
deps/outliner/src/logseq/outliner/page.cljs
vendored
@@ -154,10 +154,33 @@
|
||||
(ldb/transact! conn tx-data tx-meta'))
|
||||
true))))))
|
||||
|
||||
(defn- tag-ident
|
||||
[tag]
|
||||
(cond (de/entity? tag) (:db/ident tag)
|
||||
(keyword? tag) tag
|
||||
(map? tag) (:db/ident tag)))
|
||||
|
||||
(defn- build-page-tx [db properties page {:keys [class? tags class-ident-namespace]}]
|
||||
(when (:block/uuid page)
|
||||
(let [type-tag (if class? :logseq.class/Tag :logseq.class/Page)
|
||||
tags' (if (:block/journal-day page) tags (conj tags type-tag))
|
||||
;; A page already tagged with a built-in class that extends #Page
|
||||
;; (Journal, Whiteboard, Event, Contact, Project, Area, ...) must
|
||||
;; not also carry the generic #Page tag -- malli-schema rejects
|
||||
;; that combination as conflicting built-in page tags.
|
||||
already-page-tagged? (and (= type-tag :logseq.class/Page)
|
||||
(some (fn [tag]
|
||||
(let [ident (tag-ident tag)]
|
||||
(and ident
|
||||
(or (= ident :logseq.class/Page)
|
||||
(contains? db-class/page-children-classes ident)))))
|
||||
tags))
|
||||
tags' (if (or (:block/journal-day page) already-page-tagged?) tags (conj tags type-tag))
|
||||
;; page-name->map may have already seeded :block/tags with the
|
||||
;; generic #Page tag -- strip it back out here too, or it survives
|
||||
;; the merge below even though tags' no longer re-adds it.
|
||||
page (cond-> page
|
||||
already-page-tagged?
|
||||
(update :block/tags (fn [existing] (remove #{:logseq.class/Page} existing))))
|
||||
page' (update page :block/tags
|
||||
(fnil into [])
|
||||
(mapv (fn [tag]
|
||||
@@ -181,10 +204,10 @@
|
||||
page'
|
||||
(->> properties
|
||||
(keep (fn [[k v]]
|
||||
;; TODO: Pass in property type in order to support property
|
||||
;; types other than :default
|
||||
(when (db-property/built-in-has-ref-value? k)
|
||||
[k v])))
|
||||
[{:db/ident k
|
||||
:logseq.property/type (get-in db-property/built-in-properties [k :schema :type])}
|
||||
v])))
|
||||
(into {})))]
|
||||
(cond-> (if class?
|
||||
[(merge (db-class/build-new-class db page' {:ident-namespace class-ident-namespace})
|
||||
|
||||
@@ -4714,6 +4714,34 @@
|
||||
(on-select! lang e)))
|
||||
(shui/popup-hide!))}))))
|
||||
|
||||
(hsx/defc kernel-run-button
|
||||
[block]
|
||||
(let [[running? set-running!] (hooks/use-state false)]
|
||||
(shui/button
|
||||
{:variant :text
|
||||
:size :sm
|
||||
:disabled running?
|
||||
:on-click
|
||||
(fn [^js e]
|
||||
(util/stop-propagation e)
|
||||
(let [repo (state/get-current-repo)
|
||||
page-uuid (or (:block/uuid (:block/page block)) (:block/uuid block))
|
||||
^js cm (util/get-cm-instance (util/rec-get-node (.-target e) "ls-block"))]
|
||||
(when (and repo page-uuid cm)
|
||||
(set-running! true)
|
||||
(-> (state/<invoke-db-worker :thread-api/kernel-eval!
|
||||
repo page-uuid (:block/uuid block) (.getValue cm))
|
||||
(p/finally #(set-running! false))))))}
|
||||
(ui/icon (if running? "loader-2" "player-play") {:class (when running? "animate-spin")})
|
||||
(t (if running? :editor/running-code :editor/run-code)))))
|
||||
|
||||
(hsx/defc kernel-result
|
||||
[block]
|
||||
(when-let [result (:logseq.property.code/result block)]
|
||||
[:div.kernel-results.mt-1
|
||||
[:code (t :editor/kernel-results)]
|
||||
[:pre.code (str result)]]))
|
||||
|
||||
(hsx/defc src-cp
|
||||
[config options]
|
||||
(let [block (or (:code-block config) (:block config))
|
||||
@@ -4779,11 +4807,15 @@
|
||||
(util/copy-to-clipboard! (.getValue cm))
|
||||
(notification/show! (t :notification/copied) :success)))}
|
||||
(ui/icon "copy")
|
||||
(t :ui/copy))]
|
||||
(t :ui/copy))
|
||||
(when (= language "clojure")
|
||||
(kernel-run-button block))]
|
||||
(lazy-editor/editor config editor-id attr code options)
|
||||
(let [options (:options options) block (:block config)]
|
||||
(when (and (= language "clojure") (contains? (set options) ":results"))
|
||||
(sci/eval-result code block)))]]))))))
|
||||
(sci/eval-result code block)))
|
||||
(when (= language "clojure")
|
||||
(kernel-result block))]]))))))
|
||||
|
||||
(defn ^:large-vars/cleanup-todo markup-element-cp
|
||||
[{:keys [html-export?] :as config} item]
|
||||
|
||||
44
src/main/frontend/components/live_note.cljs
Normal file
44
src/main/frontend/components/live_note.cljs
Normal file
@@ -0,0 +1,44 @@
|
||||
(ns frontend.components.live-note
|
||||
"Displays fetched content for a page tagged :logseq.class/LiveNote, backed
|
||||
by the :live-note-content render-resource (worker/handler/live_notes.cljs
|
||||
fetches and transacts the content on a refresh schedule; this component
|
||||
only reads it back reactively)."
|
||||
(:require [frontend.context.i18n :refer [t]]
|
||||
[frontend.db.hooks :as db-hooks]
|
||||
[frontend.handler.notification :as notification]
|
||||
[frontend.state :as state]
|
||||
[io.factorhouse.hsx.core :as hsx]
|
||||
[logseq.shui.hooks :as hooks]))
|
||||
|
||||
(defn- format-timestamp
|
||||
[ms]
|
||||
(when (number? ms)
|
||||
(.toLocaleString (js/Date. ms))))
|
||||
|
||||
(hsx/defc live-note
|
||||
[page-uuid]
|
||||
(let [{:keys [url content last-fetched-at last-error]} (db-hooks/use-resource [:live-note-content page-uuid])
|
||||
[refreshing? set-refreshing!] (hooks/use-state false)]
|
||||
(when url
|
||||
[:div.live-note.fade-in.delay {:key "page-live-note"}
|
||||
[:div.flex.items-center.justify-between.mb-2
|
||||
[:div.text-sm.font-medium.text-muted-foreground (t :live-note/title)]
|
||||
[:button.text-sm.text-muted-foreground.hover:text-foreground
|
||||
{:disabled refreshing?
|
||||
:on-click (fn []
|
||||
(set-refreshing! true)
|
||||
(-> (state/<invoke-db-worker :thread-api/live-note-refresh-now!
|
||||
(state/get-current-repo) page-uuid)
|
||||
(.finally (fn []
|
||||
(set-refreshing! false)
|
||||
(when last-error
|
||||
(notification/show! last-error :error))))))}
|
||||
(if refreshing? (t :live-note/refreshing) (t :live-note/refresh-now))]]
|
||||
(when last-error
|
||||
[:div.text-sm.text-error.mb-2 last-error])
|
||||
(if content
|
||||
[:div.live-note-content.whitespace-pre-wrap.text-sm content]
|
||||
[:div.text-sm.text-muted-foreground (t :live-note/not-fetched-yet)])
|
||||
(when last-fetched-at
|
||||
[:div.text-xs.text-muted-foreground.mt-2
|
||||
(str (t :live-note/last-fetched) " " (format-timestamp last-fetched-at))])])))
|
||||
@@ -15,6 +15,7 @@
|
||||
[frontend.components.recycle :as recycle]
|
||||
[frontend.components.reference :as reference]
|
||||
[frontend.components.scheduled-deadlines :as scheduled]
|
||||
[frontend.components.live-note :as live-note]
|
||||
[frontend.components.similar-pages :as similar-pages]
|
||||
[frontend.config :as config]
|
||||
[frontend.context.i18n :refer [t]]
|
||||
@@ -514,6 +515,9 @@
|
||||
(when (entity/class? page)
|
||||
(class-component/class-children page))
|
||||
|
||||
(when (some #(= :logseq.class/LiveNote (:db/ident %)) (:block/tags page))
|
||||
(live-note/live-note (:block/uuid page)))
|
||||
|
||||
;; referenced blocks
|
||||
(when (and (not tag-dialog?)
|
||||
(not linked-refs?))
|
||||
|
||||
@@ -205,7 +205,14 @@
|
||||
:logseq.property.caldav/uid
|
||||
:logseq.property.caldav/href
|
||||
:logseq.property.caldav/etag]
|
||||
:fix add-calendar-view-type-closed-value}]])
|
||||
:fix add-calendar-view-type-closed-value}]
|
||||
["65.38" {:properties [:logseq.property.code/result :logseq.property.code/executed-at]}]
|
||||
["65.39" {:classes [:logseq.class/LiveNote]
|
||||
:properties [:logseq.property.livenote/url
|
||||
:logseq.property.livenote/refresh-minutes
|
||||
:logseq.property.livenote/content
|
||||
:logseq.property.livenote/last-fetched-at
|
||||
:logseq.property.livenote/last-error]}]])
|
||||
|
||||
(let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first)
|
||||
schema-version->updates)))]
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
[frontend.worker.handler.export]
|
||||
[frontend.worker.handler.flashcard]
|
||||
[frontend.worker.handler.graph]
|
||||
[frontend.worker.handler.kernel :as kernel-handler]
|
||||
[frontend.worker.handler.live-notes :as live-notes-handler]
|
||||
[frontend.worker.handler.markdown]
|
||||
[frontend.worker.handler.maintenance]
|
||||
[frontend.worker.handler.page]
|
||||
@@ -366,6 +368,8 @@
|
||||
(js/clearInterval timer))
|
||||
(swap! *client-ops-cleanup-timers dissoc repo)
|
||||
(caldav-handler/stop-sync! repo)
|
||||
(kernel-handler/clear-repo-sessions! repo)
|
||||
(live-notes-handler/stop-refresh! repo)
|
||||
(swap! *sqlite-conns dissoc repo)
|
||||
(when-let [vector-index (worker-state/get-vector-index repo)]
|
||||
(when-let [close-fn (:close! vector-index)]
|
||||
@@ -651,6 +655,7 @@
|
||||
(db-listener/listen-db-changes! repo conn)
|
||||
|
||||
(caldav-handler/maybe-start-sync! repo)
|
||||
(live-notes-handler/maybe-start-refresh! repo)
|
||||
|
||||
nil))))))
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@
|
||||
(when (seq tx) (ldb/transact! conn tx))
|
||||
(:block/uuid existing))
|
||||
(let [[_title page-uuid] (page-handler/create! conn title
|
||||
{:tags [tag-ident]
|
||||
{:tags [(d/entity db [:db/ident tag-ident])]
|
||||
:properties (clean-properties properties)})]
|
||||
page-uuid))))
|
||||
|
||||
@@ -521,7 +521,7 @@
|
||||
(p/catch (fn [error]
|
||||
(swap! *sync-status assoc repo {:last-sync-at (js/Date.now())
|
||||
:last-sync-error (str error)})
|
||||
(log/error :caldav/sync-failed {:repo repo :error error})
|
||||
(log/error :caldav/sync-failed {:repo repo :error (str error)})
|
||||
{:status :error :error (str error)}))))))))
|
||||
|
||||
(defn- node-runtime?
|
||||
@@ -548,7 +548,7 @@
|
||||
(swap! *sync-intervals assoc repo
|
||||
(js/setInterval #(<sync! repo) sync-interval-ms)))))
|
||||
(p/catch (fn [error]
|
||||
(log/error :caldav/maybe-start-sync-failed {:repo repo :error error}))))))
|
||||
(log/error :caldav/maybe-start-sync-failed {:repo repo :error (str error)}))))))
|
||||
|
||||
(def-thread-api :thread-api/caldav-connect!
|
||||
[repo config]
|
||||
|
||||
99
src/main/frontend/worker/handler/kernel.cljs
Normal file
99
src/main/frontend/worker/handler/kernel.cljs
Normal file
@@ -0,0 +1,99 @@
|
||||
(ns frontend.worker.handler.kernel
|
||||
"A stateful SCI kernel for executable code blocks: one interpreter context
|
||||
per page, reused across block evaluations on that page so `def`s and
|
||||
other side effects persist between runs -- unlike the plain, stateless
|
||||
per-call eval in frontend.extensions.sci that the block editor already
|
||||
does for inline `:results` display.
|
||||
|
||||
Isolation boundary, chosen deliberately instead of guessed at: this does
|
||||
NOT expand what code can do beyond what SCI already exposed to the
|
||||
client-side single-shot evaluator -- no shell/process execution, no
|
||||
filesystem access, no arbitrary JS interop beyond the small curated
|
||||
`user` namespace below (a strict subset of frontend.extensions.sci's,
|
||||
with the browser-only `call-api` binding dropped). Running the kernel in
|
||||
the daemon rather than the browser tab is what makes results consistent
|
||||
across clients and lets a session survive a page staying open elsewhere;
|
||||
it is not a new sandbox. A real subprocess-based sandbox (per the plan's
|
||||
own \"definir el limite antes de implementar\") is future work, not
|
||||
something to fake here."
|
||||
(:require [datascript.core :as d]
|
||||
[frontend.common.thread-api :refer [def-thread-api]]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.db :as ldb]
|
||||
[sci.core :as sci]))
|
||||
|
||||
(def ^:private sum (partial apply +))
|
||||
|
||||
(defn- average
|
||||
[coll]
|
||||
(/ (reduce + coll) (count coll)))
|
||||
|
||||
(def ^:private default-user-namespace
|
||||
{'sum sum
|
||||
'average average
|
||||
'parseFloat js/parseFloat
|
||||
'isNaN js/isNaN
|
||||
'log js/console.log
|
||||
'pprint pr-str})
|
||||
|
||||
(defonce ^:private *sessions (atom {}))
|
||||
|
||||
(defn- session-key
|
||||
[repo page-uuid]
|
||||
[repo page-uuid])
|
||||
|
||||
(defn- get-or-create-session!
|
||||
[repo page-uuid]
|
||||
(let [k (session-key repo page-uuid)]
|
||||
(or (get @*sessions k)
|
||||
(let [ctx (sci/init {:namespaces {'user default-user-namespace}})]
|
||||
(swap! *sessions assoc k ctx)
|
||||
ctx))))
|
||||
|
||||
(defn restart-session!
|
||||
[repo page-uuid]
|
||||
(swap! *sessions dissoc (session-key repo page-uuid)))
|
||||
|
||||
(defn clear-repo-sessions!
|
||||
[repo]
|
||||
(swap! *sessions
|
||||
(fn [sessions]
|
||||
(into {} (remove (fn [[[r _page-uuid] _ctx]] (= r repo))) sessions))))
|
||||
|
||||
(defn- eval-code
|
||||
[ctx code]
|
||||
(try
|
||||
{:value (sci/eval-string* ctx code)}
|
||||
(catch :default e
|
||||
{:error (or (.-message e) (str e))})))
|
||||
|
||||
(defn eval-block!
|
||||
"Evaluates `code` in the persistent kernel session for `page-uuid`, then
|
||||
persists the formatted result onto `block-uuid` as
|
||||
:logseq.property.code/result (prefixed with \"Error: \" on failure)."
|
||||
[repo page-uuid block-uuid code]
|
||||
(if-let [conn (worker-state/get-datascript-conn repo)]
|
||||
(let [ctx (get-or-create-session! repo page-uuid)
|
||||
{:keys [value error]} (eval-code ctx code)
|
||||
result-text (if error (str "Error: " error) (pr-str value))
|
||||
block (d/entity @conn [:block/uuid block-uuid])]
|
||||
(if block
|
||||
(do
|
||||
(ldb/transact! conn
|
||||
[[:db/add (:db/id block) :logseq.property.code/result result-text]
|
||||
[:db/add (:db/id block) :logseq.property.code/executed-at (js/Date.now())]])
|
||||
{:result result-text :error? (boolean error)})
|
||||
(do
|
||||
(log/warn :kernel/eval-missing-block {:repo repo :block-uuid block-uuid})
|
||||
{:result nil :error? true})))
|
||||
{:result nil :error? true}))
|
||||
|
||||
(def-thread-api :thread-api/kernel-eval!
|
||||
[repo page-uuid block-uuid code]
|
||||
(eval-block! repo page-uuid block-uuid code))
|
||||
|
||||
(def-thread-api :thread-api/kernel-restart!
|
||||
[repo page-uuid]
|
||||
(restart-session! repo page-uuid)
|
||||
{:status :restarted})
|
||||
149
src/main/frontend/worker/handler/live_notes.cljs
Normal file
149
src/main/frontend/worker/handler/live_notes.cljs
Normal file
@@ -0,0 +1,149 @@
|
||||
(ns frontend.worker.handler.live-notes
|
||||
"Live notes: pages tagged :logseq.class/LiveNote whose content is fetched
|
||||
from an external URL on a refresh schedule.
|
||||
|
||||
Native, not a plugin: the plugin API's fenced-code-renderer + exper_request
|
||||
pattern only runs in Electron and has no refresh scheduler or secure
|
||||
credential store. This follows the render-resource guidance instead --
|
||||
a background poller `transact!`s fetched content into the graph as a
|
||||
normal property, and the existing render-resource/affected-keys pipeline
|
||||
(see render_resource/live_note.cljs) picks up the change and pushes it to
|
||||
clients with zero bespoke plumbing on the read side.
|
||||
|
||||
Node-only, like search embeddings and CalDAV: outbound fetch and the
|
||||
platform keychain only exist in the daemon runtime."
|
||||
(:require [datascript.core :as d]
|
||||
[frontend.common.thread-api :refer [def-thread-api]]
|
||||
[frontend.worker.platform :as platform]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.db :as ldb]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(def ^:private default-refresh-minutes 60)
|
||||
(def ^:private min-refresh-minutes 5)
|
||||
(def ^:private max-content-length 8000)
|
||||
(def ^:private tick-interval-ms (* 5 60 1000))
|
||||
|
||||
(defn- node-runtime?
|
||||
[]
|
||||
(= :node (platform/env-flag (platform/current) :runtime)))
|
||||
|
||||
(defn- auth-secret-key
|
||||
[repo block-uuid]
|
||||
(str "live-note:" repo ":" block-uuid))
|
||||
|
||||
(defn <save-auth!
|
||||
[repo block-uuid auth-header]
|
||||
(platform/save-secret-text! (platform/current) (auth-secret-key repo block-uuid) auth-header))
|
||||
|
||||
(defn <read-auth
|
||||
[repo block-uuid]
|
||||
(platform/read-secret-text (platform/current) (auth-secret-key repo block-uuid)))
|
||||
|
||||
(defn <delete-auth!
|
||||
[repo block-uuid]
|
||||
(platform/delete-secret-text! (platform/current) (auth-secret-key repo block-uuid)))
|
||||
|
||||
(defn effective-refresh-minutes
|
||||
[refresh-minutes]
|
||||
(max min-refresh-minutes (or refresh-minutes default-refresh-minutes)))
|
||||
|
||||
(defn due?
|
||||
"Whether a live note is due for a refresh: never fetched, or its own
|
||||
refresh interval has elapsed since the last fetch."
|
||||
[now-ms last-fetched-at refresh-minutes]
|
||||
(or (not (number? last-fetched-at))
|
||||
(>= (- now-ms last-fetched-at)
|
||||
(* (effective-refresh-minutes refresh-minutes) 60 1000))))
|
||||
|
||||
(defn truncate-content
|
||||
[text]
|
||||
(when (string? text)
|
||||
(if (> (count text) max-content-length)
|
||||
(str (subs text 0 max-content-length) "\n...(truncated)")
|
||||
text)))
|
||||
|
||||
(defn- <fetch-content!
|
||||
[fetch-fn url auth-header]
|
||||
(-> (fetch-fn url (clj->js (cond-> {:method "GET"}
|
||||
auth-header (assoc :headers {"Authorization" auth-header}))))
|
||||
(p/then (fn [resp]
|
||||
(if (.-ok resp)
|
||||
(.text resp)
|
||||
(p/rejected (ex-info (str "HTTP " (.-status resp)) {:status (.-status resp)})))))))
|
||||
|
||||
(defn- <refresh-page!
|
||||
[conn fetch-fn repo page]
|
||||
(let [url (:logseq.property.livenote/url page)]
|
||||
(-> (p/let [auth-header (<read-auth repo (:block/uuid page))
|
||||
text (<fetch-content! fetch-fn url auth-header)]
|
||||
(ldb/transact! conn
|
||||
[[:db/add (:db/id page) :logseq.property.livenote/content (truncate-content text)]
|
||||
[:db/add (:db/id page) :logseq.property.livenote/last-fetched-at (js/Date.now())]
|
||||
[:db/retract (:db/id page) :logseq.property.livenote/last-error]]))
|
||||
(p/catch (fn [error]
|
||||
(log/warn :live-notes/refresh-failed
|
||||
{:page-uuid (str (:block/uuid page)) :error (str error)})
|
||||
(ldb/transact! conn
|
||||
[[:db/add (:db/id page) :logseq.property.livenote/last-fetched-at (js/Date.now())]
|
||||
[:db/add (:db/id page) :logseq.property.livenote/last-error (str error)]]))))))
|
||||
|
||||
(defn- live-note-pages
|
||||
"Guarded on the class existing: an empty graph (skips built-in initial
|
||||
data) or a graph created before this migration step has no
|
||||
:logseq.class/LiveNote ident, and an :avet lookup on a non-existent ident
|
||||
throws rather than returning no results."
|
||||
[db]
|
||||
(when (d/entity db :logseq.class/LiveNote)
|
||||
(->> (d/datoms db :avet :block/tags :logseq.class/LiveNote)
|
||||
(map (fn [datom] (d/entity db (:e datom))))
|
||||
(remove #(or (:logseq.property/deleted-at %) (ldb/hidden? %)))
|
||||
(filter :logseq.property.livenote/url))))
|
||||
|
||||
(defn- due-pages
|
||||
[now-ms pages]
|
||||
(filter (fn [page]
|
||||
(due? now-ms
|
||||
(:logseq.property.livenote/last-fetched-at page)
|
||||
(:logseq.property.livenote/refresh-minutes page)))
|
||||
pages))
|
||||
|
||||
(defn <tick!
|
||||
([repo] (<tick! repo js/fetch))
|
||||
([repo fetch-fn]
|
||||
(if-let [conn (worker-state/get-datascript-conn repo)]
|
||||
(let [due (due-pages (js/Date.now()) (live-note-pages @conn))]
|
||||
(p/all (map #(<refresh-page! conn fetch-fn repo %) due)))
|
||||
(p/resolved []))))
|
||||
|
||||
(defonce ^:private *intervals (atom {}))
|
||||
|
||||
(defn stop-refresh!
|
||||
[repo]
|
||||
(when-let [interval-id (get @*intervals repo)]
|
||||
(js/clearInterval interval-id))
|
||||
(swap! *intervals dissoc repo))
|
||||
|
||||
(defn maybe-start-refresh!
|
||||
[repo]
|
||||
(when (node-runtime?)
|
||||
(stop-refresh! repo)
|
||||
(<tick! repo)
|
||||
(swap! *intervals assoc repo (js/setInterval #(<tick! repo) tick-interval-ms))))
|
||||
|
||||
(def-thread-api :thread-api/live-note-refresh-now!
|
||||
[repo block-uuid]
|
||||
(if-let [conn (worker-state/get-datascript-conn repo)]
|
||||
(if-let [page (d/entity @conn [:block/uuid block-uuid])]
|
||||
(p/let [_ (<refresh-page! conn js/fetch repo page)]
|
||||
{:status :ok})
|
||||
(p/resolved {:status :not-found}))
|
||||
(p/resolved {:status :not-found})))
|
||||
|
||||
(def-thread-api :thread-api/live-note-set-auth!
|
||||
[repo block-uuid auth-header]
|
||||
(p/let [_ (if (seq auth-header)
|
||||
(<save-auth! repo block-uuid auth-header)
|
||||
(<delete-auth! repo block-uuid))]
|
||||
{:status :ok}))
|
||||
@@ -4,6 +4,7 @@
|
||||
[frontend.worker.handler.block :as block-handler]
|
||||
[frontend.worker.handler.render-resource.basic :as basic]
|
||||
[frontend.worker.handler.render-resource.common :as common]
|
||||
[frontend.worker.handler.render-resource.live-note :as live-note]
|
||||
[frontend.worker.handler.render-resource.property :as property]
|
||||
[frontend.worker.handler.render-resource.query :as query]
|
||||
[frontend.worker.handler.render-resource.similar :as similar]
|
||||
@@ -16,6 +17,7 @@
|
||||
view/resource-renderers
|
||||
query/resource-renderers
|
||||
similar/resource-renderers
|
||||
live-note/resource-renderers
|
||||
{:block-sync-conflicts
|
||||
(common/renderer nil
|
||||
(fn [_db resource-key _runtime]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
(ns frontend.worker.handler.render-resource.live-note
|
||||
"Live note content renderer resource. The content itself is fetched and
|
||||
transacted by frontend.worker.handler.live-notes; this renderer only
|
||||
reads it back out and declares the watch key that makes it reactive --
|
||||
see render_affected_keys.cljs's generic per-entity [:entity uuid] key,
|
||||
emitted whenever any of that entity's attributes change in a tx."
|
||||
(:require [frontend.worker.handler.render-resource.common :as common]))
|
||||
|
||||
(defn- live-note-content
|
||||
[db resource-key _runtime]
|
||||
(let [block-uuid (second resource-key)
|
||||
page (common/entity-by-uuid! db :block-uuid block-uuid)]
|
||||
[#{[:entity block-uuid]}
|
||||
{:url (:logseq.property.livenote/url page)
|
||||
:content (:logseq.property.livenote/content page)
|
||||
:last-fetched-at (:logseq.property.livenote/last-fetched-at page)
|
||||
:last-error (:logseq.property.livenote/last-error page)}]))
|
||||
|
||||
(def resource-renderers
|
||||
{:live-note-content (common/renderer 2 live-note-content)})
|
||||
@@ -497,6 +497,7 @@
|
||||
:editor/display-tag-inline-hint "to display this tag inline instead of at the end of this node."
|
||||
:editor/expand-block-children "Expand all"
|
||||
:editor/heading "Heading {1}"
|
||||
:editor/kernel-results "Kernel results"
|
||||
:editor/moving-blocks-count (fn [n] (str "Moving " n (if (> n 1) " blocks" " block")))
|
||||
:editor/new-page "New page"
|
||||
:editor/new-tag "New tag"
|
||||
@@ -504,6 +505,8 @@
|
||||
:editor/paste "Paste"
|
||||
:editor/reference-node-use-page-ref "To reference a node, please use `[[]]`."
|
||||
:editor/remove-heading "Remove heading"
|
||||
:editor/run-code "Run"
|
||||
:editor/running-code "Running..."
|
||||
:editor/search-for-node "Search for a node"
|
||||
:editor/search-for-tag "Search for a tag"
|
||||
:editor/search-template-placeholder "Search for a template"
|
||||
@@ -1012,6 +1015,12 @@
|
||||
:library/add-pages "Add pages"
|
||||
:library/title "Library"
|
||||
|
||||
:live-note/last-fetched "Last fetched:"
|
||||
:live-note/not-fetched-yet "Not fetched yet"
|
||||
:live-note/refresh-now "Refresh now"
|
||||
:live-note/refreshing "Refreshing…"
|
||||
:live-note/title "Live note"
|
||||
|
||||
:mobile/camera-access-denied "Camera access is denied. Enable it in Settings > Logseq."
|
||||
:mobile/microphone-access-denied "Microphone access is denied. Enable it in Settings > Logseq."
|
||||
|
||||
|
||||
86
src/test/frontend/worker/handler/kernel_test.cljs
Normal file
86
src/test/frontend/worker/handler/kernel_test.cljs
Normal file
@@ -0,0 +1,86 @@
|
||||
(ns frontend.worker.handler.kernel-test
|
||||
(:require [cljs.test :refer [deftest is testing use-fixtures]]
|
||||
[frontend.worker.handler.kernel :as kernel]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[logseq.db.test.helper :as db-test]))
|
||||
|
||||
(def ^:private test-repo "kernel-test-repo")
|
||||
|
||||
(defn- with-test-conn
|
||||
[f]
|
||||
(let [conn (db-test/create-conn-with-blocks
|
||||
{:pages-and-blocks
|
||||
[{:page {:block/title "Kernel page"}
|
||||
:blocks [{:block/title "code block"}]}]})
|
||||
prev-conns @worker-state/*datascript-conns]
|
||||
(swap! worker-state/*datascript-conns assoc test-repo conn)
|
||||
(try
|
||||
(f conn)
|
||||
(finally
|
||||
(reset! worker-state/*datascript-conns prev-conns)
|
||||
(kernel/clear-repo-sessions! test-repo)))))
|
||||
|
||||
(use-fixtures :each
|
||||
{:after #(kernel/clear-repo-sessions! test-repo)})
|
||||
|
||||
(deftest eval-block-persists-result-onto-the-block
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-uuid (:block/uuid (:block/page block))
|
||||
result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(+ 1 2)")]
|
||||
(is (= "3" (:result result)))
|
||||
(is (false? (:error? result)))
|
||||
(let [updated (db-test/find-block-by-content @conn "code block")]
|
||||
(is (= "3" (:logseq.property.code/result updated)))
|
||||
(is (number? (:logseq.property.code/executed-at updated))))))))
|
||||
|
||||
(deftest eval-block-formats-errors-with-a-prefix
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-uuid (:block/uuid (:block/page block))
|
||||
result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(throw (ex-info \"boom\" {}))")]
|
||||
(is (true? (:error? result)))
|
||||
(is (re-find #"^Error: " (:result result)))))))
|
||||
|
||||
(deftest kernel-session-state-persists-across-evaluations-on-the-same-page
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-uuid (:block/uuid (:block/page block))]
|
||||
(kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter 0)")
|
||||
(kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter (inc counter))")
|
||||
(kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter (inc counter))")
|
||||
(let [result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "counter")]
|
||||
(is (= "2" (:result result))))))))
|
||||
|
||||
(deftest kernel-sessions-are-isolated-per-page
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-a (random-uuid)
|
||||
page-b (random-uuid)]
|
||||
(kernel/eval-block! test-repo page-a (:block/uuid block) "(def shared 1)")
|
||||
(let [result (kernel/eval-block! test-repo page-b (:block/uuid block) "(def shared 2) shared")]
|
||||
(is (= "2" (:result result))))
|
||||
(let [result (kernel/eval-block! test-repo page-a (:block/uuid block) "shared")]
|
||||
(is (= "1" (:result result))))))))
|
||||
|
||||
(deftest restart-session-clears-persisted-state
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-uuid (:block/uuid (:block/page block))]
|
||||
(kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter 0)")
|
||||
(kernel/restart-session! test-repo page-uuid)
|
||||
(let [result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(def counter 0) counter")]
|
||||
(is (= "0" (:result result))))))))
|
||||
|
||||
(deftest kernel-namespace-exposes-the-same-helpers-as-the-client-evaluator
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(let [block (db-test/find-block-by-content @conn "code block")
|
||||
page-uuid (:block/uuid (:block/page block))]
|
||||
(is (= "6" (:result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(sum [1 2 3])"))))
|
||||
(is (= "2" (:result (kernel/eval-block! test-repo page-uuid (:block/uuid block) "(average [1 2 3])"))))))))
|
||||
150
src/test/frontend/worker/handler/live_notes_test.cljs
Normal file
150
src/test/frontend/worker/handler/live_notes_test.cljs
Normal file
@@ -0,0 +1,150 @@
|
||||
(ns frontend.worker.handler.live-notes-test
|
||||
(:require [cljs.test :refer [async deftest is testing]]
|
||||
[datascript.core :as d]
|
||||
[frontend.worker.handler.live-notes :as live-notes]
|
||||
[frontend.worker.handler.page :as page-handler]
|
||||
[frontend.worker.platform :as platform]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[logseq.db.test.helper :as db-test]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(deftest effective-refresh-minutes-clamps-to-a-minimum
|
||||
(is (= 60 (live-notes/effective-refresh-minutes nil)))
|
||||
(is (= 60 (live-notes/effective-refresh-minutes 60)))
|
||||
(is (= 5 (live-notes/effective-refresh-minutes 1)))
|
||||
(is (= 30 (live-notes/effective-refresh-minutes 30))))
|
||||
|
||||
(deftest due?-is-true-when-never-fetched
|
||||
(is (true? (live-notes/due? 1000000 nil 60)))
|
||||
(is (true? (live-notes/due? 1000000 "not-a-number" 60))))
|
||||
|
||||
(deftest due?-compares-elapsed-time-against-the-refresh-interval
|
||||
(let [now 10000000
|
||||
one-hour-ms (* 60 60 1000)]
|
||||
(testing "not yet due"
|
||||
(is (false? (live-notes/due? now (- now (/ one-hour-ms 2)) 60))))
|
||||
(testing "exactly due"
|
||||
(is (true? (live-notes/due? now (- now one-hour-ms) 60))))
|
||||
(testing "overdue"
|
||||
(is (true? (live-notes/due? now (- now (* 2 one-hour-ms)) 60))))))
|
||||
|
||||
(deftest truncate-content-leaves-short-text-untouched
|
||||
(is (= "short" (live-notes/truncate-content "short")))
|
||||
(is (nil? (live-notes/truncate-content nil))))
|
||||
|
||||
(deftest truncate-content-truncates-long-text-with-a-marker
|
||||
(let [long-text (apply str (repeat 9000 "a"))
|
||||
result (live-notes/truncate-content long-text)]
|
||||
(is (< (count result) (count long-text)))
|
||||
(is (re-find #"\(truncated\)$" result))))
|
||||
|
||||
(def ^:private test-repo "live-notes-test-repo")
|
||||
|
||||
;; :url is a ref-typed property (its value is wrapped in a property-value
|
||||
;; entity, per db-property-build/build-properties-with-ref-values), so it
|
||||
;; can't be set with a raw [:db/add eid attr string] tuple, and :build/tags
|
||||
;; + :build/properties on create-conn-with-blocks' page map only recognizes
|
||||
;; property/class namespaces curated into logseq-property-namespaces/
|
||||
;; db-class/logseq-class? -- going through page-handler/create! (the same
|
||||
;; path caldav.cljs's upsert-page! uses to create tagged pages with
|
||||
;; properties) exercises the real ref-value-building pipeline instead.
|
||||
(defn- with-test-conn
|
||||
[f]
|
||||
(let [conn (db-test/create-conn-with-blocks {:pages-and-blocks []})
|
||||
_ (page-handler/create! conn "Weather"
|
||||
{:tags [(d/entity @conn [:db/ident :logseq.class/LiveNote])]
|
||||
:properties {:logseq.property.livenote/url "https://example.com/weather"
|
||||
:logseq.property.livenote/refresh-minutes 60}})
|
||||
_ (page-handler/create! conn "Plain page" {})
|
||||
prev-conns @worker-state/*datascript-conns]
|
||||
(swap! worker-state/*datascript-conns assoc test-repo conn)
|
||||
(try
|
||||
(f conn)
|
||||
(finally
|
||||
(reset! worker-state/*datascript-conns prev-conns)
|
||||
(live-notes/stop-refresh! test-repo)))))
|
||||
|
||||
(defn- fake-fetch
|
||||
[response-text]
|
||||
(fn [_url _opts]
|
||||
(js/Promise.resolve
|
||||
#js {:ok true
|
||||
:text (fn [] (js/Promise.resolve response-text))})))
|
||||
|
||||
(defn- failing-fetch
|
||||
[_url _opts]
|
||||
(js/Promise.resolve #js {:ok false :status 500 :text (fn [] (js/Promise.resolve ""))}))
|
||||
|
||||
(def ^:private test-platform
|
||||
{:env {:publishing? false :runtime :node}
|
||||
:storage {}
|
||||
:kv {:get (fn [_] nil) :set! (fn [_ _] nil)}
|
||||
:broadcast {:post-message! (fn [& _] nil)}
|
||||
:websocket {:connect (fn [_] #js {})}
|
||||
:sqlite {}
|
||||
:crypto {:save-secret-text! (fn [_key _text] (p/resolved nil))
|
||||
:read-secret-text (fn [_key] (p/resolved nil))
|
||||
:delete-secret-text! (fn [_key] (p/resolved nil))}
|
||||
:timers {:set-interval! (fn [_ _] nil)}})
|
||||
|
||||
;; live-notes/<read-auth calls (platform/current), which throws unless the
|
||||
;; platform adapter is installed for this test process. with-redefs only
|
||||
;; holds for the SYNCHRONOUS extent of its body -- <tick! resolves through
|
||||
;; several .then callbacks that run after this fn returns, by which point a
|
||||
;; with-redefs binding would already be gone (and platform/current back to
|
||||
;; throwing) -- so install a real platform via set-platform! instead, kept
|
||||
;; live until the returned promise settles, matching the async-safe
|
||||
;; save/restore pattern in db_worker_test.cljs's restoring-worker-state.
|
||||
(defn- with-stub-platform
|
||||
"Installs test-platform for the async extent of (f), then restores the
|
||||
prior platform and calls done -- in that order, so the next async test
|
||||
can't install its own platform before this one's cleanup runs (a race
|
||||
that previously clobbered the next test's platform back to nil)."
|
||||
[f done]
|
||||
(let [platform-prev @@#'platform/*platform]
|
||||
(platform/set-platform! test-platform)
|
||||
(-> (f)
|
||||
(p/catch (fn [e] (is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(reset! @#'platform/*platform platform-prev)
|
||||
(done))))))
|
||||
|
||||
(deftest tick-fetches-due-live-notes-and-transacts-content
|
||||
(async done
|
||||
(with-stub-platform
|
||||
(fn []
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(-> (live-notes/<tick! test-repo (fake-fetch "Sunny, 22C"))
|
||||
(p/then (fn [_]
|
||||
(let [page (db-test/find-page-by-title @conn "Weather")]
|
||||
(is (= "Sunny, 22C" (:logseq.property.livenote/content page)))
|
||||
(is (number? (:logseq.property.livenote/last-fetched-at page)))
|
||||
(is (nil? (:logseq.property.livenote/last-error page))))))))))
|
||||
done)))
|
||||
|
||||
(deftest tick-ignores-pages-that-are-not-live-notes
|
||||
(async done
|
||||
(with-stub-platform
|
||||
(fn []
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(-> (live-notes/<tick! test-repo (fake-fetch "irrelevant"))
|
||||
(p/then (fn [_]
|
||||
(let [page (db-test/find-page-by-title @conn "Plain page")]
|
||||
(is (nil? (:logseq.property.livenote/content page))))))))))
|
||||
done)))
|
||||
|
||||
(deftest tick-records-an-error-when-the-fetch-fails
|
||||
(async done
|
||||
(with-stub-platform
|
||||
(fn []
|
||||
(with-test-conn
|
||||
(fn [conn]
|
||||
(-> (live-notes/<tick! test-repo failing-fetch)
|
||||
(p/then (fn [_]
|
||||
(let [page (db-test/find-page-by-title @conn "Weather")]
|
||||
(is (nil? (:logseq.property.livenote/content page)))
|
||||
(is (string? (:logseq.property.livenote/last-error page)))
|
||||
(is (number? (:logseq.property.livenote/last-fetched-at page))))))))))
|
||||
done)))
|
||||
Reference in New Issue
Block a user