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:
2026-08-26 22:41:23 +02:00
parent 0158430489
commit d8996bd5e1
18 changed files with 698 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View 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])"))))))))

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