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
|
:schema {:properties [:logseq.property.contact/email :logseq.property.contact/phone
|
||||||
:logseq.property.contact/organization]}}
|
: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)
|
;; 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]
|
[entity]
|
||||||
(has-tag? entity :logseq.class/Journal))
|
(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?
|
(defn page?
|
||||||
[entity]
|
[entity]
|
||||||
(or (internal-page? entity)
|
(or (internal-page? entity)
|
||||||
(journal? entity)
|
(journal? entity)
|
||||||
(class? entity)
|
(class? entity)
|
||||||
(property? entity)))
|
(property? entity)
|
||||||
|
(built-in-page-child? entity)))
|
||||||
|
|
||||||
(defn asset?
|
(defn asset?
|
||||||
"Given an entity or map, check if it is an asset block"
|
"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
|
:hide? true
|
||||||
:view-context :block}
|
:view-context :block}
|
||||||
:queryable? true}
|
: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"
|
:logseq.property/default-value {:title "Default value"
|
||||||
:schema {:type :entity
|
:schema {:type :entity
|
||||||
:public? false
|
:public? false
|
||||||
@@ -452,6 +462,27 @@
|
|||||||
:hide? true
|
:hide? true
|
||||||
:public? false}}
|
: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"
|
:logseq.property/icon {:title "Icon"
|
||||||
:schema {:type :map}}
|
:schema {:type :map}}
|
||||||
:logseq.property/publishing-public? {:title "Publishing Public?"
|
:logseq.property/publishing-public? {:title "Publishing Public?"
|
||||||
@@ -804,7 +835,9 @@
|
|||||||
"logseq.property.journal" "logseq.property.class" "logseq.property.view"
|
"logseq.property.journal" "logseq.property.class" "logseq.property.view"
|
||||||
"logseq.property.user" "logseq.property.history"
|
"logseq.property.user" "logseq.property.history"
|
||||||
"logseq.property.reaction" "logseq.property.sync" "logseq.property.publish"
|
"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?
|
(defn logseq-property?
|
||||||
"Determines if keyword is a 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)
|
(map (juxt :major :minor)
|
||||||
[(parse-schema-version x) (parse-schema-version y)])))
|
[(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
|
(defn major-version
|
||||||
"Return a number.
|
"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'))
|
(ldb/transact! conn tx-data tx-meta'))
|
||||||
true))))))
|
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]}]
|
(defn- build-page-tx [db properties page {:keys [class? tags class-ident-namespace]}]
|
||||||
(when (:block/uuid page)
|
(when (:block/uuid page)
|
||||||
(let [type-tag (if class? :logseq.class/Tag :logseq.class/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
|
page' (update page :block/tags
|
||||||
(fnil into [])
|
(fnil into [])
|
||||||
(mapv (fn [tag]
|
(mapv (fn [tag]
|
||||||
@@ -181,10 +204,10 @@
|
|||||||
page'
|
page'
|
||||||
(->> properties
|
(->> properties
|
||||||
(keep (fn [[k v]]
|
(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)
|
(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 {})))]
|
(into {})))]
|
||||||
(cond-> (if class?
|
(cond-> (if class?
|
||||||
[(merge (db-class/build-new-class db page' {:ident-namespace class-ident-namespace})
|
[(merge (db-class/build-new-class db page' {:ident-namespace class-ident-namespace})
|
||||||
|
|||||||
@@ -4714,6 +4714,34 @@
|
|||||||
(on-select! lang e)))
|
(on-select! lang e)))
|
||||||
(shui/popup-hide!))}))))
|
(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
|
(hsx/defc src-cp
|
||||||
[config options]
|
[config options]
|
||||||
(let [block (or (:code-block config) (:block config))
|
(let [block (or (:code-block config) (:block config))
|
||||||
@@ -4779,11 +4807,15 @@
|
|||||||
(util/copy-to-clipboard! (.getValue cm))
|
(util/copy-to-clipboard! (.getValue cm))
|
||||||
(notification/show! (t :notification/copied) :success)))}
|
(notification/show! (t :notification/copied) :success)))}
|
||||||
(ui/icon "copy")
|
(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)
|
(lazy-editor/editor config editor-id attr code options)
|
||||||
(let [options (:options options) block (:block config)]
|
(let [options (:options options) block (:block config)]
|
||||||
(when (and (= language "clojure") (contains? (set options) ":results"))
|
(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
|
(defn ^:large-vars/cleanup-todo markup-element-cp
|
||||||
[{:keys [html-export?] :as config} item]
|
[{: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.recycle :as recycle]
|
||||||
[frontend.components.reference :as reference]
|
[frontend.components.reference :as reference]
|
||||||
[frontend.components.scheduled-deadlines :as scheduled]
|
[frontend.components.scheduled-deadlines :as scheduled]
|
||||||
|
[frontend.components.live-note :as live-note]
|
||||||
[frontend.components.similar-pages :as similar-pages]
|
[frontend.components.similar-pages :as similar-pages]
|
||||||
[frontend.config :as config]
|
[frontend.config :as config]
|
||||||
[frontend.context.i18n :refer [t]]
|
[frontend.context.i18n :refer [t]]
|
||||||
@@ -514,6 +515,9 @@
|
|||||||
(when (entity/class? page)
|
(when (entity/class? page)
|
||||||
(class-component/class-children 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
|
;; referenced blocks
|
||||||
(when (and (not tag-dialog?)
|
(when (and (not tag-dialog?)
|
||||||
(not linked-refs?))
|
(not linked-refs?))
|
||||||
|
|||||||
@@ -205,7 +205,14 @@
|
|||||||
:logseq.property.caldav/uid
|
:logseq.property.caldav/uid
|
||||||
:logseq.property.caldav/href
|
:logseq.property.caldav/href
|
||||||
:logseq.property.caldav/etag]
|
: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)
|
(let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first)
|
||||||
schema-version->updates)))]
|
schema-version->updates)))]
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
[frontend.worker.handler.export]
|
[frontend.worker.handler.export]
|
||||||
[frontend.worker.handler.flashcard]
|
[frontend.worker.handler.flashcard]
|
||||||
[frontend.worker.handler.graph]
|
[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.markdown]
|
||||||
[frontend.worker.handler.maintenance]
|
[frontend.worker.handler.maintenance]
|
||||||
[frontend.worker.handler.page]
|
[frontend.worker.handler.page]
|
||||||
@@ -366,6 +368,8 @@
|
|||||||
(js/clearInterval timer))
|
(js/clearInterval timer))
|
||||||
(swap! *client-ops-cleanup-timers dissoc repo)
|
(swap! *client-ops-cleanup-timers dissoc repo)
|
||||||
(caldav-handler/stop-sync! repo)
|
(caldav-handler/stop-sync! repo)
|
||||||
|
(kernel-handler/clear-repo-sessions! repo)
|
||||||
|
(live-notes-handler/stop-refresh! repo)
|
||||||
(swap! *sqlite-conns dissoc repo)
|
(swap! *sqlite-conns dissoc repo)
|
||||||
(when-let [vector-index (worker-state/get-vector-index repo)]
|
(when-let [vector-index (worker-state/get-vector-index repo)]
|
||||||
(when-let [close-fn (:close! vector-index)]
|
(when-let [close-fn (:close! vector-index)]
|
||||||
@@ -651,6 +655,7 @@
|
|||||||
(db-listener/listen-db-changes! repo conn)
|
(db-listener/listen-db-changes! repo conn)
|
||||||
|
|
||||||
(caldav-handler/maybe-start-sync! repo)
|
(caldav-handler/maybe-start-sync! repo)
|
||||||
|
(live-notes-handler/maybe-start-refresh! repo)
|
||||||
|
|
||||||
nil))))))
|
nil))))))
|
||||||
|
|
||||||
|
|||||||
@@ -371,7 +371,7 @@
|
|||||||
(when (seq tx) (ldb/transact! conn tx))
|
(when (seq tx) (ldb/transact! conn tx))
|
||||||
(:block/uuid existing))
|
(:block/uuid existing))
|
||||||
(let [[_title page-uuid] (page-handler/create! conn title
|
(let [[_title page-uuid] (page-handler/create! conn title
|
||||||
{:tags [tag-ident]
|
{:tags [(d/entity db [:db/ident tag-ident])]
|
||||||
:properties (clean-properties properties)})]
|
:properties (clean-properties properties)})]
|
||||||
page-uuid))))
|
page-uuid))))
|
||||||
|
|
||||||
@@ -521,7 +521,7 @@
|
|||||||
(p/catch (fn [error]
|
(p/catch (fn [error]
|
||||||
(swap! *sync-status assoc repo {:last-sync-at (js/Date.now())
|
(swap! *sync-status assoc repo {:last-sync-at (js/Date.now())
|
||||||
:last-sync-error (str error)})
|
: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)}))))))))
|
{:status :error :error (str error)}))))))))
|
||||||
|
|
||||||
(defn- node-runtime?
|
(defn- node-runtime?
|
||||||
@@ -548,7 +548,7 @@
|
|||||||
(swap! *sync-intervals assoc repo
|
(swap! *sync-intervals assoc repo
|
||||||
(js/setInterval #(<sync! repo) sync-interval-ms)))))
|
(js/setInterval #(<sync! repo) sync-interval-ms)))))
|
||||||
(p/catch (fn [error]
|
(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!
|
(def-thread-api :thread-api/caldav-connect!
|
||||||
[repo config]
|
[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.block :as block-handler]
|
||||||
[frontend.worker.handler.render-resource.basic :as basic]
|
[frontend.worker.handler.render-resource.basic :as basic]
|
||||||
[frontend.worker.handler.render-resource.common :as common]
|
[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.property :as property]
|
||||||
[frontend.worker.handler.render-resource.query :as query]
|
[frontend.worker.handler.render-resource.query :as query]
|
||||||
[frontend.worker.handler.render-resource.similar :as similar]
|
[frontend.worker.handler.render-resource.similar :as similar]
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
view/resource-renderers
|
view/resource-renderers
|
||||||
query/resource-renderers
|
query/resource-renderers
|
||||||
similar/resource-renderers
|
similar/resource-renderers
|
||||||
|
live-note/resource-renderers
|
||||||
{:block-sync-conflicts
|
{:block-sync-conflicts
|
||||||
(common/renderer nil
|
(common/renderer nil
|
||||||
(fn [_db resource-key _runtime]
|
(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/display-tag-inline-hint "to display this tag inline instead of at the end of this node."
|
||||||
:editor/expand-block-children "Expand all"
|
:editor/expand-block-children "Expand all"
|
||||||
:editor/heading "Heading {1}"
|
:editor/heading "Heading {1}"
|
||||||
|
:editor/kernel-results "Kernel results"
|
||||||
:editor/moving-blocks-count (fn [n] (str "Moving " n (if (> n 1) " blocks" " block")))
|
:editor/moving-blocks-count (fn [n] (str "Moving " n (if (> n 1) " blocks" " block")))
|
||||||
:editor/new-page "New page"
|
:editor/new-page "New page"
|
||||||
:editor/new-tag "New tag"
|
:editor/new-tag "New tag"
|
||||||
@@ -504,6 +505,8 @@
|
|||||||
:editor/paste "Paste"
|
:editor/paste "Paste"
|
||||||
:editor/reference-node-use-page-ref "To reference a node, please use `[[]]`."
|
:editor/reference-node-use-page-ref "To reference a node, please use `[[]]`."
|
||||||
:editor/remove-heading "Remove heading"
|
:editor/remove-heading "Remove heading"
|
||||||
|
:editor/run-code "Run"
|
||||||
|
:editor/running-code "Running..."
|
||||||
:editor/search-for-node "Search for a node"
|
:editor/search-for-node "Search for a node"
|
||||||
:editor/search-for-tag "Search for a tag"
|
:editor/search-for-tag "Search for a tag"
|
||||||
:editor/search-template-placeholder "Search for a template"
|
:editor/search-template-placeholder "Search for a template"
|
||||||
@@ -1012,6 +1015,12 @@
|
|||||||
:library/add-pages "Add pages"
|
:library/add-pages "Add pages"
|
||||||
:library/title "Library"
|
: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/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."
|
: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