diff --git a/.i18n-lint.toml b/.i18n-lint.toml index a1bb609afb..d82123c3f2 100644 --- a/.i18n-lint.toml +++ b/.i18n-lint.toml @@ -106,6 +106,7 @@ exclude_patterns = [ "src/main/frontend/handler/shell.cljs", # Run shell command "src/main/frontend/undo_redo/debug_ui.cljs", # Developer undo/redo tool "src/main/frontend/worker/commands.cljs", # Internal command identifier strings + "src/main/frontend/**/plugin*", # Plugin namespaces — may contain hardcoded strings that are not ] # Maximum character length of the text preview in output. diff --git a/deps/outliner/src/logseq/outliner/core.cljs b/deps/outliner/src/logseq/outliner/core.cljs index e5090d3d64..534b495cff 100644 --- a/deps/outliner/src/logseq/outliner/core.cljs +++ b/deps/outliner/src/logseq/outliner/core.cljs @@ -612,7 +612,8 @@ (throw (js/Error. (str "[insert-blocks] illegal lookup: " lookup ", block: " block))))) blocks-tx (build-insert-blocks-tx db target-block blocks uuids get-new-id opts)] {:blocks-tx blocks-tx - :id->new-uuid id->new-uuid})) + :id->new-uuid id->new-uuid + :uuid->new-uuid uuids})) (defn- get-target-block [db blocks target-block {:keys [outliner-op bottom? top? indent? sibling? up? replace-empty-target?]}] @@ -763,11 +764,11 @@ :keep-block-order? keep-block-order? :outliner-op outliner-op :insert-template? insert-template?} - {:keys [id->new-uuid blocks-tx]} (insert-blocks-aux db blocks' target-block insert-opts)] - (if (some (fn [b] (or (nil? (:block/parent b)) (nil? (:block/order b)))) blocks-tx) - (throw (ex-info "Invalid outliner data" - {:opts insert-opts - :tx (vec blocks-tx) + {:keys [id->new-uuid uuid->new-uuid blocks-tx]} (insert-blocks-aux db blocks' target-block insert-opts)] + (if (some (fn [b] (or (nil? (:block/parent b)) (nil? (:block/order b)))) blocks-tx) + (throw (ex-info "Invalid outliner data" + {:opts insert-opts + :tx (vec blocks-tx) :blocks (vec blocks) :target-block target-block})) (let [tx (assign-temp-id blocks-tx target-block replace-empty-target?) @@ -778,12 +779,14 @@ (remove old-db-id-blocks) (remove nil?) (map (fn [uuid'] {:block/uuid uuid'}))) - from-property (:logseq.property/created-from-property target-block) - many? (= :db.cardinality/many (:db/cardinality from-property)) - property-values-tx (when (and sibling? from-property many?) + from-property (:logseq.property/created-from-property target-block) + many? (= :db.cardinality/many (:db/cardinality from-property)) + property-values-tx (when (and sibling? from-property many?) (let [top-level-blocks (filter #(= 1 (:block/level %)) blocks')] (mapcat (fn [block] - (when-let [new-id (or (id->new-uuid (:db/id block)) (:block/uuid block))] + (when-let [new-id (or (id->new-uuid (:db/id block)) + (uuid->new-uuid (:block/uuid block)) + (:block/uuid block))] [{:block/uuid new-id :logseq.property/created-from-property (:db/id from-property)} [:db/add diff --git a/deps/publish/src/logseq/publish/meta_store.cljs b/deps/publish/src/logseq/publish/meta_store.cljs index 0243567ee6..13d224f966 100644 --- a/deps/publish/src/logseq/publish/meta_store.cljs +++ b/deps/publish/src/logseq/publish/meta_store.cljs @@ -281,10 +281,11 @@ "page_tags.source_block_content, page_tags.source_block_format, page_tags.updated_at, " "pages.short_id " "FROM page_tags " - "LEFT JOIN pages " + "INNER JOIN pages " "ON pages.graph_uuid = page_tags.graph_uuid " "AND pages.page_uuid = page_tags.source_page_uuid " "WHERE page_tags.tag_title = ? " + "AND pages.password_hash IS NULL " "ORDER BY page_tags.updated_at DESC;") tag-name)) page-rows (publish-common/get-sql-rows @@ -293,10 +294,11 @@ "pages.short_id, " "MAX(page_tags.updated_at) AS updated_at " "FROM page_tags " - "LEFT JOIN pages " + "INNER JOIN pages " "ON pages.graph_uuid = page_tags.graph_uuid " "AND pages.page_uuid = page_tags.source_page_uuid " "WHERE page_tags.tag_title = ? " + "AND pages.password_hash IS NULL " "GROUP BY page_tags.graph_uuid, page_tags.source_page_uuid, page_tags.source_page_title, pages.short_id " "ORDER BY updated_at DESC;") tag-name))] @@ -381,11 +383,16 @@ (= (nth parts 4 nil) "tagged_nodes") (let [rows (publish-common/get-sql-rows (publish-common/sql-exec sql - (str "SELECT graph_uuid, tag_page_uuid, tag_title, source_page_uuid, " - "source_page_title, source_block_uuid, source_block_content, " - "source_block_format, updated_at " - "FROM page_tags WHERE graph_uuid = ? AND tag_page_uuid = ? " - "ORDER BY updated_at DESC;") + (str "SELECT page_tags.graph_uuid, page_tags.tag_page_uuid, page_tags.tag_title, page_tags.source_page_uuid, " + "page_tags.source_page_title, page_tags.source_block_uuid, page_tags.source_block_content, " + "page_tags.source_block_format, page_tags.updated_at " + "FROM page_tags " + "INNER JOIN pages " + "ON pages.graph_uuid = page_tags.graph_uuid " + "AND pages.page_uuid = page_tags.source_page_uuid " + "WHERE page_tags.graph_uuid = ? AND page_tags.tag_page_uuid = ? " + "AND pages.password_hash IS NULL " + "ORDER BY page_tags.updated_at DESC;") graph-uuid page-uuid))] (publish-common/json-response {:tagged_nodes (map (fn [row] diff --git a/deps/publish/src/logseq/publish/routes.cljs b/deps/publish/src/logseq/publish/routes.cljs index c781744c9d..88f8138614 100644 --- a/deps/publish/src/logseq/publish/routes.cljs +++ b/deps/publish/src/logseq/publish/routes.cljs @@ -26,14 +26,19 @@ (defn- fetch-page-password-hash [graph-uuid page-uuid env] - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/password") - #js {:method "GET"})] - (when (.-ok resp) - (p/let [data (.json resp)] - (aget data "password_hash"))))) + ;; NOTE: DO stubs from (.get do-ns do-id) are thenable under the + ;; new cloudflare:workers DurableObject RPC runtime. Binding them + ;; in (p/let) causes promesa to await them, triggering + ;; structured-clone of the stub (DataCloneError on RpcPromise). + ;; Keep stub acquisition in a sync (let). + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/password") + #js {:method "GET"})] + (when (.-ok resp) + (p/let [data (.json resp)] + (aget data "password_hash")))))) (defn- check-page-password [request graph-uuid page-uuid env] @@ -57,118 +62,133 @@ {:claims claims})) (defn handle-post-pages [request env] - (p/let [auth-header (.get (.-headers request) "authorization") - token (when (and auth-header (string/starts-with? auth-header "Bearer ")) - (subs auth-header 7)) - claims (cond - (nil? token) nil - :else (authorization/verify-jwt token env))] - (if (nil? claims) - (publish-common/unauthorized) - (p/let [body (.arrayBuffer request)] - (let [{:keys [content_hash content_length graph page_uuid schema_version block_count created_at] :as meta} - (or (publish-common/parse-meta-header request) - (publish-common/meta-from-body body)) - payload (publish-common/read-transit-safe (.decode publish-common/text-decoder body)) - payload-entities (publish-model/datoms->entities (:datoms payload)) - page-eid (some (fn [[e entity]] - (when (= (:block/uuid entity) (uuid page_uuid)) - e)) - payload-entities) - page-title (or (:page-title payload) - (get payload "page-title") - (when page-eid - (publish-model/entity->title (get payload-entities page-eid)))) - blocks (or (:blocks payload) - (get payload "blocks")) - page-password (or (:page-password payload) - (get payload "page-password")) - refs (when (and page-eid page-title) - (publish-index/page-refs-from-payload payload page-eid page_uuid page-title graph)) - tagged-nodes (when (and page-eid page-title) - (publish-index/page-tagged-nodes-from-payload payload page-eid page_uuid page-title graph))] - (cond - (not (publish-common/valid-meta? meta)) - (publish-common/bad-request "missing publish metadata") + (-> (p/let [auth-header (.get (.-headers request) "authorization") + token (when (and auth-header (string/starts-with? auth-header "Bearer ")) + (subs auth-header 7)) + claims (cond + (nil? token) nil + :else (authorization/verify-jwt token env))] + (if (nil? claims) + (publish-common/unauthorized) + (p/let [body (.arrayBuffer request)] + (let [{:keys [content_hash content_length graph page_uuid schema_version block_count created_at] :as meta} + (or (publish-common/parse-meta-header request) + (publish-common/meta-from-body body)) + payload (publish-common/read-transit-safe (.decode publish-common/text-decoder body)) + payload-entities (publish-model/datoms->entities (:datoms payload)) + page-eid (some (fn [[e entity]] + (when (= (:block/uuid entity) (uuid page_uuid)) + e)) + payload-entities) + page-title (or (:page-title payload) + (get payload "page-title") + (when page-eid + (publish-model/entity->title (get payload-entities page-eid)))) + blocks (or (:blocks payload) + (get payload "blocks")) + page-password (or (:page-password payload) + (get payload "page-password")) + refs (when (and page-eid page-title) + (publish-index/page-refs-from-payload payload page-eid page_uuid page-title graph)) + tagged-nodes (when (and page-eid page-title) + (publish-index/page-tagged-nodes-from-payload payload page-eid page_uuid page-title graph))] + (cond + (not (publish-common/valid-meta? meta)) + (publish-common/bad-request "missing publish metadata") - :else - (p/let [graph-uuid graph - r2-key (str "publish/" graph-uuid "/" - content_hash ".transit") - r2 (aget env "PUBLISH_R2") - existing (.head r2 r2-key) - _ (when-not existing - (.put r2 r2-key body - #js {:httpMetadata #js {:contentType "application/transit+json"}})) - ^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns - (str graph-uuid - ":" - page_uuid)) - do-stub (.get do-ns do-id) - page-tags (or (:page-tags payload) - (get payload "page-tags")) - short-id (publish-common/short-id-for-page graph-uuid page_uuid) - owner-sub (:owner_sub meta) - owner-username (:owner_username meta) - updated-at (.now js/Date) - _ (when-not (and owner-sub owner-username) - (throw (ex-info "owner sub or username is missing" - {:owner-sub owner-sub - :owner-username owner-username}))) - password-hash (when (and (string? page-password) - (not (string/blank? page-password))) - (publish-common/hash-password page-password)) - payload (bean/->js - {:page_uuid page_uuid - :page_title page-title - :page_tags (when page-tags - (js/JSON.stringify (clj->js page-tags))) - :password_hash password-hash - :graph graph-uuid - :schema_version schema_version - :block_count block_count - :content_hash content_hash - :content_length content_length - :r2_key r2-key - :owner_sub owner-sub - :owner_username owner-username - :created_at created_at - :updated_at updated-at - :short_id short-id - :refs refs - :tagged_nodes tagged-nodes - :blocks (when (seq blocks) - (map (fn [block] - (assoc block :updated_at updated-at)) - blocks))}) - meta-resp (.fetch do-stub "https://publish/pages" - #js {:method "POST" - :headers #js {"content-type" "application/json"} - :body (js/JSON.stringify payload)})] - (if-not (.-ok meta-resp) - (publish-common/json-response {:error "metadata store failed"} 500) - (p/let [index-id (.idFromName do-ns "index") - index-stub (.get do-ns index-id) - _ (.fetch index-stub "https://publish/pages" - #js {:method "POST" - :headers #js {"content-type" "application/json"} - :body (js/JSON.stringify payload)})] - (publish-common/json-response {:page_uuid page_uuid - :graph_uuid graph-uuid - :r2_key r2-key - :short_id short-id - :short_url (str "/p/" short-id) - :updated_at (.now js/Date)})))))))))) + :else + ;; NOTE: DO stubs from (.get do-ns do-id) are thenable under the + ;; new cloudflare:workers DurableObject RPC runtime. Binding them + ;; in a (p/let) causes promesa to await them, which triggers + ;; structured-clone of the stub and crashes with + ;; DataCloneError: Could not serialize object of type "RpcPromise". + ;; Keep stubs in a sync (let) so promesa never awaits them. + (let [graph-uuid graph + r2-key (str "publish/" graph-uuid "/" + content_hash ".transit") + r2 (aget env "PUBLISH_R2") + ^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns + (str graph-uuid + ":" + page_uuid)) + do-stub (.get do-ns do-id) + page-tags (or (:page-tags payload) + (get payload "page-tags")) + owner-sub (:owner_sub meta) + owner-username (:owner_username meta) + updated-at (.now js/Date)] + (when-not (and owner-sub owner-username) + (throw (ex-info "owner sub or username is missing" + {:owner-sub owner-sub + :owner-username owner-username}))) + (p/let [existing (.head r2 r2-key) + _ (when-not existing + (.put r2 r2-key body + #js {:httpMetadata #js {:contentType "application/transit+json"}})) + short-id (publish-common/short-id-for-page graph-uuid page_uuid) + password-hash (when (and (string? page-password) + (not (string/blank? page-password))) + (publish-common/hash-password page-password))] + (let [payload (bean/->js + {:page_uuid page_uuid + :page_title page-title + :page_tags (when page-tags + (js/JSON.stringify (clj->js page-tags))) + :password_hash password-hash + :graph graph-uuid + :schema_version schema_version + :block_count block_count + :content_hash content_hash + :content_length content_length + :r2_key r2-key + :owner_sub owner-sub + :owner_username owner-username + :created_at created_at + :updated_at updated-at + :short_id short-id + :refs refs + :tagged_nodes tagged-nodes + :blocks (when (seq blocks) + (map (fn [block] + (assoc block :updated_at updated-at)) + blocks))}) + body-str (js/JSON.stringify payload)] + (p/let [meta-resp (.fetch do-stub "https://publish/pages" + #js {:method "POST" + :headers #js {"content-type" "application/json"} + :body body-str})] + (if-not (.-ok meta-resp) + (publish-common/json-response {:error "metadata store failed"} 500) + (let [index-id (.idFromName do-ns "index") + index-stub (.get do-ns index-id)] + (p/let [_ (.fetch index-stub "https://publish/pages" + #js {:method "POST" + :headers #js {"content-type" "application/json"} + :body body-str})] + (publish-common/json-response {:page_uuid page_uuid + :graph_uuid graph-uuid + :r2_key r2-key + :short_id short-id + :short_url (str "/p/" short-id) + :updated_at (.now js/Date)}))))))))))))) + (p/catch (fn [^js err] + (js/console.error "publish:post-pages error" + (some-> err .-name) + (some-> err .-message) + (some-> err .-stack)) + (publish-common/json-response + {:error "internal"} + 500))))) (defn handle-tag-page-html [graph-uuid tag-uuid env] (if (or (nil? graph-uuid) (nil? tag-uuid)) (publish-common/bad-request "missing graph uuid or tag uuid") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" tag-uuid "/tagged_nodes") - #js {:method "GET"})] + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" tag-uuid "/tagged_nodes") + #js {:method "GET"})] (if-not (.-ok tags-resp) (publish-common/not-found) (p/let [raw (.json tags-resp) @@ -184,7 +204,7 @@ (publish-render/render-tag-html graph-uuid tag-uuid tag-title tag-items) #js {:headers (publish-common/merge-headers #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))})))))) + (publish-common/cors-headers))}))))))) (defn handle-get-page [request env] (let [url (js/URL. (.-url request)) @@ -193,24 +213,24 @@ page-uuid (nth parts 3 nil)] (if (or (nil? graph-uuid) (nil? page-uuid)) (publish-common/bad-request "missing graph uuid or page uuid") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) - do-stub (.get do-ns do-id) - meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))] - (if-not (.-ok meta-resp) - (handle-tag-page-html graph-uuid page-uuid env) - (p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] - (if-not allowed? - (publish-common/json-response {:error "password required"} 401) - (p/let [meta (.json meta-resp) - etag (aget meta "content_hash") - if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))] - (if (and etag if-none-match (= etag if-none-match)) - (js/Response. nil #js {:status 304 - :headers (publish-common/merge-headers - #js {:etag etag} - (publish-common/cors-headers))}) - (publish-common/json-response (js->clj meta :keywordize-keys true) 200)))))))))) + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) + do-stub (.get do-ns do-id)] + (p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))] + (if-not (.-ok meta-resp) + (handle-tag-page-html graph-uuid page-uuid env) + (p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (publish-common/json-response {:error "password required"} 401) + (p/let [meta (.json meta-resp) + etag (aget meta "content_hash") + if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match"))] + (if (and etag if-none-match (= etag if-none-match)) + (js/Response. nil #js {:status 304 + :headers (publish-common/merge-headers + #js {:etag etag} + (publish-common/cors-headers))}) + (publish-common/json-response (js->clj meta :keywordize-keys true) 200))))))))))) (defn handle-get-page-transit [request env] (let [url (js/URL. (.-url request)) @@ -219,36 +239,36 @@ page-uuid (nth parts 3 nil)] (if (or (nil? graph-uuid) (nil? page-uuid)) (publish-common/bad-request "missing graph uuid or page uuid") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) - do-stub (.get do-ns do-id) - meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))] - (if-not (.-ok meta-resp) - (js/Response. - (publish-render/render-404-html) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}) - (p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] - (if-not allowed? - (publish-common/json-response {:error "password required"} 401) - (p/let [meta (.json meta-resp) - r2-key (aget meta "r2_key")] - (if-not r2-key - (publish-common/json-response {:error "missing transit"} 404) - (p/let [etag (aget meta "content_hash") - if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match")) - signed-url (when-not (and etag if-none-match (= etag if-none-match)) - (publish-common/presign-r2-url r2-key env))] - (if (and etag if-none-match (= etag if-none-match)) - (js/Response. nil #js {:status 304 - :headers (publish-common/merge-headers - #js {:etag etag} - (publish-common/cors-headers))}) - (publish-common/json-response {:url signed-url - :expires_in 300 - :etag etag} - 200)))))))))))) + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) + do-stub (.get do-ns do-id)] + (p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))] + (if-not (.-ok meta-resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (publish-common/json-response {:error "password required"} 401) + (p/let [meta (.json meta-resp) + r2-key (aget meta "r2_key")] + (if-not r2-key + (publish-common/json-response {:error "missing transit"} 404) + (p/let [etag (aget meta "content_hash") + if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match")) + signed-url (when-not (and etag if-none-match (= etag if-none-match)) + (publish-common/presign-r2-url r2-key env))] + (if (and etag if-none-match (= etag if-none-match)) + (js/Response. nil #js {:status 304 + :headers (publish-common/merge-headers + #js {:etag etag} + (publish-common/cors-headers))}) + (publish-common/json-response {:url signed-url + :expires_in 300 + :etag etag} + 200))))))))))))) (defn handle-get-page-refs [request env] (let [url (js/URL. (.-url request)) @@ -260,18 +280,18 @@ (p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] (if-not allowed? (publish-common/json-response {:error "password required"} 401) - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - refs-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs"))] - (if-not (.-ok refs-resp) - (js/Response. - (publish-render/render-404-html) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}) - (p/let [refs (.json refs-resp)] - (publish-common/json-response (js->clj refs :keywordize-keys true) 200))))))))) + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [refs-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs"))] + (if-not (.-ok refs-resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [refs (.json refs-resp)] + (publish-common/json-response (js->clj refs :keywordize-keys true) 200)))))))))) (defn handle-get-page-tagged-nodes [request env] (let [url (js/URL. (.-url request)) @@ -283,37 +303,20 @@ (p/let [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] (if-not allowed? (publish-common/json-response {:error "password required"} 401) - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes"))] - (if-not (.-ok tags-resp) - (publish-common/not-found) - (p/let [tags (.json tags-resp)] - (publish-common/json-response (js->clj tags :keywordize-keys true) 200))))))))) + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [tags-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes"))] + (if-not (.-ok tags-resp) + (publish-common/not-found) + (p/let [tags (.json tags-resp)] + (publish-common/json-response (js->clj tags :keywordize-keys true) 200)))))))))) (defn handle-list-pages [env] - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - meta-resp (.fetch do-stub "https://publish/pages" #js {:method "GET"})] - (if-not (.-ok meta-resp) - (js/Response. - (publish-render/render-404-html) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}) - (p/let [meta (.json meta-resp)] - (publish-common/json-response (js->clj meta :keywordize-keys true) 200))))) - -(defn handle-list-graph-pages-by-uuid [graph-uuid env] - (if-not graph-uuid - (publish-common/bad-request "missing graph uuid") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid) - #js {:method "GET"})] + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [meta-resp (.fetch do-stub "https://publish/pages" #js {:method "GET"})] (if-not (.-ok meta-resp) (js/Response. (publish-render/render-404-html) @@ -323,6 +326,23 @@ (p/let [meta (.json meta-resp)] (publish-common/json-response (js->clj meta :keywordize-keys true) 200)))))) +(defn handle-list-graph-pages-by-uuid [graph-uuid env] + (if-not graph-uuid + (publish-common/bad-request "missing graph uuid") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid) + #js {:method "GET"})] + (if-not (.-ok meta-resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [meta (.json meta-resp)] + (publish-common/json-response (js->clj meta :keywordize-keys true) 200))))))) + (defn handle-graph-search [request env] (let [url (js/URL. (.-url request)) parts (string/split (.-pathname url) #"/") @@ -330,111 +350,111 @@ query (.get (.-searchParams url) "q")] (if (or (string/blank? graph-uuid) (string/blank? query)) (publish-common/bad-request "missing graph uuid or query") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - resp (.fetch do-stub - (str "https://publish/search/" graph-uuid - "?q=" (js/encodeURIComponent query)) + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub + (str "https://publish/search/" graph-uuid + "?q=" (js/encodeURIComponent query)) + #js {:method "GET"})] + (if-not (.-ok resp) + (publish-common/not-found) + (p/let [data (.json resp)] + (publish-common/json-response (js->clj data :keywordize-keys true) 200)))))))) + +(defn handle-graph-html [graph-uuid env] + (if-not graph-uuid + (publish-common/bad-request "missing graph uuid") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid) + #js {:method "GET"})] + (if-not (.-ok meta-resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [meta (.json meta-resp) + pages (or (aget meta "pages") #js [])] + (js/Response. + (publish-render/render-graph-html graph-uuid pages) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))))))) + +(defn handle-tag-name-json [tag-name env] + (if-not tag-name + (publish-common/bad-request "missing tag name") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/tag/" (js/encodeURIComponent tag-name)) + #js {:method "GET"})] + (if-not (.-ok resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [data (.json resp)] + (publish-common/json-response (js->clj data :keywordize-keys true) 200))))))) + +(defn handle-tag-name-html [tag-name env] + (if-not tag-name + (publish-common/bad-request "missing tag name") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/tag/" (js/encodeURIComponent tag-name)) + #js {:method "GET"})] + (if-not (.-ok resp) + (js/Response. + (publish-render/render-404-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [data (.json resp) + rows (or (aget data "tagged_nodes") #js []) + title (or tag-name "Tag")] + (js/Response. + (publish-render/render-tag-name-html tag-name title rows) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))))))) + +(defn handle-ref-name-json [ref-name env] + (if-not ref-name + (publish-common/bad-request "missing ref name") + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/ref/" (js/encodeURIComponent ref-name)) #js {:method "GET"})] (if-not (.-ok resp) (publish-common/not-found) (p/let [data (.json resp)] (publish-common/json-response (js->clj data :keywordize-keys true) 200))))))) -(defn handle-graph-html [graph-uuid env] - (if-not graph-uuid - (publish-common/bad-request "missing graph uuid") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid) - #js {:method "GET"})] - (if-not (.-ok meta-resp) - (js/Response. - (publish-render/render-404-html) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}) - (p/let [meta (.json meta-resp) - pages (or (aget meta "pages") #js [])] - (js/Response. - (publish-render/render-graph-html graph-uuid pages) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))})))))) - -(defn handle-tag-name-json [tag-name env] - (if-not tag-name - (publish-common/bad-request "missing tag name") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - resp (.fetch do-stub (str "https://publish/tag/" (js/encodeURIComponent tag-name)) - #js {:method "GET"})] - (if-not (.-ok resp) - (js/Response. - (publish-render/render-404-html) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}) - (p/let [data (.json resp)] - (publish-common/json-response (js->clj data :keywordize-keys true) 200)))))) - -(defn handle-tag-name-html [tag-name env] - (if-not tag-name - (publish-common/bad-request "missing tag name") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - resp (.fetch do-stub (str "https://publish/tag/" (js/encodeURIComponent tag-name)) - #js {:method "GET"})] - (if-not (.-ok resp) - (js/Response. - (publish-render/render-404-html) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}) - (p/let [data (.json resp) - rows (or (aget data "tagged_nodes") #js []) - title (or tag-name "Tag")] - (js/Response. - (publish-render/render-tag-name-html tag-name title rows) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))})))))) - -(defn handle-ref-name-json [ref-name env] - (if-not ref-name - (publish-common/bad-request "missing ref name") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - resp (.fetch do-stub (str "https://publish/ref/" (js/encodeURIComponent ref-name)) - #js {:method "GET"})] - (if-not (.-ok resp) - (publish-common/not-found) - (p/let [data (.json resp)] - (publish-common/json-response (js->clj data :keywordize-keys true) 200)))))) - (defn handle-ref-name-html [ref-name env] (if-not ref-name (publish-common/bad-request "missing ref name") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - resp (.fetch do-stub (str "https://publish/ref/" (js/encodeURIComponent ref-name)) - #js {:method "GET"})] - (if-not (.-ok resp) - (publish-common/not-found) - (p/let [data (.json resp) - rows (or (aget data "pages") #js []) - title (or ref-name "Reference")] - (js/Response. - (publish-render/render-ref-html "all" ref-name title rows) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))})))))) + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/ref/" (js/encodeURIComponent ref-name)) + #js {:method "GET"})] + (if-not (.-ok resp) + (publish-common/not-found) + (p/let [data (.json resp) + rows (or (aget data "pages") #js []) + title (or ref-name "Reference")] + (js/Response. + (publish-render/render-ref-html "all" ref-name title rows) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))))))) (defn handle-list-graph-pages [request env] (let [url (js/URL. (.-url request)) @@ -452,28 +472,28 @@ (p/let [{:keys [claims]} (auth-claims request env)] (if (nil? claims) (publish-common/unauthorized) - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - page-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) - page-stub (.get do-ns page-id) - index-id (.idFromName do-ns "index") - index-stub (.get do-ns index-id) - meta-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) - #js {:method "GET"})] - (if-not (.-ok meta-resp) - (publish-common/not-found) - (p/let [meta (.json meta-resp) - owner-sub (aget meta "owner_sub") - subject (aget claims "sub")] - (if (or (string/blank? owner-sub) - (not= owner-sub subject)) - (publish-common/forbidden) - (p/let [page-resp (.fetch page-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) - #js {:method "DELETE"}) - index-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) - #js {:method "DELETE"})] - (if (or (not (.-ok page-resp)) (not (.-ok index-resp))) - (publish-common/not-found) - (publish-common/json-response {:ok true} 200)))))))))))) + (let [^js do-ns (aget env "PUBLISH_META_DO") + page-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) + page-stub (.get do-ns page-id) + index-id (.idFromName do-ns "index") + index-stub (.get do-ns index-id)] + (p/let [meta-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) + #js {:method "GET"})] + (if-not (.-ok meta-resp) + (publish-common/not-found) + (p/let [meta (.json meta-resp) + owner-sub (aget meta "owner_sub") + subject (aget claims "sub")] + (if (or (string/blank? owner-sub) + (not= owner-sub subject)) + (publish-common/forbidden) + (p/let [page-resp (.fetch page-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) + #js {:method "DELETE"}) + index-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) + #js {:method "DELETE"})] + (if (or (not (.-ok page-resp)) (not (.-ok index-resp))) + (publish-common/not-found) + (publish-common/json-response {:ok true} 200))))))))))))) (defn handle-delete-graph [request env] (let [url (js/URL. (.-url request)) @@ -484,36 +504,36 @@ (p/let [{:keys [claims]} (auth-claims request env)] (if (nil? claims) (publish-common/unauthorized) - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - index-id (.idFromName do-ns "index") - index-stub (.get do-ns index-id) - list-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid) - #js {:method "GET"})] - (if-not (.-ok list-resp) - (publish-common/not-found) - (p/let [data (.json list-resp) - pages (or (aget data "pages") #js []) - subject (aget claims "sub") - owner-mismatch? (some (fn [page] - (let [owner-sub (aget page "owner_sub")] - (or (string/blank? owner-sub) - (not= owner-sub subject)))) - (array-seq pages))] - (if owner-mismatch? - (publish-common/forbidden) - (p/let [_ (js/Promise.all - (map (fn [page] - (let [page-uuid (aget page "page_uuid") - page-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) - page-stub (.get do-ns page-id)] - (.fetch page-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) - #js {:method "DELETE"}))) - pages)) - del-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid) - #js {:method "DELETE"})] - (if-not (.-ok del-resp) - (publish-common/not-found) - (publish-common/json-response {:ok true} 200)))))))))))) + (let [^js do-ns (aget env "PUBLISH_META_DO") + index-id (.idFromName do-ns "index") + index-stub (.get do-ns index-id)] + (p/let [list-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid) + #js {:method "GET"})] + (if-not (.-ok list-resp) + (publish-common/not-found) + (p/let [data (.json list-resp) + pages (or (aget data "pages") #js []) + subject (aget claims "sub") + owner-mismatch? (some (fn [page] + (let [owner-sub (aget page "owner_sub")] + (or (string/blank? owner-sub) + (not= owner-sub subject)))) + (array-seq pages))] + (if owner-mismatch? + (publish-common/forbidden) + (p/let [_ (js/Promise.all + (map (fn [page] + (let [page-uuid (aget page "page_uuid") + page-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) + page-stub (.get do-ns page-id)] + (.fetch page-stub (str "https://publish/pages/" graph-uuid "/" page-uuid) + #js {:method "DELETE"}))) + pages)) + del-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid) + #js {:method "DELETE"})] + (if-not (.-ok del-resp) + (publish-common/not-found) + (publish-common/json-response {:ok true} 200))))))))))))) (defn handle-page-html [request env] (let [url (js/URL. (.-url request)) @@ -522,85 +542,83 @@ page-uuid (nth parts 3 nil)] (if (or (nil? graph-uuid) (nil? page-uuid)) (publish-common/bad-request "missing graph uuid or page uuid") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) - do-stub (.get do-ns do-id) - meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))] - (if-not (.-ok meta-resp) - (p/let [index-id (.idFromName do-ns "index") - index-stub (.get do-ns index-id) - tags-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes") - #js {:method "GET"})] - (if (and tags-resp (.-ok tags-resp)) - (p/let [raw (.json tags-resp) - tag-items (js->clj (or (aget raw "tagged_nodes") #js []) - :keywordize-keys true) - tag-title (or (some (fn [item] - (let [title (publish-render/tag-item-val item :tag_title)] - (when (and title (not (string/blank? title))) - title))) - tag-items) - page-uuid)] - (if (seq tag-items) - (js/Response. - (publish-render/render-tag-html graph-uuid page-uuid tag-title tag-items) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}) - (js/Response. - (publish-render/render-not-published-html graph-uuid) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}))) - (js/Response. - (publish-render/render-not-published-html graph-uuid) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}))) - (p/let [{:keys [allowed? provided?]} (check-page-password request graph-uuid page-uuid env)] - (if-not allowed? - (js/Response. - (publish-render/render-password-html graph-uuid page-uuid provided?) - #js {:status 401 - :headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}) - (p/let [meta (.json meta-resp) - etag (aget meta "content_hash") - if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match")) - index-id (.idFromName do-ns "index") - index-stub (.get do-ns index-id) - refs-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs")) - refs-json (when (and refs-resp (.-ok refs-resp)) - (p/let [raw (.json refs-resp)] - (js->clj raw :keywordize-keys false))) - tags-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes") - #js {:method "GET"}) - tagged-nodes (when (and tags-resp (.-ok tags-resp)) - (p/let [raw (.json tags-resp)] - (js->clj (or (aget raw "tagged_nodes") #js []) - :keywordize-keys true))) - r2 (aget env "PUBLISH_R2") - object (.get r2 (aget meta "r2_key"))] - (if (and etag if-none-match (= etag if-none-match)) - (js/Response. nil #js {:status 304 - :headers (publish-common/merge-headers - #js {:etag etag - "cache-control" "public, max-age=300, must-revalidate"} - (publish-common/cors-headers))}) - (if-not object - (publish-common/json-response {:error "missing transit blob"} 404) - (p/let [buffer (.arrayBuffer object) - transit (.decode publish-common/text-decoder buffer)] - (let [headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8" - "cache-control" "public, max-age=300, must-revalidate"} - (publish-common/cors-headers))] - (when etag - (.set headers "etag" etag)) - (js/Response. - (publish-render/render-page-html transit page-uuid refs-json tagged-nodes) - #js {:headers headers}))))))))))))) + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns (str graph-uuid ":" page-uuid)) + do-stub (.get do-ns do-id) + index-id (.idFromName do-ns "index") + index-stub (.get do-ns index-id)] + (p/let [meta-resp (.fetch do-stub (str "https://publish/pages/" graph-uuid "/" page-uuid))] + (if-not (.-ok meta-resp) + (p/let [tags-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes") + #js {:method "GET"})] + (if (and tags-resp (.-ok tags-resp)) + (p/let [raw (.json tags-resp) + tag-items (js->clj (or (aget raw "tagged_nodes") #js []) + :keywordize-keys true) + tag-title (or (some (fn [item] + (let [title (publish-render/tag-item-val item :tag_title)] + (when (and title (not (string/blank? title))) + title))) + tag-items) + page-uuid)] + (if (seq tag-items) + (js/Response. + (publish-render/render-tag-html graph-uuid page-uuid tag-title tag-items) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (js/Response. + (publish-render/render-not-published-html graph-uuid) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))) + (js/Response. + (publish-render/render-not-published-html graph-uuid) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))) + (p/let [{:keys [allowed? provided?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (js/Response. + (publish-render/render-password-html graph-uuid page-uuid provided?) + #js {:status 401 + :headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}) + (p/let [meta (.json meta-resp) + etag (aget meta "content_hash") + if-none-match (publish-common/normalize-etag (.get (.-headers request) "if-none-match")) + refs-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/refs")) + refs-json (when (and refs-resp (.-ok refs-resp)) + (p/let [raw (.json refs-resp)] + (js->clj raw :keywordize-keys false))) + tags-resp (.fetch index-stub (str "https://publish/pages/" graph-uuid "/" page-uuid "/tagged_nodes") + #js {:method "GET"}) + tagged-nodes (when (and tags-resp (.-ok tags-resp)) + (p/let [raw (.json tags-resp)] + (js->clj (or (aget raw "tagged_nodes") #js []) + :keywordize-keys true))) + r2 (aget env "PUBLISH_R2") + object (.get r2 (aget meta "r2_key"))] + (if (and etag if-none-match (= etag if-none-match)) + (js/Response. nil #js {:status 304 + :headers (publish-common/merge-headers + #js {:etag etag + "cache-control" "public, max-age=300, must-revalidate"} + (publish-common/cors-headers))}) + (if-not object + (publish-common/json-response {:error "missing transit blob"} 404) + (p/let [buffer (.arrayBuffer object) + transit (.decode publish-common/text-decoder buffer)] + (let [headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8" + "cache-control" "public, max-age=300, must-revalidate"} + (publish-common/cors-headers))] + (when etag + (.set headers "etag" etag)) + (js/Response. + (publish-render/render-page-html transit page-uuid refs-json tagged-nodes) + #js {:headers headers})))))))))))))) (defn ^:large-vars/cleanup-todo handle-fetch [request env] (let [url (js/URL. (.-url request)) @@ -713,45 +731,45 @@ short-id (nth parts 2 nil)] (if (string/blank? short-id) (publish-common/bad-request "missing short id") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id) - resp (.fetch do-stub (str "https://publish/short/" short-id) - #js {:method "GET"})] - (if-not (.-ok resp) - (publish-common/not-found) - (p/let [data (.json resp) - row (aget data "page")] - (if-not row - (publish-common/not-found) - (let [graph-uuid (aget row "graph_uuid") - page-uuid (aget row "page_uuid") - location (str "/page/" graph-uuid "/" page-uuid)] - (js/Response. nil #js {:status 302 - :headers (publish-common/merge-headers - #js {"location" location} - (publish-common/cors-headers))})))))))) + (let [^js do-ns (aget env "PUBLISH_META_DO") + do-id (.idFromName do-ns "index") + do-stub (.get do-ns do-id)] + (p/let [resp (.fetch do-stub (str "https://publish/short/" short-id) + #js {:method "GET"})] + (if-not (.-ok resp) + (publish-common/not-found) + (p/let [data (.json resp) + row (aget data "page")] + (if-not row + (publish-common/not-found) + (let [graph-uuid (aget row "graph_uuid") + page-uuid (aget row "page_uuid") + location (str "/page/" graph-uuid "/" page-uuid)] + (js/Response. nil #js {:status 302 + :headers (publish-common/merge-headers + #js {"location" location} + (publish-common/cors-headers))}))))))))) (and (string/starts-with? path "/u/") (= method "GET")) (let [parts (string/split path #"/") username (nth parts 2 nil)] (if (string/blank? username) (publish-common/bad-request "missing username") - (p/let [^js do-ns (aget env "PUBLISH_META_DO") - index-id (.idFromName do-ns "index") - index-stub (.get do-ns index-id) - resp (.fetch index-stub (str "https://publish/user/" username) - #js {:method "GET"})] - (if-not (.-ok resp) - (publish-common/not-found) - (p/let [data (.json resp) - user (aget data "user") - rows (or (aget data "pages") #js [])] - (js/Response. - (publish-render/render-user-html username user rows) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))})))))) + (let [^js do-ns (aget env "PUBLISH_META_DO") + index-id (.idFromName do-ns "index") + index-stub (.get do-ns index-id)] + (p/let [resp (.fetch index-stub (str "https://publish/user/" username) + #js {:method "GET"})] + (if-not (.-ok resp) + (publish-common/not-found) + (p/let [data (.json resp) + user (aget data "user") + rows (or (aget data "pages") #js [])] + (js/Response. + (publish-render/render-user-html username user rows) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8"} + (publish-common/cors-headers))}))))))) (and (string/starts-with? path "/pages/") (= method "GET")) (let [parts (string/split path #"/")] diff --git a/libs/SKILL.md b/libs/SKILL.md new file mode 100644 index 0000000000..cdbb076f0a --- /dev/null +++ b/libs/SKILL.md @@ -0,0 +1,133 @@ +--- +name: logseq-plugin-sdk +description: Build, debug, or review Logseq plugins with the `@logseq/libs` SDK (TypeScript/JavaScript, iframe/shadow sandboxed). Use when the task involves writing plugin entry code, registering slash/command/UI items, provideUI/provideStyle/provideModel, settings schema, macro renderers, DB-graph properties & tags, Datascript/DSL queries, experimental APIs, theme plugins, or the `logseq/*` CLJS facade generated under this package. +--- + +# Logseq Plugin SDK Skill + +This skill governs work inside `libs/` — the source of the npm package [`@logseq/libs`](./package.json) and its CLJS facade under [`cljs-sdk/`](./cljs-sdk). Use it whenever the user is authoring, upgrading, or debugging a Logseq plugin, or extending the SDK itself. + +## When to use + +Trigger this skill when the task mentions any of: + +- `@logseq/libs`, `logseq.App`, `logseq.Editor`, `logseq.DB`, `logseq.UI`, `logseq.Assets`, `logseq.Git`, `logseq.Experiments` +- `provideUI` / `provideStyle` / `provideModel` / `useSettingsSchema` / `onMacroRendererSlotted` +- `registerSlashCommand`, `registerBlockContextMenuItem`, `registerCommandPalette`, `registerUIItem` +- Plugin `package.json` `logseq` block, themes, `effect` plugins, iframe/shadow sandbox +- DB-graph properties, tags/classes, property idents (`:logseq.property/*`, `:plugin.property./*`) +- Datascript / DSL queries through `logseq.DB.q` / `logseq.DB.datascriptQuery` +- Regenerating the CLJS SDK (`yarn run generate:schema`, `bb libs:generate-cljs-sdk`) + +If the user is editing core Logseq app code (not a plugin), prefer the repo-root `AGENTS.md` instead. + +## Golden rules + +1. **Always `await logseq.ready(main)`** before touching any API. Most SDK calls are async RPC over postMessage. +2. **Detect graph mode** before using DB-only APIs: `await logseq.App.checkCurrentIsDbGraph()`. `IBatchBlock.properties` is **not** supported for DB graphs — use `Editor.upsertBlockProperty` / `upsertProperty` instead. +3. **Clean up listeners** in `logseq.beforeunload` (collect the `off` functions returned by every `onXxx` hook). +4. **Batch mutations** (`Editor.insertBatchBlock`) and **debounce** `DB.onChanged` / `onBlockChanged` handlers — they fire on every keystroke. +5. **Prefer CSS variables** (`--ls-primary-text-color`, `--ls-primary-background-color`, `--ls-border-color`, …) over hard-coded colors so plugins follow the active theme. +6. **Unique plugin id** in `package.json > logseq.id`; keep it lowercase-kebab. `main`/`entry` must point at a built HTML file. +7. **Experimental APIs (`logseq.Experiments.*`) are unstable** — only use when no stable API exists and document the reason. +8. **Idents are identity.** For built-in or cross-graph stable references, use idents (`:logseq.property/created-at`, `:plugin.property./`) instead of display names. + +## Canonical plugin skeleton + +```ts +import '@logseq/libs' + +const offHooks: Array<() => void> = [] + +async function main() { + logseq.useSettingsSchema([ + { key: 'enabled', type: 'boolean', default: true, title: 'Enabled', description: '' }, + ]) + + logseq.Editor.registerSlashCommand('My Command', async () => { + await logseq.Editor.insertAtEditingCursor('Hello from my plugin!') + }) + + offHooks.push( + logseq.DB.onChanged(({ blocks }) => { + // debounce in real code + }), + ) + + logseq.beforeunload(async () => { + offHooks.forEach((off) => off()) + }) +} + +logseq.ready(main).catch(console.error) +``` + +## Workflow + +1. **Scope the request.** Is it a new plugin, a change to an existing plugin, SDK-internal work, or the CLJS facade? +2. **Load the right reference file(s)** from [`./guides/`](./guides) (see table below) before proposing code. +3. **For SDK-internal changes**, open the matching TypeScript under [`./src/`](./src) (`LSPlugin.ts` for types, `LSPlugin.user.ts` for the proxy implementation, `modules/` for Experiments/Storage/Request). +4. **For CLJS facade changes**, regenerate with: + ```bash + yarn run generate:schema # dist/logseq-sdk-schema.json + bb libs:generate-cljs-sdk # target/generated-cljs/logseq/*.cljs + ``` + Non-proxy methods land in `logseq.core`; each `IXxxProxy` gets its own namespace (`logseq.app`, `logseq.editor`, …). +5. **Validate.** Build the plugin (`npm run build` / `parcel build`) and load it via Settings → Developer mode → `t p` → *Load unpacked plugin*. Use DevTools (`Cmd+Shift+I`) and `logseq.UI.showMsg` for quick feedback. +6. **Respect the package.json rules** (see [`guides/AGENTS.md`](./guides/AGENTS.md) §Configuration Fields). + +## Reference map (`./guides/`) + +Load these on demand — do not dump their full contents unless needed: + +| File | Load when… | +|------|------------| +| [`guides/AGENTS.md`](./guides/AGENTS.md) | Authoritative overview of SDK namespaces, `package.json > logseq` schema, theme plugins, UI injection, macro renderers, lifecycle. Start here for most plugin tasks. | +| [`guides/custom_theme_guide.md`](./guides/custom_theme_guide.md) | Building or reviewing Logseq theme plugins, custom theme CSS, `logseq.themes`, `provideTheme`, theme variables, light/dark mode styling, or UI selector/theme-token guidance. | +| [`guides/starter_guide.md`](./guides/starter_guide.md) | Bootstrapping a new plugin project (Node/TS toolchain, desktop dev-mode loading, hello-world). | +| [`guides/db_properties_guide.md`](./guides/db_properties_guide.md) | Conceptual model: file-graph vs DB-graph properties, schema vs values, tag/class modeling. | +| [`guides/db_properties_references.md`](./guides/db_properties_references.md) | API reference for `upsertProperty`, `upsertBlockProperty`, property schemas/types/cardinality. | +| [`guides/db_tag_property_idents_guide.md`](./guides/db_tag_property_idents_guide.md) | Ident naming rules (`:logseq.property/*`, `:logseq.class/*`, `:plugin.property./*`, `:plugin.class./*`) and when to use them. | +| [`guides/db_query_guide.md`](./guides/db_query_guide.md) | DSL (`logseq.DB.q`) vs Datascript (`logseq.DB.datascriptQuery`) queries, parameters, change watchers. | +| [`guides/experiments_api_guide.md`](./guides/experiments_api_guide.md) | `logseq.Experiments.*` — React/ReactDOM reuse, internal components, CLJS interop, custom fenced-code / route / sidebar / property / block-body renderers. | + +## Core API quick index + +Full code examples live in [`guides/AGENTS.md`](./guides/AGENTS.md) — use this table to jump to the right namespace: + +- `logseq.App` — info, graph, navigation, `registerUIItem`, `registerCommandPalette`, lifecycle hooks (`onCurrentGraphChanged`, `onThemeModeChanged`, `onRouteChanged`, `onMacroRendererSlotted`), `checkCurrentIsDbGraph`. +- `logseq.Editor` — slash & context-menu commands, block CRUD, `insertBatchBlock`, pages, cursor/selection, `upsertBlockProperty` / `getBlockProperties` (DB). +- `logseq.DB` — `q`, `datascriptQuery`, `onChanged`, `onBlockChanged`, `getFileContent` / `setFileContent`. +- `logseq.UI` — `showMsg`, `closeMsg`, `queryElementRect`, `queryElementById`. +- `logseq.Assets` — `listFilesOfCurrentGraph`, `makeSandboxStorage`, `makeUrl`, `builtInOpen`. +- `logseq.Git` — `execCommand`, `loadIgnoreFile`, `saveIgnoreFile` (**file graphs / desktop only**). +- `logseq.Experiments` — unstable; see the Experiments guide before using. +- Top-level — `provideUI`, `provideStyle`, `provideModel`, `useSettingsSchema`, `onSettingsChanged`, `updateSettings`, `showMainUI` / `hideMainUI` / `toggleMainUI` / `setMainUIInlineStyle`, `beforeunload`, `ready`. + +## Common pitfalls + +- Forgetting `await` — nearly every API is async. +- Using `IBatchBlock.properties` in a DB graph (silently ignored). +- Treating `block.content` as current — it is deprecated; use `block.title`. +- Registering the same `key` twice in `provideUI` / `provideStyle` without intending to replace. +- Hard-coding colors instead of `--ls-*` CSS variables. +- Leaking listeners (no cleanup in `beforeunload`). +- Shipping plugins without `logseq.id` or with a non-unique id. +- Assuming Git APIs exist on mobile / DB graphs. + +## When editing SDK source + +- Type definitions: [`src/LSPlugin.ts`](./src/LSPlugin.ts). Keep `IAppProxy`, `IEditorProxy`, `IDBProxy`, `IUIProxy`, `IAssetsProxy`, `IGitProxy`, `IExperimentsProxy` and the `ILSPluginUser` surface in sync. +- User proxy implementation: [`src/LSPlugin.user.ts`](./src/LSPlugin.user.ts). +- Modules: [`src/modules/`](./src/modules) (Experiments, Storage, Request). +- After changing the public surface, regenerate the CLJS facade (see Workflow step 4) and update [`CHANGELOG.md`](./CHANGELOG.md). +- Follow the repo commit style: short imperative subjects, optional scope (e.g. `enhance(libs): …`, `fix(libs): …`). + +## Resources + +- API docs: +- Samples: +- CLJS template: +- TS template: +- Discord: + diff --git a/libs/development-notes/experiments_api_guide.md b/libs/development-notes/experiments_api_guide.md deleted file mode 100644 index 72329db74f..0000000000 --- a/libs/development-notes/experiments_api_guide.md +++ /dev/null @@ -1,460 +0,0 @@ -# Logseq Experiments API Guide - -This guide covers the **experimental APIs** available in the Logseq Plugin SDK. These APIs provide advanced functionality for creating custom renderers, loading external scripts, and accessing internal utilities. - -> **⚠️ WARNING**: These are experimental features that may change at any time. Plugins using these APIs may not be supported on the Marketplace temporarily. - ---- - -## Overview - -The Experiments API is accessed via `logseq.Experiments` and provides: - -1. **React Integration** - Access to React and ReactDOM from the host -2. **Custom Renderers** - Register custom code block, route, and daemon renderers -3. **Component Access** - Access to internal Logseq components -4. **Utilities** - ClojureScript interop utilities (toClj, toJs, etc.) -5. **Script Loading** - Dynamic loading of external scripts -6. **Extension Enhancers** - Enhance libraries like KaTeX and CodeMirror - ---- - -## 1. React Integration - -Access React and ReactDOM from the Logseq host environment. - -### Properties - -#### `logseq.Experiments.React` - -Returns the React instance from the host scope. - -```typescript -const React = logseq.Experiments.React -``` - -#### `logseq.Experiments.ReactDOM` - -Returns the ReactDOM instance from the host scope. - -```typescript -const ReactDOM = logseq.Experiments.ReactDOM -``` - -### Example Usage - -```typescript -const React = logseq.Experiments.React -const ReactDOM = logseq.Experiments.ReactDOM - -// Use React to create components -const MyComponent = React.createElement('div', null, 'Hello from plugin!') -``` - ---- - -## 2. Components - -Access internal Logseq components for advanced UI integration. - -### `logseq.Experiments.Components.Editor` - -A page editor component that can render Logseq page content. - -**Type**: `(props: { page: string } & any) => any` - -**Parameters**: -- `page` (string): The page name to render - -```typescript -const Editor = logseq.Experiments.Components.Editor - -// Render a page editor -const editor = Editor({ page: 'My Page Name' }) -``` - ---- - -## 3. Utilities - -ClojureScript interop utilities for data conversion between JavaScript and ClojureScript. - -### `logseq.Experiments.Utils` - -Provides conversion utilities: - -#### `toClj(input: any)` - -Convert JavaScript data to ClojureScript data structures. - -```typescript -const cljData = logseq.Experiments.Utils.toClj({ key: 'value' }) -``` - -#### `jsxToClj(input: any)` - -Convert JSX/JavaScript objects to ClojureScript, preserving JSX structures. - -```typescript -const cljData = logseq.Experiments.Utils.jsxToClj(
Content
) -``` - -#### `toJs(input: any)` - -Convert ClojureScript data structures to JavaScript. - -```typescript -const jsData = logseq.Experiments.Utils.toJs(cljData) -``` - -#### `toKeyword(input: any)` - -Convert a string to a ClojureScript keyword. - -```typescript -const keyword = logseq.Experiments.Utils.toKeyword('my-key') -``` - -#### `toSymbol(input: any)` - -Convert a string to a ClojureScript symbol. - -```typescript -const symbol = logseq.Experiments.Utils.toSymbol('my-symbol') -``` - ---- - -## 4. Script Loading - -### `logseq.Experiments.loadScripts(...scripts: string[])` - -Dynamically load external scripts into the Logseq environment. - -**Parameters**: -- `scripts` (string[]): Array of script URLs or relative paths - -**Returns**: `Promise` - -**Behavior**: -- Relative paths are resolved using the plugin's resource path -- HTTP/HTTPS URLs are loaded directly -- Scripts are loaded in order - -```typescript -// Load external library -await logseq.Experiments.loadScripts( - 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js' -) - -// Load local script from plugin resources -await logseq.Experiments.loadScripts('./my-script.js') - -// Load multiple scripts -await logseq.Experiments.loadScripts( - 'https://cdn.example.com/lib1.js', - 'https://cdn.example.com/lib2.js', - './local-script.js' -) -``` - ---- - -## 5. Custom Renderers - -### 5.1 Fenced Code Renderer - -Register a custom renderer for code blocks with specific language tags. - -#### `logseq.Experiments.registerFencedCodeRenderer(lang: string, opts: object)` - -**Parameters**: -- `lang` (string): The language identifier for the code block (e.g., 'mermaid', 'chart') -- `opts` (object): - - `render` (function, required): Render function that receives props - - `edit` (boolean, optional): Whether the block is editable - - `before` (function, optional): Async function to run before rendering - - `subs` (string[], optional): Subscriptions to state changes - -**Render Props**: -- `content` (string): The content of the code block - -```typescript -// Register a custom code block renderer -logseq.Experiments.registerFencedCodeRenderer('my-chart', { - edit: false, - before: async () => { - // Load dependencies before rendering - await logseq.Experiments.loadScripts( - 'https://cdn.jsdelivr.net/npm/chart.js' - ) - }, - render: (props) => { - const React = logseq.Experiments.React - - return React.createElement('div', { - ref: (el) => { - if (el) { - // Parse content and render chart - const config = JSON.parse(props.content) - new Chart(el, config) - } - } - }) - } -}) -``` - -**Usage in Logseq**: -````markdown -```my-chart -{ - "type": "bar", - "data": { - "labels": ["A", "B", "C"], - "datasets": [{"data": [10, 20, 30]}] - } -} -``` -```` - -### 5.2 Daemon Renderer - -Register a renderer that runs continuously in the background (daemon). - -#### `logseq.Experiments.registerDaemonRenderer(key: string, opts: object)` - -**Parameters**: -- `key` (string): Unique identifier for the daemon renderer -- `opts` (object): - - `render` (function, required): Render function - - `sub` (string[], optional): Subscriptions to state changes - -```typescript -// Register a daemon renderer for persistent UI -logseq.Experiments.registerDaemonRenderer('my-status-bar', { - sub: ['ui/theme', 'ui/sidebar-open'], - render: (props) => { - const React = logseq.Experiments.React - - return React.createElement('div', { - style: { - position: 'fixed', - bottom: 0, - right: 0, - padding: '10px', - background: '#333', - color: '#fff' - } - }, 'Status: Active') - } -}) -``` - -### 5.3 Route Renderer - -Register a custom renderer for specific routes in Logseq. - -#### `logseq.Experiments.registerRouteRenderer(key: string, opts: object)` - -**Parameters**: -- `key` (string): Unique identifier for the route renderer -- `opts` (object): - - `path` (string, required): Route path (e.g., '/my-plugin-page') - - `render` (function, required): Render function - - `name` (string, optional): Display name for the route - - `subs` (string[], optional): Subscriptions to state changes - -```typescript -// Register a custom route -logseq.Experiments.registerRouteRenderer('my-custom-page', { - path: '/my-plugin-dashboard', - name: 'Dashboard', - subs: ['ui/theme'], - render: (props) => { - const React = logseq.Experiments.React - - return React.createElement('div', { - className: 'my-plugin-dashboard' - }, [ - React.createElement('h1', null, 'Plugin Dashboard'), - React.createElement('p', null, 'Custom content here') - ]) - } -}) - -// Navigate to the route -logseq.App.pushState('page', { name: 'my-plugin-dashboard' }) -``` - ---- - -## 6. Extension Enhancers - -Enhance external libraries that Logseq uses (like KaTeX for math rendering). - -### `logseq.Experiments.registerExtensionsEnhancer(type: string, enhancer: function)` - -**Parameters**: -- `type` ('katex' | 'codemirror'): The extension type to enhance -- `enhancer` (function): Async function that receives the library instance and can modify it - -**Returns**: `Promise` - -```typescript -// Enhance KaTeX with custom macros -logseq.Experiments.registerExtensionsEnhancer('katex', async (katex) => { - // Add custom KaTeX macros - katex.macros = { - ...katex.macros, - '\\RR': '\\mathbb{R}', - '\\NN': '\\mathbb{N}', - '\\ZZ': '\\mathbb{Z}' - } - - console.log('KaTeX enhanced with custom macros') -}) -``` - ---- - -## 7. Plugin Local Access - -### `logseq.Experiments.pluginLocal` - -Access the internal plugin instance (PluginLocal) for advanced operations. - -**Type**: `PluginLocal` - -```typescript -const pluginLocal = logseq.Experiments.pluginLocal - -// Access plugin-specific internal state -console.log('Plugin ID:', pluginLocal.id) -``` - ---- - -## 8. Advanced: Invoke Experimental Methods - -### `logseq.Experiments.invokeExperMethod(type: string, ...args: any[])` - -Directly invoke experimental methods from the host scope. - -**Parameters**: -- `type` (string): Method name (converted to snake_case) -- `...args`: Arguments to pass to the method - -**Returns**: `any` - -```typescript -// Invoke a custom experimental method -const result = logseq.Experiments.invokeExperMethod( - 'someExperimentalFeature', - arg1, - arg2 -) -``` - ---- - -## Complete Example: Custom Chart Renderer - -Here's a complete example combining multiple APIs: - -```typescript -import '@logseq/libs' - -async function main() { - console.log('Chart Plugin Loaded') - - // Register fenced code renderer for charts - logseq.Experiments.registerFencedCodeRenderer('chart', { - edit: false, - before: async () => { - // Load Chart.js before rendering - await logseq.Experiments.loadScripts( - 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js' - ) - }, - render: (props) => { - const React = logseq.Experiments.React - - return React.createElement('div', null, [ - React.createElement('canvas', { - ref: (canvas) => { - if (canvas && window.Chart) { - try { - const config = JSON.parse(props.content) - new window.Chart(canvas, config) - } catch (e) { - console.error('Chart rendering error:', e) - } - } - } - }) - ]) - } - }) -} - -logseq.ready(main).catch(console.error) -``` - -**Usage**: -````markdown -```chart -{ - "type": "line", - "data": { - "labels": ["Jan", "Feb", "Mar", "Apr"], - "datasets": [{ - "label": "Sales", - "data": [10, 20, 15, 30], - "borderColor": "rgb(75, 192, 192)" - }] - } -} -``` -```` - ---- - -## Best Practices - -1. **Check Host Scope**: Always ensure the host scope is accessible before using experimental APIs -2. **Error Handling**: Wrap experimental API calls in try-catch blocks -3. **Dependencies**: Load external scripts in `before` hooks to ensure they're ready -4. **Memory Management**: Clean up event listeners and subscriptions in daemon renderers -5. **Compatibility**: Test thoroughly as these APIs may change between Logseq versions -6. **Documentation**: Document which experimental APIs your plugin uses -7. **Marketplace**: Be aware that plugins using these APIs may not be accepted on the Marketplace - ---- - -## Limitations - -- **Experimental Status**: These APIs are not stable and may change without notice -- **Marketplace Support**: Plugins using experimental APIs may not be approved for the Marketplace -- **Security**: Be cautious when loading external scripts or accessing host scope -- **Performance**: Custom renderers can impact performance if not optimized -- **Compatibility**: Limited backwards compatibility guarantees - ---- - -## See Also - -- [Starter Guide](./starter_guide.md) - Getting started with plugin development -- [DB Properties Guide](./db_properties_guide.md) - Working with database properties -- [DB Query Guide](./db_query_guide.md) - Querying the Logseq database - ---- - -## Support - -For questions and issues: -- [Logseq Discord](https://discord.gg/logseq) - #plugin-dev channel -- [GitHub Discussions](https://github.com/logseq/logseq/discussions) -- [Plugin API Documentation](https://plugins-doc.logseq.com/) - -Remember: These are experimental features. Use at your own risk and always test thoroughly! diff --git a/libs/development-notes/AGENTS.md b/libs/guides/AGENTS.md similarity index 100% rename from libs/development-notes/AGENTS.md rename to libs/guides/AGENTS.md diff --git a/libs/guides/custom_theme_guide.md b/libs/guides/custom_theme_guide.md new file mode 100644 index 0000000000..d35fe54321 --- /dev/null +++ b/libs/guides/custom_theme_guide.md @@ -0,0 +1,595 @@ +# Custom Theme Plugin Guide + +This guide summarizes Logseq's current UI theme architecture and gives a practical workflow for building a Logseq **theme plugin** with `@logseq/libs`. + +It is based on the current repository structure and these theme-related entry points: + +- CSS load order: `tailwind.all.css` +- Design tokens and theme variables: `packages/ui/src/radix.css`, `packages/ui/src/radix-hsl.css`, `packages/ui/src/vars-classic.css`, `packages/ui/src/colors.css` +- Shared shui/Radix component CSS: `resources/css/shui.css` +- CodeMirror theme bridge: `resources/css/codemirror.lsradix.css` +- Frontend component CSS: `src/main/frontend/**/*.css` +- Theme runtime attributes: `src/main/frontend/components/theme.cljs`, `src/main/frontend/state.cljs`, `src/main/frontend/ui.cljs` +- Plugin theme registration/injection: `libs/src/LSPlugin.ts`, `libs/src/LSPlugin.core.ts`, `libs/src/LSPlugin.user.ts`, `libs/src/common.ts` + +## Mental model + +Logseq themes are mostly CSS-variable based. A theme plugin should first override the semantic variables that the app already consumes, and only then add targeted selector overrides for areas that are not fully tokenized. + +### CSS loading order + +The main stylesheet imports theme foundations before app component CSS: + +1. `packages/ui/src/radix.css` — Radix color scales as `--rx-*` variables, for example `--rx-gray-01` through `--rx-gray-12` and alpha variants. +2. `packages/ui/src/radix-hsl.css` — HSL forms such as `--rx-gray-01-hsl` for Tailwind/shui tokens. +3. `packages/ui/src/vars-classic.css` — Logseq semantic variables, layout variables, default light/dark values for `data-color=logseq`. +4. `packages/ui/src/colors.css` — accent palettes selected by `html[data-color=...]`, mapping `--lx-accent-*`, `--lx-gray-*`, shui tokens, and many `--ls-*` values. +5. `packages/ui/src/index.css` — Tailwind base. +6. `resources/css/shui.css` — shared UI components built on shui/Radix/Tailwind tokens. +7. Third-party CSS: Inter, PhotoSwipe, KaTeX, CodeMirror, PDF.js, Tabler, `codemirror.lsradix.css`. +8. `src/main/frontend/**/[!_]*.css` — component and extension CSS. +9. A selected plugin theme is injected later as a ``, so it can override earlier rules when specificity is equal or higher. + +### Runtime attributes and classes + +Theme CSS should scope by attributes on `html` and compatibility classes on `body`: + +| Hook | Set by | Meaning | +| --- | --- | --- | +| `html[data-theme="light"]` / `html[data-theme="dark"]` | `frontend.components.theme/container` | Current light/dark mode. Prefer this for theme CSS. | +| `html.dark` | same component | Tailwind dark-mode hook. | +| `body.light-theme`, `body.white-theme`, `body.dark-theme` | same component | Backward-compatible hooks for older custom CSS/themes. | +| `html[data-color="logseq"]` and other colors | same component | Accent color selection. A theme plugin can override or ignore this. | +| `html[data-font="serif"|"mono"]`, `html[data-font-global="true"]` | same component | Editor/global font preferences. Avoid fighting these unless your theme intentionally owns typography. | +| Platform classes such as `html.is-mobile`, `html.is-electron`, `html.is-mac`, `html.is-native-iphone` | `frontend.ui/inject-document-devices-envs!` | Useful for responsive/mobile-safe fixes. | + +### Plugin theme lifecycle + +A theme can be registered in either of two ways: + +1. Declaratively in `package.json > logseq.themes`. During package preparation, Logseq resolves relative `url` values to plugin resources, registers each theme, and shows them in the theme selector. +2. Programmatically with `logseq.provideTheme(theme)`. This is useful for generated themes or settings-driven variants. + +When a user selects a theme, Logseq: + +- removes the previously injected custom theme link; +- injects the selected theme CSS link into `document.head`; +- persists the selected theme separately for `light` and `dark` modes; +- updates `:plugin/selected-theme` and emits plugin app hooks such as `:theme-changed`. + +## Minimal theme plugin + +### Directory layout + +```text +logseq-my-theme/ +├── package.json +├── index.html +└── themes/ + ├── my-theme-light.css + └── my-theme-dark.css +``` + +### `package.json` + +Use a stable lowercase-kebab `logseq.id`. For a theme-only plugin, set `effect: true` and declare each CSS file in `themes`. + +```json +{ + "name": "logseq-my-theme", + "version": "0.1.0", + "description": "A custom Logseq theme pack", + "license": "MIT", + "main": "index.html", + "logseq": { + "id": "my-theme", + "main": "index.html", + "title": "My Theme", + "icon": "./icon.png", + "effect": true, + "themes": [ + { + "name": "My Theme Light", + "url": "./themes/my-theme-light.css", + "mode": "light", + "description": "Light variant of My Theme" + }, + { + "name": "My Theme Dark", + "url": "./themes/my-theme-dark.css", + "mode": "dark", + "description": "Dark variant of My Theme" + } + ] + }, + "devDependencies": { + "@logseq/libs": "^0.0.17" + } +} +``` + +### `index.html` + +Declarative themes do not need runtime code, but keeping a tiny entry file makes the plugin package explicit and easy to extend later. + +```html + + + + + My Logseq Theme + + + +``` + +### Programmatic variant with `provideTheme` + +If the theme URL or metadata is generated at runtime, use the SDK. Always wait for Logseq to be ready. + +```ts +import '@logseq/libs' + +async function main() { + logseq.provideTheme({ + name: 'My Generated Dark Theme', + url: './themes/generated-dark.css', + mode: 'dark', + description: 'Generated from plugin settings', + }) +} + +logseq.ready(main).catch(console.error) +``` + +## CSS strategy + +Start with variables. Add selector overrides only for UI that cannot be changed via variables. + +### Dark theme starter + +```css +/* themes/my-theme-dark.css */ + +html[data-theme="dark"] { + color-scheme: dark; + + /* Core backgrounds */ + --ls-primary-background-color: #111827; + --ls-secondary-background-color: #172033; + --ls-tertiary-background-color: #202b42; + --ls-quaternary-background-color: #2a3650; + --ls-table-tr-even-background-color: var(--ls-secondary-background-color); + --ls-slide-background-color: var(--ls-primary-background-color); + + /* Text */ + --ls-primary-text-color: #d6deeb; + --ls-secondary-text-color: #eef2ff; + --ls-title-text-color: #f8fafc; + --ls-left-sidebar-text-color: #cbd5e1; + + /* Links and refs */ + --ls-link-text-color: #7dd3fc; + --ls-link-text-hover-color: #bae6fd; + --ls-link-ref-text-color: var(--ls-link-text-color); + --ls-link-ref-text-hover-color: var(--ls-link-text-hover-color); + --ls-block-ref-link-text-color: #38bdf8; + --ls-tag-text-color: #93c5fd; + --ls-tag-text-hover-color: #bfdbfe; + + /* Borders, guidelines, focus */ + --ls-border-color: #334155; + --ls-secondary-border-color: #475569; + --ls-tertiary-border-color: rgb(148 163 184 / 0.18); + --ls-guideline-color: rgb(148 163 184 / 0.18); + --ls-focus-ring-color: rgb(56 189 248 / 0.45); + + /* Blocks and properties */ + --ls-block-properties-background-color: #1e293b; + --ls-page-properties-background-color: #1e293b; + --ls-block-bullet-color: #64748b; + --ls-block-bullet-border-color: #475569; + --ls-block-highlight-color: rgb(14 165 233 / 0.22); + --ls-a-chosen-bg: rgb(56 189 248 / 0.16); + --ls-menu-hover-color: var(--ls-a-chosen-bg); + + /* Selection, checkbox, quote, mark, inline code */ + --ls-selection-background-color: rgb(56 189 248 / 0.32); + --ls-selection-text-color: #f8fafc; + --ls-page-checkbox-color: #64748b; + --ls-page-checkbox-border-color: #475569; + --ls-page-blockquote-color: var(--ls-primary-text-color); + --ls-page-blockquote-bg-color: #172033; + --ls-page-blockquote-border-color: #38bdf8; + --ls-page-mark-color: #111827; + --ls-page-mark-bg-color: #fde68a; + --ls-page-inline-code-color: #e0f2fe; + --ls-page-inline-code-bg-color: #0f172a; + + /* Scrollbars and notifications */ + --ls-scrollbar-foreground-color: rgb(148 163 184 / 0.35); + --ls-scrollbar-background-color: rgb(15 23 42 / 0.35); + --ls-scrollbar-thumb-hover-color: rgb(148 163 184 / 0.55); + --ls-notification-background: #1e293b; + --ls-notification-text-color: #f8fafc; + + /* shui/Tailwind HSL tokens: use space-separated HSL channels, not hsl(). */ + --background: 222 47% 11%; + --foreground: 210 40% 96%; + --card: 222 47% 13%; + --card-foreground: 210 40% 96%; + --popover: 222 47% 10%; + --popover-foreground: 210 40% 96%; + --primary: 199 89% 48%; + --primary-foreground: 210 40% 98%; + --secondary: 217 33% 18%; + --secondary-foreground: 210 40% 96%; + --muted: 217 33% 16%; + --border: 217 33% 24%; + --input: 217 33% 24%; + --ring: 199 89% 48%; + --accent: 199 89% 48%; + --accent-foreground: 210 40% 98%; +} + +html[data-theme="dark"] body { + background: var(--ls-primary-background-color); + color: var(--ls-primary-text-color); +} + +html[data-theme="dark"] ::selection { + background: var(--ls-selection-background-color); + color: var(--ls-selection-text-color); +} +``` + +### Light theme starter + +```css +/* themes/my-theme-light.css */ + +html[data-theme="light"] { + color-scheme: light; + + --ls-primary-background-color: #ffffff; + --ls-secondary-background-color: #f8fafc; + --ls-tertiary-background-color: #eef2f7; + --ls-quaternary-background-color: #e2e8f0; + + --ls-primary-text-color: #1e293b; + --ls-secondary-text-color: #0f172a; + --ls-title-text-color: #0f172a; + --ls-left-sidebar-text-color: #334155; + + --ls-link-text-color: #0369a1; + --ls-link-text-hover-color: #075985; + --ls-link-ref-text-color: var(--ls-link-text-color); + --ls-link-ref-text-hover-color: var(--ls-link-text-hover-color); + --ls-block-ref-link-text-color: #0284c7; + --ls-tag-text-color: #0369a1; + --ls-tag-text-hover-color: #075985; + + --ls-border-color: #cbd5e1; + --ls-secondary-border-color: #e2e8f0; + --ls-tertiary-border-color: rgb(15 23 42 / 0.08); + --ls-guideline-color: rgb(15 23 42 / 0.08); + --ls-focus-ring-color: rgb(14 165 233 / 0.35); + + --ls-block-properties-background-color: #f1f5f9; + --ls-page-properties-background-color: #f1f5f9; + --ls-block-bullet-color: #94a3b8; + --ls-block-bullet-border-color: #cbd5e1; + --ls-block-highlight-color: #e0f2fe; + --ls-a-chosen-bg: #e0f2fe; + --ls-menu-hover-color: var(--ls-a-chosen-bg); + + --ls-selection-background-color: #dbeafe; + --ls-selection-text-color: #0f172a; + --ls-page-checkbox-color: #94a3b8; + --ls-page-checkbox-border-color: #94a3b8; + --ls-page-blockquote-color: var(--ls-primary-text-color); + --ls-page-blockquote-bg-color: #f8fafc; + --ls-page-blockquote-border-color: #38bdf8; + --ls-page-mark-color: #0f172a; + --ls-page-mark-bg-color: #fef3c7; + --ls-page-inline-code-color: #0f172a; + --ls-page-inline-code-bg-color: #f1f5f9; + + --ls-scrollbar-foreground-color: rgb(15 23 42 / 0.12); + --ls-scrollbar-background-color: rgb(15 23 42 / 0.05); + --ls-scrollbar-thumb-hover-color: rgb(15 23 42 / 0.22); + + --background: 0 0% 100%; + --foreground: 222 47% 11%; + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + --primary: 199 89% 40%; + --primary-foreground: 0 0% 100%; + --secondary: 210 40% 96%; + --secondary-foreground: 222 47% 11%; + --muted: 210 40% 96%; + --border: 214 32% 91%; + --input: 214 32% 91%; + --ring: 199 89% 40%; + --accent: 199 89% 40%; + --accent-foreground: 0 0% 100%; +} +``` + +## Important variable groups + +Prefer these variables before reaching for selectors. + +### Global layout and typography + +| Variable | Purpose | +| --- | --- | +| `--ls-page-text-size` | Base page text size under `#root`. | +| `--ls-page-title-size` | Page title size. Mobile may override title layout. | +| `--ls-main-content-max-width`, `--ls-main-content-max-width-wide` | Main content width. | +| `--ls-font-family` | Global app font when not overridden by user font settings. | +| `--ls-scrollbar-width` | Custom scrollbar width. | +| `--ls-border-radius-low`, `--ls-border-radius-medium` | Classic radius tokens. | +| `--ls-headbar-height`, `--ls-headbar-inner-top-padding` | Header sizing. | +| `--ls-left-sidebar-width`, `--ls-left-sidebar-sm-width`, `--ls-left-sidebar-nav-btn-size` | Left sidebar sizing. | + +### Logseq semantic colors + +| Variable | Purpose | +| --- | --- | +| `--ls-primary-background-color` | Main background. | +| `--ls-secondary-background-color` | Secondary surfaces, editors, menus. | +| `--ls-tertiary-background-color` | Nested/raised surfaces. | +| `--ls-quaternary-background-color` | Active/hover surfaces. | +| `--ls-primary-text-color`, `--ls-secondary-text-color`, `--ls-title-text-color` | Body, stronger text, titles. | +| `--ls-border-color`, `--ls-secondary-border-color`, `--ls-tertiary-border-color` | Borders. | +| `--ls-guideline-color` | Block indentation guide lines. | +| `--ls-active-primary-color`, `--ls-active-secondary-color` | Active states. | +| `--ls-a-chosen-bg`, `--ls-menu-hover-color` | Chosen menu/list item backgrounds. | +| `--ls-focus-ring-color` | Focus rings. | + +### Links, references, tags, and blocks + +| Variable | Purpose | +| --- | --- | +| `--ls-link-text-color`, `--ls-link-text-hover-color` | General links. | +| `--ls-link-ref-text-color`, `--ls-link-ref-text-hover-color` | Page references. | +| `--ls-block-ref-link-text-color` | Block references. | +| `--ls-tag-text-color`, `--ls-tag-text-hover-color`, `--ls-tag-text-opacity`, `--ls-tag-text-hover-opacity` | Tags. | +| `--ls-block-bullet-color`, `--ls-block-bullet-border-color`, `--ls-block-bullet-active-color` | Block bullets. | +| `--ls-block-highlight-color` | Block highlight. | +| `--ls-block-properties-background-color`, `--ls-page-properties-background-color` | Property panels. | + +### Content tokens + +| Variable | Purpose | +| --- | --- | +| `--ls-selection-background-color`, `--ls-selection-text-color` | Text selection. | +| `--ls-page-checkbox-color`, `--ls-page-checkbox-border-color` | Markdown task checkboxes/radios. | +| `--ls-page-blockquote-color`, `--ls-page-blockquote-bg-color`, `--ls-page-blockquote-border-color` | Blockquotes. | +| `--ls-page-mark-color`, `--ls-page-mark-bg-color` | Highlight/mark text. | +| `--ls-page-inline-code-color`, `--ls-page-inline-code-bg-color` | Inline code. | +| `--ls-table-tr-even-background-color` | Alternating table rows. | +| `--ls-cloze-text-color` | Cloze text. | +| `--ls-slide-background-color` | Slide mode background. | + +### shui/Tailwind HSL tokens + +Newer shared UI components use HSL channel tokens, often through Tailwind classes such as `bg-background`, `bg-popover`, `border`, `text-foreground`, and `bg-primary`. + +Set these as space-separated HSL channels: + +```css +html[data-theme="dark"] { + --background: 222 47% 11%; + --foreground: 210 40% 96%; + --card: 222 47% 13%; + --card-foreground: 210 40% 96%; + --popover: 222 47% 10%; + --popover-foreground: 210 40% 96%; + --primary: 199 89% 48%; + --primary-foreground: 210 40% 98%; + --secondary: 217 33% 18%; + --secondary-foreground: 210 40% 96%; + --muted: 217 33% 16%; + --border: 217 33% 24%; + --input: 217 33% 24%; + --ring: 199 89% 48%; + --accent: 199 89% 48%; + --accent-foreground: 210 40% 98%; +} +``` + +Do not write `--primary: hsl(199 89% 48%)`; consumers call `hsl(var(--primary))`. + +### `--lx-*` and `--rx-*` color scales + +- `--rx-*` variables are Radix-style raw palettes. They are broad and already available. +- `--lx-gray-01` through `--lx-gray-12` and `--lx-gray-*-alpha` represent the active neutral scale. +- `--lx-accent-01` through `--lx-accent-12` and `--lx-accent-*-alpha` represent the active accent scale. + +If your theme is a complete palette, map `--lx-gray-*` and `--lx-accent-*` as well as `--ls-*`. This makes Radix/shui-heavy UI more consistent: + +```css +html[data-theme="dark"] { + --lx-gray-01: #0f172a; + --lx-gray-02: #111827; + --lx-gray-03: #1e293b; + --lx-gray-04: #273449; + --lx-gray-05: #334155; + --lx-gray-06: #475569; + --lx-gray-07: #64748b; + --lx-gray-08: #94a3b8; + --lx-gray-09: #cbd5e1; + --lx-gray-10: #dbeafe; + --lx-gray-11: #e2e8f0; + --lx-gray-12: #f8fafc; + + --lx-accent-09: #0ea5e9; + --lx-accent-10: #0284c7; + --lx-accent-11: #38bdf8; + --lx-accent-12: #e0f2fe; +} +``` + +## UI selector map + +Use this as a last-mile map after variables. Keep overrides narrow and prefer `:where(...)` to avoid specificity wars. + +### App shell and layout + +| Area | Useful selectors | Notes | +| --- | --- | --- | +| Root/app | `#root`, `#app-container`, `#root-container.theme-container`, `main.theme-container-inner` | `#root` uses `--ls-page-text-size`; `main.theme-container-inner` defines `--left-sidebar-bg-color`. | +| Main content | `#main-container`, `#main-content`, `#main-content-container`, `.page-blocks-inner` | Avoid hard-coded viewport hacks; test desktop and mobile. | +| Header | `.cp__header`, `.head`, `.button`, `.ui__button` | Mostly tokenized through shui/Tailwind variables. | +| Left sidebar | `.left-sidebar-inner`, `.sidebar-header-container`, `.sidebar-contents-container`, `.sidebar-content-group`, `.sidebar-navigations` | Prefer `--left-sidebar-bg-color`, `--ls-left-sidebar-text-color`, and `--ls-left-sidebar-width`. | +| Right sidebar | `#right-sidebar`, `.cp__right-sidebar-inner`, `.sidebar-item`, `.references-blocks-item` | Nested reference cards may need extra contrast. | + +### Pages, blocks, and editor + +| Area | Useful selectors | Notes | +| --- | --- | --- | +| Page title | `.ls-page-title`, `.page-title`, `.journal-title`, `.page-title-sizer-wrapper` | Prefer `--ls-page-title-size` and title text variables. | +| Blocks | `.ls-block`, `.block-main-container`, `.block-content-wrapper`, `.block-content`, `.block-body` | Avoid altering core layout unless necessary. | +| Block tree guides | `.block-children-container`, `.block-children`, `.block-children-left-border` | Governed by `--ls-guideline-color` and related border variables. | +| Bullets/control | `.block-control-wrap`, `.block-control`, `.bullet-container`, `.bullet-link-wrap` | Governed by bullet variables; keep hit areas accessible. | +| Page refs | `.page-ref`, `.page-reference`, `.breadcrumb` | Use link/reference variables first. | +| Block refs | `.block-ref`, `.block-ref-no-title`, `.open-block-ref-link` | Use `--ls-block-ref-link-text-color`, property surface variables. | +| Properties | `.block-properties`, `.page-properties`, `.property-value-inner`, `.property-key` | Use property background variables. | +| Editor textarea | `.editor-inner textarea`, `.edit-input`, `.non-block-editor textarea` | Background usually comes from `--ls-secondary-background-color`. | +| Autocomplete/slash popup | `#ui__ac-inner`, `.menu-link`, `.absolute-modal[data-modal-name]`, `.cp__commands-slash` | Use menu hover and popover tokens. | + +### Shared UI, dialogs, command palette + +| Area | Useful selectors | Notes | +| --- | --- | --- | +| Buttons | `.ui__button`, `.button`, `.ui__toggle`, `.ui__toggle-background-on`, `.ui__toggle-background-off` | Set `--primary`, `--primary-foreground`, `--accent`, `--ring`. | +| Dialogs/modals | `.ui__modal`, `.ui__modal-panel`, `.ui__dialog-overlay`, `.ui__dialog-content`, `.ui__alert-dialog-content` | `--ls-modal-overlay-gradient-start/end` can control modal overlays. | +| Dropdowns/popovers | `.ui__dropdown-menu-content`, `.ui__popover-content`, `div[data-radix-menu-content]`, `div[data-radix-popper-content-wrapper]` | Usually driven by `--popover`, `--border`, `--accent`, `--lx-popover-bg`. | +| Select/calendar | `.ui__select-content`, `.ui__calendar`, `.rc-datepicker` | shui tokens and `--accent` matter. | +| Notifications | `.ui__notifications .notification-area` | Use `--ls-notification-background`, `--ls-notification-text-color`. | +| Command palette | `.cp__cmdk` | Border and hint colors use `--ls-border-color`, `--lx-gray-*`, `--accent`. | +| Context menu | `#custom-context-menu`, `.ls-context-menu-content`, `.menu-links-wrapper` | Popover/menu variables usually cover it. | + +### Extensions and special surfaces + +| Area | Useful selectors/variables | Notes | +| --- | --- | --- | +| CodeMirror | `.cm-s-lsradix`, `.cm-s-lsradix.cm-s-dark`, `.cm-s-lsradix.cm-s-light`, `.CodeMirror-*`, `.cm-*` | `resources/css/codemirror.lsradix.css` already bridges many `--lx-*`/`--ls-*` variables. Override only syntax colors if needed. | +| PDF | `.extensions__pdf-container`, `.extensions__pdf-toolbar`, `.extensions__pdf-outline`, `--ph-highlight-color-*`, `--ph-link-color`, `--ph-view-container-width`, `--lx-pdf-container-dark-bg` | PDF highlight colors are `--ph-*`, not `--ls-highlight-color-*`. | +| Graph | `.graph-layout`, `.graph-filters`, graph extension CSS under `src/main/frontend/extensions/graph.css` | Some graph styles are canvas/SVG-driven; inspect DOM before overriding. | +| Whiteboard/tldraw | `.tl-container`, `.tl-button`, `--ls-wb-stroke-color-default`, `--ls-wb-background-color-default`, `--ls-wb-text-color-default` | Accent colors in `colors.css` set default whiteboard tokens. | +| Tables | `.table-wrapper`, `.table-auto`, table rows | Start with `--ls-table-tr-even-background-color`, borders, text tokens. | +| Cards/flashcards | `.ls-card`, `.ui__dialog-content[label=flashcards__cp]` | Check dialog contrast and card minimum sizes. | +| Plugins UI | `.cp__plugins-page`, `.cp__themes-installed` | Useful when styling the installed theme picker itself. | + +## Scoped overrides: examples + +### Keep overrides mode-specific + +```text +html[data-theme="dark"] :where(.cp__right-sidebar-inner .references-blocks-item) { + background: color-mix(in srgb, var(--ls-secondary-background-color) 88%, white 12%); +} +``` + +### Prefer low specificity for optional polish + +```text +html[data-theme="light"] :where(.page-reference:hover) { + background: rgb(14 165 233 / 0.12); +} +``` + +### Avoid global resets + +Avoid rules like this in a theme plugin: + +```css +/* Avoid */ +* { + transition: all 200ms ease; +} +``` + +They can slow editing, affect CodeMirror/PDF.js, and break subtle interaction states. + +## Mobile and desktop considerations + +- Test `html.is-mobile`, `html.is-native-iphone`, `html.is-native-android`, and `html.is-electron` layouts if your theme changes spacing or header/sidebar dimensions. +- Respect safe-area insets in mobile-specific layout. Existing CSS uses `env(safe-area-inset-*)` in some places. +- Avoid changing `-webkit-app-region` on Electron headers/PDF toolbars unless you are fixing a drag-region issue. +- Do not shrink block control or bullet hit areas below the existing touch-friendly sizes. +- Avoid fixed pixel widths for the main content unless they are variables such as `--ls-main-content-max-width`. + +## Assets and fonts + +Prefer local assets packaged with the plugin. Relative URLs inside the selected theme CSS are resolved relative to the CSS file by the browser. + +```css +@font-face { + font-family: "MyThemeText"; + src: url("../fonts/MyThemeText.woff2") format("woff2"); + font-display: swap; +} + +html[data-theme="dark"] { + --ls-font-family: "MyThemeText", Inter, ui-sans-serif, system-ui, sans-serif; +} +``` + +Be careful with remote `@import` URLs: they can slow startup, fail offline, and leak network requests from a local-first app. + +## Debugging workflow + +1. Enable Logseq Developer mode. +2. Load the unpacked theme plugin. +3. Open DevTools and inspect `document.documentElement`: + - `data-theme` + - `data-color` + - `data-font` + - platform classes +4. In the theme selector, choose each declared light/dark theme. +5. Verify the selected CSS appears as a `` in `document.head`. +6. Inspect computed variables on `html`, `body`, `.theme-container`, and the specific UI area you are styling. +7. If a variable is not taking effect, check whether the component is using `--lx-*`, shui HSL tokens, or a targeted class instead of `--ls-*`. + +## Validation checklist + +Before publishing a theme plugin, test at least these screens and states: + +- [ ] Light and dark mode selection; switching between modes keeps each custom theme selection. +- [ ] Default accent color and at least one non-default `data-color` accent. +- [ ] Page title, journal title, normal blocks, nested blocks, block refs, page refs, tags, properties. +- [ ] Editing state: textarea, slash command menu, autocomplete, date picker, block context menu. +- [ ] Left sidebar, right sidebar, search, command palette, settings dialogs. +- [ ] Notifications, confirmation dialogs, dropdowns, popovers, tooltips. +- [ ] Code blocks / CodeMirror, inline code, marks, blockquotes, tables. +- [ ] PDF viewer highlights and toolbar if PDF is in scope. +- [ ] Graph and whiteboard/tldraw surfaces if your palette changes accents broadly. +- [ ] Desktop Electron and at least one mobile/narrow viewport if the theme changes layout/spacing. +- [ ] High contrast of text against every background; visible focus rings; visible selected/hover states. +- [ ] Reduced-motion friendliness if you add animations. + +## Common pitfalls + +- Overriding only `--ls-*` while newer shui components still read `--background`, `--popover`, `--border`, `--primary`, or `--lx-*` variables. +- Writing HSL tokens as full CSS colors instead of channel values. +- Using high-specificity selectors or `!important` everywhere, making user custom CSS and future Logseq changes harder to coexist with. +- Styling by generated Tailwind class names instead of stable Logseq/Radix selectors and variables. +- Forgetting to scope a dark theme to `html[data-theme="dark"]` or a light theme to `html[data-theme="light"]`. +- Hard-coding external font/image URLs without offline fallback. +- Changing editor layout, block bullets, or mobile sidebars without testing touch interactions. + +## Quick reference: priority order for theme authors + +1. Set `--ls-*` semantic variables for Logseq-specific UI. +2. Set shui/Tailwind HSL tokens (`--background`, `--foreground`, `--popover`, `--border`, `--primary`, etc.). +3. Map `--lx-gray-*` and `--lx-accent-*` for newer shared UI consistency. +4. Add extension variables such as `--ph-*` and `--ls-wb-*` only if your theme covers PDF/whiteboard. +5. Add low-specificity, mode-scoped selector overrides for the remaining gaps. + + diff --git a/libs/development-notes/db_properties_guide.md b/libs/guides/db_properties_guide.md similarity index 100% rename from libs/development-notes/db_properties_guide.md rename to libs/guides/db_properties_guide.md diff --git a/libs/development-notes/db_properties_skill.md b/libs/guides/db_properties_references.md similarity index 100% rename from libs/development-notes/db_properties_skill.md rename to libs/guides/db_properties_references.md diff --git a/libs/development-notes/db_query_guide.md b/libs/guides/db_query_guide.md similarity index 100% rename from libs/development-notes/db_query_guide.md rename to libs/guides/db_query_guide.md diff --git a/libs/development-notes/db_tag_property_idents_notes.md b/libs/guides/db_tag_property_idents_guide.md similarity index 100% rename from libs/development-notes/db_tag_property_idents_notes.md rename to libs/guides/db_tag_property_idents_guide.md diff --git a/libs/guides/experiments_api_guide.md b/libs/guides/experiments_api_guide.md new file mode 100644 index 0000000000..db1f79224e --- /dev/null +++ b/libs/guides/experiments_api_guide.md @@ -0,0 +1,784 @@ +# Logseq Experiments API Guide + +This guide covers the **experimental APIs** exposed as `logseq.Experiments` in the Logseq Plugin SDK. + +These APIs are intentionally lower-level than the stable SDK. They let plugins: + +- reuse host React/ReactDOM +- render internal Logseq components +- convert between JS and ClojureScript data structures +- load scripts dynamically +- register custom renderers for fenced code, routes, sidebars, properties, and block bodies +- hook internal extensions such as KaTeX +- access host/plugin internals when absolutely necessary + +> **⚠️ Warning** +> +> Everything in `logseq.Experiments` is unstable. Signatures, render props, and behaviors may change without a normal deprecation window. Plugins using these APIs may be temporarily unsupported on the Marketplace. + +--- + +## Overview + +The current experimental surface includes: + +1. **React integration**: `React`, `ReactDOM` +2. **Internal components**: `Components.Editor` +3. **Interop utilities**: `Utils.toClj`, `toJs`, `jsxToClj`, `toKeyword`, `toSymbol` +4. **Script loading**: `loadScripts(...)` +5. **Renderer registration**: + - `registerFencedCodeRenderer(...)` + - `registerDaemonRenderer(...)` + - `registerRouteRenderer(...)` + - `registerHostedRenderer(...)` + - `registerSidebarRenderer(...)` + - `registerBlockPropertiesRenderer(...)` + - `registerBlockRenderer(...)` +6. **Extension enhancers**: `registerExtensionsEnhancer(...)` +7. **Host/plugin internals**: + - `pluginLocal` + - `ensureHostScope()` + - `invokeExperMethod(...)` + +--- + +## 1. React Integration + +Use the host's React runtime instead of bundling your own copy. + +### `logseq.Experiments.React` + +Returns the React instance from the host scope. + +```typescript +const React = logseq.Experiments.React +``` + +### `logseq.Experiments.ReactDOM` + +Returns the ReactDOM instance from the host scope. + +```typescript +const ReactDOM = logseq.Experiments.ReactDOM +``` + +### Example + +```typescript +const React = logseq.Experiments.React + +const MyComponent = React.createElement( + 'div', + { className: 'my-plugin-card' }, + 'Hello from a host React tree' +) +``` + +--- + +## 2. Components + +### `logseq.Experiments.Components.Editor` + +Renders Logseq page content using an internal page editor component. + +**Type** + +```typescript +(props: { page: string } & Record) => any +``` + +**Parameters** + +- `page`: page name to render + +```typescript +const Editor = logseq.Experiments.Components.Editor + +const preview = Editor({ page: 'My Page Name' }) +``` + +--- + +## 3. Utilities + +`logseq.Experiments.Utils` exposes host interop helpers. + +### `toClj(input: any)` + +Convert JavaScript data into ClojureScript data structures. + +```typescript +const cljData = logseq.Experiments.Utils.toClj({ key: 'value' }) +``` + +### `jsxToClj(input: any)` + +Convert JS/JSX-style input to ClojureScript while preserving JSX-ish structures better than a plain conversion. + +```typescript +const view = { type: 'div', props: { children: 'Content' } } +const cljView = logseq.Experiments.Utils.jsxToClj(view) +``` + +### `toJs(input: any)` + +Convert ClojureScript values back into plain JavaScript. + +```typescript +const jsData = logseq.Experiments.Utils.toJs(cljData) +``` + +### `toKeyword(input: any)` + +Convert a string into a ClojureScript keyword. + +```typescript +const keyword = logseq.Experiments.Utils.toKeyword('my-key') +``` + +### `toSymbol(input: any)` + +Convert a string into a ClojureScript symbol. + +```typescript +const symbol = logseq.Experiments.Utils.toSymbol('my-symbol') +``` + +--- + +## 4. Script Loading + +### `logseq.Experiments.loadScripts(...scripts: string[])` + +Dynamically load scripts into the host environment. + +**Parameters** + +- `scripts`: HTTP(S) URLs or relative plugin resource paths + +**Returns** + +```typescript +Promise +``` + +**Behavior** + +- relative paths are resolved against the current plugin resource root +- HTTP/HTTPS URLs are used as-is +- scripts are loaded in the given order + +```typescript +await logseq.Experiments.loadScripts( + 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.umd.min.js' +) + +await logseq.Experiments.loadScripts('./vendor/local-helper.js') + +await logseq.Experiments.loadScripts( + 'https://cdn.example.com/lib1.js', + 'https://cdn.example.com/lib2.js', + './local-script.js' +) +``` + +--- + +## 5. Custom Renderers + +Experimental renderers are where most of the newer APIs live. + +## 5.1 Fenced Code Renderer + +Register a custom renderer for fenced code blocks such as: + +````markdown +```my-lang +... +``` +```` + +### `logseq.Experiments.registerFencedCodeRenderer(lang, opts)` + +```text +registerFencedCodeRenderer( + lang: string, + opts: { + edit?: boolean + before?: () => Promise + subs?: string[] + render: (props: { content: string }) => any + } +): any +``` + +**Options** + +- `edit`: whether the fenced block remains editable +- `before`: async preload hook, usually for loading scripts/assets +- `subs`: experimental subscription list +- `render`: React renderer receiving `{ content }` + +```typescript +logseq.Experiments.registerFencedCodeRenderer('my-chart', { + edit: false, + before: async () => { + await logseq.Experiments.loadScripts( + 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.umd.min.js' + ) + }, + render: ({ content }) => { + const React = logseq.Experiments.React + + return React.createElement('canvas', { + ref: (canvas: HTMLCanvasElement | null) => { + if (!canvas || !window.Chart) return + + try { + const config = JSON.parse(content) + new window.Chart(canvas, config) + } catch (error) { + console.error('Chart renderer error', error) + } + }, + }) + }, +}) +``` + +## 5.2 Daemon Renderer + +Register a renderer that stays mounted in a global daemon container. + +### `logseq.Experiments.registerDaemonRenderer(key, opts)` + +```text +registerDaemonRenderer( + key: string, + opts: { + before?: () => Promise + subs?: string[] + render: (props: {}) => any + } +): any +``` + +**Notes** + +- use `subs`, not `sub` +- `before` is supported by the host even if older typings may not show it yet +- daemon renderers are useful for lightweight always-on UI, not large app shells + +```typescript +logseq.Experiments.registerDaemonRenderer('my-status-bar', { + subs: ['ui/theme'], + render: () => { + const React = logseq.Experiments.React + + return React.createElement( + 'div', + { + style: { + position: 'fixed', + right: 12, + bottom: 12, + padding: '6px 10px', + borderRadius: 8, + background: 'var(--ls-secondary-background-color)', + }, + }, + 'Plugin active' + ) + }, +}) +``` + +## 5.3 Route Renderer + +Register a custom route view. + +### `logseq.Experiments.registerRouteRenderer(key, opts)` + +```text +registerRouteRenderer( + key: string, + opts: { + path: string + name?: string + subs?: string[] + render: (props: {}) => any + } +): any +``` + +**Options** + +- `path`: route path, e.g. `'/my-plugin-dashboard'` +- `name`: optional display name; if omitted, the internal key is reused +- `subs`: experimental subscription list +- `render`: route component + +```typescript +logseq.Experiments.registerRouteRenderer('my-custom-page', { + path: '/my-plugin-dashboard', + name: 'Dashboard', + render: () => { + const React = logseq.Experiments.React + + return React.createElement('div', { className: 'my-plugin-dashboard' }, [ + React.createElement('h1', { key: 'title' }, 'Plugin Dashboard'), + React.createElement('p', { key: 'body' }, 'Custom content here'), + ]) + }, +}) +``` + +> Route navigation is handled by Logseq's router. In docs and examples, prefer describing the registered `path` rather than relying on page navigation APIs, which are not the same thing. + +## 5.4 Hosted Renderer + +Low-level API for host-managed render targets. + +Today, the main built-in consumer is the right sidebar, so most plugins should prefer `registerSidebarRenderer(...)` unless they specifically need the lower-level primitive. + +### `logseq.Experiments.registerHostedRenderer(key, opts)` + +```text +registerHostedRenderer( + key: string, + opts: { + title?: string + mode?: string + type?: string + subs?: string[] + render: (props: {}) => any + } +): any +``` + +**Options** + +- `title`: display title when the host surfaces the renderer +- `type`: host-specific placement type +- `mode`: host-specific placement mode +- `subs`: experimental subscription list +- `render`: React renderer + +The host currently passes the registered renderer record back into the render function in some placements. Treat that as implementation detail, not a stable contract. + +## 5.5 Sidebar Renderer + +Convenience wrapper over `registerHostedRenderer(...)` for right-sidebar tools. + +### `logseq.Experiments.registerSidebarRenderer(key, opts)` + +```text +registerSidebarRenderer( + key: string, + opts: { + title?: string + subs?: string[] + render: (props: {}) => any + [key: string]: any + } +): any +``` + +**Behavior** + +- your key is automatically namespaced internally as `_sidebar.${key}` +- `type` is forced to `'sidebar'` +- the renderer appears in the right-sidebar plugin menu + +```typescript +logseq.Experiments.registerSidebarRenderer('inspector', { + title: 'Inspector', + render: () => { + const React = logseq.Experiments.React + + return React.createElement('div', null, 'Hello from the sidebar renderer') + }, +}) +``` + +## 5.6 Block Properties Renderer + +Render custom UI inside a block's properties area. + +### `logseq.Experiments.registerBlockPropertiesRenderer(key, opts)` + +```text +type BlockPropertiesCondition = + | { has: string } + | { equals: [string, any] } + | { in: [string, any[]] } + | { not: BlockPropertiesCondition } + | { any: BlockPropertiesCondition[] } + | { all: BlockPropertiesCondition[] } + +type BlockPropertiesRendererProps = { + blockId: string + properties: Record +} + +registerBlockPropertiesRenderer( + key: string, + opts: { + when?: BlockPropertiesCondition | ((props: BlockPropertiesRendererProps) => boolean) + mode?: 'prepend' | 'append' | 'replace' + priority?: number + subs?: string[] + render: (props: BlockPropertiesRendererProps) => any + } +): any +``` + +**Behavior** + +- `when` may be omitted, a declarative condition, or a synchronous predicate +- `mode` controls placement in the properties area: + - `prepend`: before native properties + - `append`: after native properties + - `replace`: replace native properties UI +- higher `priority` wins for conflicts +- for `replace`, the highest-priority matching replace renderer wins +- for `prepend`/`append`, all matching renderers are rendered in priority order + +**Render props** + +- `blockId`: block UUID string +- `properties`: plain JS object keyed by property names without the leading `:` + +**Property serialization details** + +Before data is passed into plugins, Logseq normalizes some values: + +- keywords become strings like `'logseq.property/status'` +- UUIDs become strings +- entity references become small objects such as `{ uuid, title }` +- sets / collections of entity references become arrays of those objects + +```typescript +logseq.Experiments.registerBlockPropertiesRenderer('priority-pill', { + when: { has: 'priority' }, + mode: 'prepend', + priority: 10, + render: ({ properties }) => { + const React = logseq.Experiments.React + const priority = properties.priority + + if (!priority) return null + + return React.createElement( + 'span', + { + style: { + display: 'inline-flex', + marginRight: 8, + padding: '2px 8px', + borderRadius: 9999, + fontSize: 12, + background: 'var(--ls-tertiary-background-color)', + }, + }, + `Priority: ${priority}` + ) + }, +}) +``` + +## 5.7 Block Renderer + +Replace a block's main outline body with plugin UI. + +### `logseq.Experiments.registerBlockRenderer(key, opts)` + +```text +type BlockRendererChild = Record & { + children?: BlockRendererChild[] +} + +type BlockRendererProps = { + blockId: string + properties: Record + uuid?: string + page?: string + content?: string + format?: string + children?: BlockRendererChild[] +} + +registerBlockRenderer( + key: string, + opts: { + when?: (props: BlockRendererProps) => boolean + includeChildren?: boolean + priority?: number + subs?: string[] + render: (props: BlockRendererProps) => any + } +): any +``` + +**Behavior** + +- `when` must be a **synchronous predicate function** if provided +- declarative conditions are **not** supported here +- highest `priority` match wins +- when the plugin renderer is active, users can switch back to the native outline view via built-in UI on that block +- when `includeChildren` is `true`, Logseq passes a recursive child tree and hides native outline children while the plugin renderer is active + +**Render props** + +- `blockId`: block UUID string +- `uuid`: same block UUID +- `page`: page title +- `content`: block content/title text +- `format`: `'markdown'`, `'org'`, etc. +- `properties`: normalized property object +- `children`: recursive normalized child tree when `includeChildren` is enabled + +```typescript +logseq.Experiments.registerBlockRenderer('kanban-card', { + when: ({ properties }) => properties.view === 'kanban-card', + includeChildren: true, + priority: 20, + render: ({ content, children = [] }) => { + const React = logseq.Experiments.React + + return React.createElement('section', { className: 'my-kanban-card' }, [ + React.createElement('h3', { key: 'title' }, content || 'Untitled'), + React.createElement( + 'ul', + { key: 'children' }, + children.map((child, index) => + React.createElement('li', { key: child.uuid || index }, child.title || child.content) + ) + ), + ]) + }, +}) +``` + +--- + +## 6. Extension Enhancers + +### `logseq.Experiments.registerExtensionsEnhancer(type, enhancer)` + +Enhance host libraries such as KaTeX. + +```text +registerExtensionsEnhancer( + type: 'katex' | 'codemirror', + enhancer: (value: any) => Promise +): any +``` + +For `katex`, the host immediately invokes the enhancer if KaTeX is already present. + +```typescript +logseq.Experiments.registerExtensionsEnhancer('katex', async (katex) => { + katex.macros = { + ...katex.macros, + '\\RR': '\\mathbb{R}', + '\\NN': '\\mathbb{N}', + '\\ZZ': '\\mathbb{Z}', + } +}) +``` + +--- + +## 7. Host / Plugin Internals + +## 7.1 `logseq.Experiments.pluginLocal` + +Returns the internal `PluginLocal` instance for the current plugin. + +```typescript +const pluginLocal = logseq.Experiments.pluginLocal +console.log(pluginLocal.id) +``` + +Use this sparingly. It is intentionally internal. + +## 7.2 `logseq.Experiments.ensureHostScope()` + +Returns the host scope, currently `window.top`, after attempting an access check. + +```typescript +const host = logseq.Experiments.ensureHostScope() +``` + +This is mostly useful when you need direct access to host globals and understand the risks. + +## 7.3 `logseq.Experiments.invokeExperMethod(type, ...args)` + +Direct escape hatch for calling experimental host methods. + +```typescript +const result = logseq.Experiments.invokeExperMethod( + 'someExperimentalFeature', + arg1, + arg2 +) +``` + +`type` is normalized to snake_case before resolution. + +--- + +## 8. Complete Example: Fenced Code Renderer + +```typescript +import '@logseq/libs' + +async function main() { + logseq.Experiments.registerFencedCodeRenderer('chart', { + edit: false, + before: async () => { + await logseq.Experiments.loadScripts( + 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.umd.min.js' + ) + }, + render: ({ content }) => { + const React = logseq.Experiments.React + + return React.createElement('canvas', { + ref: (canvas: HTMLCanvasElement | null) => { + if (!canvas || !window.Chart) return + + try { + const config = JSON.parse(content) + new window.Chart(canvas, config) + } catch (error) { + console.error('Chart rendering error:', error) + } + }, + }) + }, + }) +} + +logseq.ready(main).catch(console.error) +``` + +**Usage** + +````markdown +```chart +{ + "type": "line", + "data": { + "labels": ["Jan", "Feb", "Mar", "Apr"], + "datasets": [{ + "label": "Sales", + "data": [10, 20, 15, 30], + "borderColor": "rgb(75, 192, 192)" + }] + } +} +``` +```` + +--- + +## 9. Complete Example: Block Properties Badge + +```typescript +import '@logseq/libs' + +async function main() { + logseq.Experiments.registerBlockPropertiesRenderer('task-status-chip', { + when: { + any: [ + { equals: ['status', 'todo'] }, + { equals: ['status', 'doing'] }, + ], + }, + mode: 'append', + priority: 5, + render: ({ properties }) => { + const React = logseq.Experiments.React + const value = properties.status + + return React.createElement( + 'span', + { + style: { + marginLeft: 8, + padding: '2px 8px', + borderRadius: 9999, + fontSize: 12, + background: 'var(--ls-secondary-background-color)', + }, + }, + `Status: ${value}` + ) + }, + }) +} + +logseq.ready(main).catch(console.error) +``` + +--- + +## 10. Best Practices + +1. **Prefer stable APIs first**. Only use `Experiments` when the stable SDK cannot solve the problem. +2. **Use host React**. Avoid bundling a second React runtime into the same tree. +3. **Keep `when` predicates synchronous**. This is especially important for `registerBlockRenderer(...)`. +4. **Use `before` to preload dependencies** instead of doing ad hoc script injection inside render. +5. **Treat `subs` as experimental**. Reactive semantics may change. +6. **Keep renderers lightweight**. Block and daemon renderers can affect overall app responsiveness. +7. **Handle bad input defensively**. Render props often contain user-authored content and properties. +8. **Document your experimental usage** in the plugin README so users understand the risk. +9. **Prefer `registerSidebarRenderer(...)` over raw hosted renderers** when your goal is a right-sidebar tool. +10. **Test against real graphs**. Property values, references, and child trees can vary a lot. + +--- + +## 11. Limitations and Notes + +- **Experimental status**: no stability guarantee +- **Marketplace support**: may be restricted temporarily +- **Security**: be careful with external scripts and direct host access +- **Performance**: custom renderers run inside the app UI, so poor implementations are noticeable +- **Typings may lag behavior**: some newer runtime options can land before every generated wrapper/type is refreshed + +### ClojureScript SDK note + +The generated ClojureScript wrapper namespace `com.logseq.experiments` currently includes wrappers for: + +- `load-scripts` +- `register-fenced-code-renderer` +- `register-daemon-renderer` +- `register-hosted-renderer` +- `register-sidebar-renderer` +- `register-route-renderer` +- `register-extensions-enhancer` + +At the time of writing, `register-block-properties-renderer` and `register-block-renderer` are not yet present in that generated wrapper, so ClojureScript plugins may need to call them via `invoke-exper-method` until the wrapper is regenerated. + +--- + +## See Also + +- [Starter Guide](./starter_guide.md) - getting started with plugin development +- [DB Properties Guide](./db_properties_guide.md) - working with database properties +- [DB Query Guide](./db_query_guide.md) - querying the Logseq database + +--- + +## Support + +For questions and issues: + +- [Logseq Discord](https://discord.gg/logseq) - `#plugin-dev` +- [GitHub Discussions](https://github.com/logseq/logseq/discussions) +- [Plugin API Documentation](https://plugins-doc.logseq.com/) + +Remember: these are experimental features. Use them carefully and test thoroughly. diff --git a/libs/development-notes/starter_guide.md b/libs/guides/starter_guide.md similarity index 100% rename from libs/development-notes/starter_guide.md rename to libs/guides/starter_guide.md diff --git a/libs/package.json b/libs/package.json index 1810846d57..24eeb4c77c 100644 --- a/libs/package.json +++ b/libs/package.json @@ -1,7 +1,7 @@ { "name": "@logseq/libs", "packageManager": "pnpm@10.33.0", - "version": "0.3.2", + "version": "0.3.3", "description": "Logseq SDK libraries", "main": "dist/lsplugin.user.js", "typings": "index.d.ts", diff --git a/libs/src/LSPlugin.core.ts b/libs/src/LSPlugin.core.ts index f06d44ec4a..288fdee08b 100644 --- a/libs/src/LSPlugin.core.ts +++ b/libs/src/LSPlugin.core.ts @@ -51,6 +51,46 @@ import { const debug = Debug('LSPlugin:core') const DIR_PLUGINS = 'plugins' +/** + * Compact one-line diff of two plain-object settings snapshots. + * Returns e.g. `foo: 1 -> 2, bar: "x" -> "y", +baz: true, -qux`. + * Returns null when no changes are detected. + */ +function diffSettings( + prev: Record | undefined, + next: Record | undefined +): string | null { + prev = prev || {} + next = next || {} + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]) + const parts: string[] = [] + const fmt = (v: any) => { + if (v === undefined) return 'undefined' + try { + const s = JSON.stringify(v) + return s && s.length > 80 ? s.slice(0, 77) + '...' : s + } catch (_e) { return String(v) } + } + for (const k of keys) { + const a = prev[k], b = next[k] + if (a === b) continue + if (!(k in prev)) { + parts.push(`+${k}: ${fmt(b)}`) + continue + } + if (!(k in next)) { + parts.push(`-${k}`) + continue + } + // deep equality fallback via JSON to skip ref-only changes + try { + if (JSON.stringify(a) === JSON.stringify(b)) continue + } catch (_e) { /* fallthrough */ } + parts.push(`${k}: ${fmt(a)} -> ${fmt(b)}`) + } + return parts.length ? parts.join(', ') : null +} + declare global { interface Window { LSPluginCore: LSPluginCore @@ -487,7 +527,12 @@ class PluginLocal extends EventEmitter< async _setupUserSettings(reload?: boolean) { const { _options } = this - const logger = (this._logger = new PluginLogger(`Loader:${this.debugTag}`)) + // Reuse the existing logger to preserve history across reloads; + // only update the tag. + if (!this._logger) { + this._logger = new PluginLogger(`Loader`) + } + const logger = this._logger if (_options.settings && !reload && this._disposeSettingsObserver) { return @@ -512,8 +557,9 @@ class PluginLocal extends EventEmitter< settings.replace(userSettings) } - const handler = async (a) => { - debug('Settings changed', this.debugTag, a) + const handler = async (a, b) => { + const changed = diffSettings(b, a) + if (changed) logger.debug('settings changed', changed) if (a) { invokeHostExportedApi('save_plugin_user_settings', this.id, a) @@ -533,7 +579,7 @@ class PluginLocal extends EventEmitter< this._disposeSettingsObserver = disposeSettingsObserver } catch (e) { debug('[load plugin user settings Error]', e) - logger?.error(e) + logger?.error('load user settings failed', e) } } @@ -580,6 +626,7 @@ class PluginLocal extends EventEmitter< packageConfigError = 'Can not resolve package config location' } else { debug('prepare package root', url) + this._logger?.debug('prepare package root', url) try { pkg = await invokeHostExportedApi('load_plugin_config', url) @@ -667,6 +714,7 @@ class PluginLocal extends EventEmitter< }) } catch (e) { debug('[save plugin ID Error] ', e) + this._logger?.warn('save plugin id failed', e) } } } @@ -689,6 +737,7 @@ class PluginLocal extends EventEmitter< } } catch (e) { debug('[prepare package effect Error]', e) + this._logger?.error('prepare package effect failed', e) } } } @@ -736,10 +785,14 @@ class PluginLocal extends EventEmitter< dirPathInstalled ) - entry = convertToLSPResource( - withFileProtocol(path.normalize(entryPath)), - this.dotPluginsRoot - ) + entry = withFileProtocol(path.normalize(entryPath)) + + if (!this._options.effect) { + entry = convertToLSPResource( + entry, + this.dotPluginsRoot + ) + } this._options.entry = entry } @@ -901,9 +954,14 @@ class PluginLocal extends EventEmitter< }> ) { if (this.pending || this.loaded) { + this._logger?.debug('load skipped', + this.pending ? '(pending)' : '(already loaded)') return } + const t0 = performance.now() + this._logger?.info('load:start', opts?.reload ? '(reload)' : '') + this._transitionStatus(PluginLocalLoadStatus.LOADING, [ PluginLocalLoadStatus.UNLOADED, PluginLocalLoadStatus.ERROR, @@ -922,16 +980,23 @@ class PluginLocal extends EventEmitter< await installPackageThemes.call(null) } - if (this.disabled || !this.options.entry) { + if (this.disabled) { + this._logger?.info('load:skip (disabled)') + return + } + if (!this.options.entry) { + this._logger?.info('load:skip (no entry - theme-only package)') return } this._ctx.emit('beforeload', this) await this._tryToNormalizeEntry() + this._logger?.debug('entry normalized', this.options.entry) this._caller = new LSPluginCaller(this) await this._caller.connectToChild() + this._logger?.debug('sandbox connected') const readyFn = () => { this._caller?.callUserModel(LSPMSG_READY, { pid: this.id }) @@ -950,8 +1015,10 @@ class PluginLocal extends EventEmitter< this._dispose(cleanInjectedScripts.bind(this)) this._ctx.emit('loadeded', this) + this._logger?.info( + `load:done in ${(performance.now() - t0).toFixed(1)}ms`) } catch (e) { - this.logger.error('load', e, true) + this.logger.error('load:failed', e, true) this.disposeRuntime().catch(null) this._status = PluginLocalLoadStatus.ERROR @@ -970,9 +1037,11 @@ class PluginLocal extends EventEmitter< async reload() { if (this.pending) { + this._logger?.debug('reload skipped (pending)') return } + this._logger?.info('reload:start') this._ctx.emit('beforereload', this) if (this.loaded) { @@ -981,6 +1050,7 @@ class PluginLocal extends EventEmitter< await this.load({ reload: true }) this._ctx.emit('reloaded', this) + this._logger?.info('reload:done') } /** @@ -988,6 +1058,7 @@ class PluginLocal extends EventEmitter< */ async unload(unregister: boolean = false) { if (this.pending) { + this._logger?.debug('unload skipped (pending)') return } @@ -997,6 +1068,7 @@ class PluginLocal extends EventEmitter< } if (unregister) { + this._logger?.info('unregister:start') await this.unload() await this.disposeRegistration() @@ -1004,9 +1076,11 @@ class PluginLocal extends EventEmitter< this._ctx.emit('unlink-plugin', this.id) } + this._logger?.info('unregister:done') return } + this._logger?.info('unload:start') try { const eventBeforeUnload = { unregister } @@ -1022,15 +1096,16 @@ class PluginLocal extends EventEmitter< ) this.emit('beforeunload', eventBeforeUnload) } catch (e) { - this.logger.error('beforeunload', e) + this.logger.error('beforeunload hook failed', e) } await this.disposeRuntime() } this.emit('unloaded') + this._logger?.info('unload:done') } catch (e) { - this.logger.error('unload', e) + this.logger.error('unload failed', e) } finally { this._status = PluginLocalLoadStatus.UNLOADED } @@ -1041,7 +1116,7 @@ class PluginLocal extends EventEmitter< try { fn && (await fn()) } catch (e) { - console.error(this.debugTag, 'dispose Error', e) + this._logger?.error('dispose failed', e) } } } @@ -1437,6 +1512,7 @@ class LSPluginCore if (loadErr) { debug('[Failed LOAD Plugin] #', pluginOptions) + pluginLocal.logger?.error('register: load failed', loadErr, true) this.emit('error', loadErr) @@ -1497,6 +1573,10 @@ class LSPluginCore const p = this.ensurePlugin(identity) await p.reload() } catch (e) { + try { + this.getPluginLogger(identity) + ?.error('reload failed', e) + } catch (_) { /* unknown plugin */ } debug(e) } } @@ -1538,12 +1618,14 @@ class LSPluginCore if (p.pending) return if (!p.disabled && p.loaded) return + p.logger?.info('enable:start') this.emit('beforeenable') p.settings?.set('disabled', false) await p.load() this.emit('enabled', p.id) + p.logger?.info('enable:done') } async disable(plugin: PluginLocalIdentity) { @@ -1551,12 +1633,14 @@ class LSPluginCore if (p.pending) return if (p.disabled && !p.loaded) return + p.logger?.info('disable:start') this.emit('beforedisable') p.settings?.set('disabled', true) await p.unload() this.emit('disabled', p.id) + p.logger?.info('disable:done') } async _hook(ns: string, type: string, payload?: any, pid?: string) { @@ -1637,6 +1721,28 @@ class LSPluginCore return p } + /** + * Return the {@link PluginLogger} of the given plugin (if any). + * Returns undefined when the plugin is unknown. + */ + getPluginLogger(id: PluginLocalIdentity) { + try { + return this.ensurePlugin(id)?.logger + } catch (_e) { + return undefined + } + } + + /** Return structured log entries for the given plugin. */ + getPluginLogs(id: PluginLocalIdentity) { + return this.getPluginLogger(id)?.getEntries() || [] + } + + /** Clear log entries for the given plugin. */ + clearPluginLogs(id: PluginLocalIdentity) { + this.getPluginLogger(id)?.clear() + } + hostMounted() { this._hostMountedActor.resolve() } @@ -1695,6 +1801,8 @@ class LSPluginCore themes.push(opt) this.emit('themes-changed', this.themes, { id, ...opt }) + this.getPluginLogger(id) + ?.debug('theme registered', opt?.name || (opt as any)?.url || '') } async selectTheme( diff --git a/libs/src/common.ts b/libs/src/common.ts index 077dc643f2..ad72bbbbfe 100644 --- a/libs/src/common.ts +++ b/libs/src/common.ts @@ -57,65 +57,155 @@ export function deepMerge(a: Partial, b: Partial): T { return merge(a, b, { arrayMerge: overwriteArrayMerge }) } -export class PluginLogger extends EventEmitter<'change'> { - private _logs: Array<[type: string, payload: any]> = [] +export type PluginLogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' + +export interface PluginLogEntry { + ts: number + level: PluginLogLevel + tag: string + message: string +} + +export interface PluginLoggerOptions { + console?: boolean + maxSize?: number + level?: PluginLogLevel +} + +const LOG_LEVEL_WEIGHT: Record = { + DEBUG: 10, + INFO: 20, + WARN: 30, + ERROR: 40, +} + +const DEFAULT_LOG_MAX_SIZE = 500 + +function safeStringifyArg(it: any): string { + if (it == null) return String(it) + if (it instanceof Error) return `${it.message}${it.stack ? '\n' + it.stack : ''}` + if (typeof it === 'string') return it + if (typeof it === 'object') { + try { + const seen = new WeakSet() + return JSON.stringify(it, (_k, v) => { + if (typeof v === 'object' && v !== null) { + if (seen.has(v)) return '[Circular]' + seen.add(v) + } + return v + }) + } catch (_e) { + try { return String(it) } catch (_) { return '[Unserializable]' } + } + } + try { return String(it) } catch (_e) { return '[Unserializable]' } +} + +export class PluginLogger extends EventEmitter<'change' | 'append' | 'clear'> { + private _logs: PluginLogEntry[] = [] + private _maxSize: number + private _level: PluginLogLevel constructor( private _tag?: string, - private _opts?: { - console: boolean - } + private _opts?: PluginLoggerOptions ) { super() + this._maxSize = Math.max(50, _opts?.maxSize ?? DEFAULT_LOG_MAX_SIZE) + this._level = _opts?.level ?? 'DEBUG' } - write(type: string, payload: any[], inConsole?: boolean) { - if (payload?.length && true === payload[payload.length - 1]) { + /** + * Write a log entry. + * Backwards compatible: the legacy boolean tail flag in `payload` to force + * console output is still honored. + */ + write(level: PluginLogLevel | string, payload: any[], inConsole?: boolean) { + // back-compat: trailing boolean === true means "force console" + if (Array.isArray(payload) && payload.length && + payload[payload.length - 1] === true) { inConsole = true - payload.pop() + payload = payload.slice(0, -1) } - const msg = payload.reduce((ac, it) => { - if (it && it instanceof Error) { - ac += `${it.message} ${it.stack}` - } else { - ac += it.toString() - } - return ac - }, `[${this._tag}][${new Date().toLocaleTimeString()}] `) + const lvl = (typeof level === 'string' + ? (level.toUpperCase() as PluginLogLevel) + : level) as PluginLogLevel + const normalizedLevel: PluginLogLevel = + lvl in LOG_LEVEL_WEIGHT ? lvl : 'INFO' - this._logs.push([type, msg]) + // level filtering + if (LOG_LEVEL_WEIGHT[normalizedLevel] < LOG_LEVEL_WEIGHT[this._level]) { + return + } + + const message = (payload || []).map(safeStringifyArg).join(' ') + const entry: PluginLogEntry = { + ts: Date.now(), + level: normalizedLevel, + tag: this._tag || '', + message, + } + + this._logs.push(entry) + // ring buffer + if (this._logs.length > this._maxSize) { + this._logs.splice(0, this._logs.length - this._maxSize) + } if (inConsole || this._opts?.console) { - console?.['ERROR' === type ? 'error' : 'debug'](`${type}: ${msg}`) + const fn = normalizedLevel === 'ERROR' + ? 'error' + : normalizedLevel === 'WARN' + ? 'warn' + : normalizedLevel === 'DEBUG' ? 'debug' : 'info' + try { + // eslint-disable-next-line no-console + console[fn](`[${entry.tag}][${new Date(entry.ts).toLocaleTimeString()}] ${normalizedLevel}: ${message}`) + } catch (_e) { /* noop */ } } + this.emit('append', entry) this.emit('change') } clear() { this._logs = [] + this.emit('clear') this.emit('change') } - info(...args: any[]) { - this.write('INFO', args) + debug(...args: any[]) { this.write('DEBUG', args) } + info(...args: any[]) { this.write('INFO', args) } + warn(...args: any[]) { this.write('WARN', args) } + error(...args: any[]) { this.write('ERROR', args) } + + setTag(s: string) { this._tag = s } + getTag() { return this._tag } + + setLevel(l: PluginLogLevel) { + if (l in LOG_LEVEL_WEIGHT) this._level = l + } + getLevel(): PluginLogLevel { return this._level } + + setMaxSize(n: number) { + this._maxSize = Math.max(50, n | 0) + if (this._logs.length > this._maxSize) { + this._logs.splice(0, this._logs.length - this._maxSize) + this.emit('change') + } } - error(...args: any[]) { - this.write('ERROR', args) + /** Structured entries (preferred). */ + getEntries(): PluginLogEntry[] { + return this._logs.slice() } - warn(...args: any[]) { - this.write('WARN', args) - } - - setTag(s: string) { - this._tag = s - } - - toJSON() { - return this._logs + /** Legacy tuple format kept for backwards compatibility. */ + toJSON(): Array<[PluginLogLevel, string]> { + return this._logs.map((e) => [e.level, + `[${e.tag}][${new Date(e.ts).toLocaleTimeString()}] ${e.message}`]) } } diff --git a/libs/src/modules/LSPlugin.Experiments.ts b/libs/src/modules/LSPlugin.Experiments.ts index b032107643..5379ff423a 100644 --- a/libs/src/modules/LSPlugin.Experiments.ts +++ b/libs/src/modules/LSPlugin.Experiments.ts @@ -2,6 +2,43 @@ import { LSPluginUser } from '../LSPlugin.user' import { PluginLocal } from '../LSPlugin.core' import { safeSnakeCase } from '../common' +/** + * Declarative condition for matching a block's properties map. + * Operators: has, equals, in, not, any, all. + */ +export type BlockPropertiesCondition = + | { has: string } + | { equals: [string, any] } + | { in: [string, Array] } + | { not: BlockPropertiesCondition } + | { any: Array } + | { all: Array } + +export type BlockPropertiesRendererProps = { + blockId: string + properties: Record +} + +export type BlockRendererChild = Record & { + children?: Array +} + +export type BlockRendererProps = BlockPropertiesRendererProps & { + uuid?: string + page?: string + content?: string + format?: string + children?: Array +} + +export type BlockPropertiesPredicate = ( + props: BlockPropertiesRendererProps +) => boolean + +export type BlockRendererPredicate = ( + props: BlockRendererProps +) => boolean + /** * WARN: These are some experience features and might be adjusted at any time. * These unofficial plugins that use these APIs are temporarily @@ -21,19 +58,20 @@ export class LSPluginExperiments { get Components() { const exper = this.ensureHostScope().logseq.sdk.experiments return { - Editor: exper.cp_page_editor as (props: { page: string } & any) => any + Editor: exper.cp_page_editor as (props: { page: string } & any) => any, } } get Utils() { const utils = this.ensureHostScope().logseq.sdk.utils - const withCall = (name: string): (input: any) => any => utils[safeSnakeCase(name)] + const withCall = (name: string): ((input: any) => any) => + utils[safeSnakeCase(name)] return { toClj: withCall('toClj'), jsxToClj: withCall('jsxToClj'), toJs: withCall('toJs'), toKeyword: withCall('toKeyword'), - toSymbol: withCall('toSymbol') + toSymbol: withCall('toSymbol'), } } @@ -46,7 +84,8 @@ export class LSPluginExperiments { public invokeExperMethod(type: string, ...args: Array) { const host = this.ensureHostScope() type = safeSnakeCase(type)?.toLowerCase() - const fn = host.logseq.api['exper_' + type] || host.logseq.sdk.experiments[type] + const fn = + host.logseq.api['exper_' + type] || host.logseq.sdk.experiments[type] return fn?.apply(host, args) } @@ -83,7 +122,8 @@ export class LSPluginExperiments { registerDaemonRenderer( key: string, opts: { - sub?: Array, + before?: () => Promise + subs?: Array render: (props: {}) => any } ) { @@ -98,9 +138,9 @@ export class LSPluginExperiments { registerHostedRenderer( key: string, opts: { - title?: string, + title?: string subs?: Array - type?: string, + type?: string render: (props: {}) => any } ) { @@ -115,9 +155,9 @@ export class LSPluginExperiments { registerSidebarRenderer( key: string, opts: { - title?: string, + title?: string subs?: Array - render: (props: {}) => any, + render: (props: {}) => any [k: string]: any } ) { @@ -129,9 +169,9 @@ export class LSPluginExperiments { registerRouteRenderer( key: string, opts: { - name?: string, + name?: string subs?: Array - path: string, + path: string render: (props: {}) => any } ) { @@ -143,6 +183,71 @@ export class LSPluginExperiments { ) } + /** + * Register a custom renderer for the block properties area. + * The renderer is shown when the block's properties match the `when` condition. + * `when` may be either a declarative condition object or a synchronous predicate. + * + * @param key Unique key for this renderer (scoped to the plugin). + * @param opts Renderer options. + * @param opts.when Optional condition or synchronous predicate; if omitted, always matches. + * @param opts.mode "prepend" | "append" (default) | "replace". + * @param opts.priority Higher number wins when multiple replace renderers match. + * @param opts.subs Reserved subscription list for future reactive updates. + * @param opts.render React function component receiving `{ blockId, properties }`. + */ + registerBlockPropertiesRenderer( + key: string, + opts: { + when?: BlockPropertiesCondition | BlockPropertiesPredicate + mode?: 'prepend' | 'append' | 'replace' + priority?: number + subs?: Array + render: (props: BlockPropertiesRendererProps) => any + } + ) { + return this.invokeExperMethod( + 'registerBlockPropertiesRenderer', + this.ctx.baseInfo.id, + key, + opts + ) + } + + /** + * Register a custom renderer for the block body. + * When the synchronous predicate matches, the plugin renderer replaces the + * default outline view by default. Users can switch back to outline view via + * an explicit UI toggle on each matched block. + * + * @param key Unique key for this renderer (scoped to the plugin). + * @param opts Renderer options. + * @param opts.when Optional synchronous predicate; if omitted, always matches. + * @param opts.includeChildren When true, passes the block's recursive children + * tree to the renderer and hides native outline children while the plugin + * renderer is active. + * @param opts.priority Higher number wins when multiple block renderers match. + * @param opts.subs Reserved subscription list for future reactive updates. + * @param opts.render React function component receiving block renderer props. + */ + registerBlockRenderer( + key: string, + opts: { + when?: BlockRendererPredicate + includeChildren?: boolean + priority?: number + subs?: Array + render: (props: BlockRendererProps) => any + } + ) { + return this.invokeExperMethod( + 'registerBlockRenderer', + this.ctx.baseInfo.id, + key, + opts + ) + } + registerExtensionsEnhancer( type: 'katex' | 'codemirror', enhancer: (v: T) => Promise @@ -168,7 +273,7 @@ export class LSPluginExperiments { ensureHostScope(): any { try { - const _ = window.top?.document + window.top?.document } catch (_e) { console.error('Can not access host scope!') } diff --git a/src/electron/electron/handler.cljs b/src/electron/electron/handler.cljs index 28fb3b9124..7eb3a653c3 100644 --- a/src/electron/electron/handler.cljs +++ b/src/electron/electron/handler.cljs @@ -22,6 +22,7 @@ [electron.i18n :as i18n] [electron.keychain :as keychain] [electron.logger :as logger] + [electron.spell-check :as spell-check] [electron.plugin :as plugin] [electron.server :as server] [electron.shell :as shell] @@ -289,11 +290,13 @@ (defmethod handle :quitApp [] (.quit app)) -(defmethod handle :userAppCfgs [_window [_ k v]] +(defmethod handle :userAppCfgs [window [_ k v]] (let [config (cfgs/get-config)] (if-let [k (and k (keyword k))] (if-not (nil? v) (do (cfgs/set-item! k v) + (when (= k :spell-check) + (spell-check/apply-window-spellcheck! window (spell-check/session-spellcheck-enabled? v))) (state/set-state! [:config k] v)) (cfgs/get-item k)) config))) diff --git a/src/electron/electron/spell_check.cljs b/src/electron/electron/spell_check.cljs new file mode 100644 index 0000000000..4241b43d3a --- /dev/null +++ b/src/electron/electron/spell_check.cljs @@ -0,0 +1,11 @@ +(ns electron.spell-check) + +(defn session-spellcheck-enabled? + [value] + (not= false value)) + +(defn apply-window-spellcheck! + [^js win enabled?] + (when-let [^js session (some-> win .-webContents .-session)] + (.setSpellCheckerEnabled session enabled?)) + win) diff --git a/src/electron/electron/window.cljs b/src/electron/electron/window.cljs index 3111126cac..1274adf9ad 100644 --- a/src/electron/electron/window.cljs +++ b/src/electron/electron/window.cljs @@ -9,6 +9,7 @@ [electron.context-menu :as context-menu] [electron.i18n :refer [t]] [electron.logger :as logger] + [electron.spell-check :as spell-check] [electron.state :as state] [electron.utils :refer [mac? win32? linux? dev? open] :as utils])) @@ -45,7 +46,6 @@ :sandbox false :webSecurity (not dev?) :contextIsolation true - :spellcheck ((fnil identity true) (cfgs/get-item :spell-check)) ;; Remove OverlayScrollbars and transition `.scrollbar-spacing` ;; to use `scollbar-gutter` after the feature is implemented in browsers. :enableBlinkFeatures 'OverlayScrollbars' @@ -56,7 +56,9 @@ linux? (assoc :icon (node-path/join js/__dirname "icons/logseq.png"))) - win (BrowserWindow. (clj->js win-opts))] + win (BrowserWindow. (clj->js win-opts)) + spell-check-enabled? (spell-check/session-spellcheck-enabled? (cfgs/get-item :spell-check))] + (spell-check/apply-window-spellcheck! win spell-check-enabled?) (.onBeforeSendHeaders (.. session -defaultSession -webRequest) (clj->js {:urls (array "*://*.youtube.com/*")}) (fn [^js details callback] diff --git a/src/main/frontend/components/block.cljs b/src/main/frontend/components/block.cljs index 09ef019682..47133ed9de 100644 --- a/src/main/frontend/components/block.cljs +++ b/src/main/frontend/components/block.cljs @@ -81,6 +81,7 @@ [logseq.graph-parser.mldoc :as gp-mldoc] [logseq.graph-parser.text :as text] [logseq.outliner.property :as outliner-property] + [logseq.sdk.utils :as sdk-util] [logseq.shui.dialog.core :as shui-dialog] [logseq.shui.hooks :as hooks] [logseq.shui.ui :as shui] @@ -3252,6 +3253,134 @@ [block* result] [nil result]))) +(defn- build-block-renderer-children-props + [block] + (when-let [block-uuid (:block/uuid block)] + (let [repo (state/get-current-repo) + blocks (some->> (db/get-block-and-children repo block-uuid) + (map (fn [child-block] + (dissoc (db/pull (:db/id child-block)) :block.temp/load-status))))] + (or (some-> blocks + (tree/blocks->vec-tree block-uuid) + first + :block/children + sdk-util/normalize-keyword-for-json) + [])))) + +(defn- build-block-renderer-match-context + ([block] + (build-block-renderer-match-context block false)) + ([block include-children?] + (let [uuid-str (some-> (:block/uuid block) str) + page-title (or (some-> (:block/page block) :block/title) + (when (ldb/page? block) (:block/title block))) + properties-map (if-let [db-id (:db/id block)] + (->> (outliner-property/get-block-full-properties (db/get-db) db-id) + (map :db/ident) + (remove #(= % :logseq.property.class/properties)) + (map (fn [property-id] [property-id (get block property-id)])) + (into {})) + (->> (:block/properties block) + (remove (fn [[property-id _]] (= property-id :logseq.property.class/properties))) + (into {}))) + children (when include-children? + (build-block-renderer-children-props block)) + props (cond-> {:blockId uuid-str + :properties (into {} (map (fn [[k v]] + [(subs (str k) 1) + (plugin-handler/serialize-property-value-for-plugin v)]) + properties-map))} + uuid-str (assoc :uuid uuid-str) + page-title (assoc :page page-title) + (:block/title block) (assoc :content (:block/title block)) + (get block :block/format :markdown) (assoc :format (name (get block :block/format :markdown))) + include-children? (assoc :children children))] + {:block-id uuid-str + :uuid uuid-str + :page page-title + :content (:block/title block) + :format (some-> (get block :block/format :markdown) name) + :properties-map properties-map + :props (clj->js props)}))) + +(defn- block-renderer-supported-view? + [{:keys [sidebar?]} property? table?] + (and (not sidebar?) + (not property?) + (not table?))) + +(defn- block-renderer-display-mode + [{:keys [matched-block-renderer use-plugin-renderer? editing? plugin-renderer-error?]}] + (if (and matched-block-renderer use-plugin-renderer? (not editing?) (not plugin-renderer-error?)) + :plugin + :outline)) + +(defn- show-block-renderer-plugin-toggle? + [display-mode {:keys [matched-block-renderer editing?]}] + (boolean (and matched-block-renderer (not editing?) (= :outline display-mode)))) + +(defn- show-block-renderer-outline-toggle? + [display-mode] + (= :plugin display-mode)) + +(rum/defc setup-plugin-renderer-effects! + [editing? switch-to-plugin-renderer!] + (let [*previous-editing? (hooks/use-ref editing?)] + (hooks/use-effect! + (fn [] + (let [previous-editing? (.-current *previous-editing?)] + (when (and previous-editing? (not editing?)) + (switch-to-plugin-renderer!)) + (set! (.-current *previous-editing?) editing?)) + (fn [])) + [editing?]) + [:<>])) + +(defn- block-renderer-hides-outline-children? + [display-mode {:keys [matched-block-renderer]}] + (boolean (and (= :plugin display-mode) + (true? (:include-children matched-block-renderer))))) + +(defn- block-renderer-outline-view + [config block uuid title table? property? edit-input-id editing? refs-count *hide-block-refs? *show-query? page-icon block-id collapsed?] + [:div.flex.flex-col.w-full + [:div.block-main-content.flex.flex-row.gap-2 + (when page-icon + page-icon) + + [:div.flex.flex-col.w-full + (let [parsed-block (merge block (block/parse-title-and-body uuid (get block :block/format :markdown) title)) + hide-block-refs-count? (or (and (:embed? config) + (= (:block/uuid parsed-block) (:embed-id config))) + table?)] + (block-content-or-editor config + parsed-block + {:edit-input-id edit-input-id + :block-id block-id + :edit? editing? + :refs-count refs-count + :*hide-block-refs? *hide-block-refs? + :hide-block-refs-count? hide-block-refs-count? + :*show-query? *show-query?}))]] + + (when (and (not collapsed?) (not (or table? property?))) + (block-positioned-properties config block :block-below)) + + (when-not (or (:table? config) (:property? config)) + (block-reactions block))]) + +(rum/defcs block-renderer-error-boundary + < {:init (fn [state] + (assoc state ::on-error (some-> state :rum/args first :on-error))) + :did-catch (fn [state error _info] + (when-let [on-error (::on-error state)] + (on-error error)) + (assoc state ::error error))} + [{error ::error} {:keys [fallback-view]} view] + (if (some? error) + fallback-view + view)) + (rum/defcs ^:large-vars/cleanup-todo block-container-inner-aux < rum/reactive db-mixins/query {:init (fn [state] (let [*ref (atom nil) @@ -3270,18 +3399,31 @@ ::ref *ref ::hide-block-refs? (atom default-hide?) ::show-query? (atom false) - ::refs-count *refs-count)))} + ::refs-count *refs-count + ::plugin-renderer-error? (atom false) + ::use-plugin-renderer? (atom true))))} (mixins/event-mixin (fn [state] (let [*ref (::ref state)] - ;; React doesn't let us directly control passive via onTouchMove - ;; So here we listen `touchmove` on the block node + ;; React doesn't let us directly control passive via onTouchMove + ;; So here we listen `touchmove` on the block node (mixins/listen state @*ref "touchmove" block-handler/on-touch-move)))) [state container-state repo config* block {:keys [navigating-block navigated? editing? selected?] :as opts}] (let [*ref (::ref state) *hide-block-refs? (get state ::hide-block-refs?) *show-query? (get state ::show-query?) show-query? (rum/react *show-query?) + *plugin-renderer-error? (get state ::plugin-renderer-error?) + *use-plugin-renderer? (get state ::use-plugin-renderer?) + plugin-renderer-error? (rum/react *plugin-renderer-error?) + use-plugin-renderer? (rum/react *use-plugin-renderer?) + switch-to-plugin-renderer! (fn [] + (reset! *plugin-renderer-error? false) + (reset! *use-plugin-renderer? true)) + switch-to-outline-view! (fn [] + (reset! *plugin-renderer-error? false) + (reset! *use-plugin-renderer? false)) + set-plugin-renderer-error! #(reset! *plugin-renderer-error? %) *refs-count (get state ::refs-count) hide-block-refs? (rum/react *hide-block-refs?) refs-count (rum/react *refs-count) @@ -3303,10 +3445,13 @@ *control-show? (get container-state ::control-show?) db-collapsed? (util/collapsed? block) collapsed? (cond + (:ignore-block-collapsed? config) + false + (or ref-or-custom-query? - (:view? config) - (root-block? config block) - (and (or (ldb/class? block) (ldb/property? block)) (:page-title? config))) + (:view? config) + (root-block? config block) + (and (or (ldb/class? block) (ldb/property? block)) (:page-title? config))) (state/sub-block-collapsed uuid container-id) :else @@ -3328,82 +3473,125 @@ page-icon (when (:page-title? config) (let [icon' (get block :logseq.property/icon)] (when-let [icon (and (ldb/page? block) - (or icon' - (some :logseq.property/icon (:block/tags block)) - (when (ldb/class? block) - {:type :tabler-icon - :id "hash"}) - (when (ldb/property? block) - {:type :tabler-icon - :id "letter-p"})))] + (or icon' + (some :logseq.property/icon (:block/tags block)) + (when (ldb/class? block) + {:type :tabler-icon + :id "hash"}) + (when (ldb/property? block) + {:type :tabler-icon + :id "letter-p"})))] [:div.ls-page-icon.flex.self-start (icon-component/icon-picker icon - {:on-chosen (fn [_e icon] - (if icon - (db-property-handler/set-block-property! - (:db/id block) - :logseq.property/icon - (select-keys icon [:id :type :color])) - ;; del - (db-property-handler/remove-block-property! - (:db/id block) - :logseq.property/icon))) - :del-btn? (boolean icon') - :icon-props {:style {:width "1lh" - :height "1lh" - :font-size (cond - (and (util/mobile?) (:page-title? config)) 24 - (:page-title? config) 38 - :else 18)}}})])))] + {:on-chosen (fn [_e icon] + (if icon + (db-property-handler/set-block-property! + (:db/id block) + :logseq.property/icon + (select-keys icon [:id :type :color])) + ;; del + (db-property-handler/remove-block-property! + (:db/id block) + :logseq.property/icon))) + :del-btn? (boolean icon') + :icon-props {:style {:width "1lh" + :height "1lh" + :font-size (cond + (and (util/mobile?) (:page-title? config)) 24 + (:page-title? config) 38 + :else 18)}}})]))) + ;; --- block renderer (full-block plugin replacement) --- + block-renderer-base-match-context + (when (and config/lsp-enabled? + (plugin-handler/any-block-renderers?) + (block-renderer-supported-view? config property? table?)) + (build-block-renderer-match-context block false)) + matched-block-renderer + (when (and (:props block-renderer-base-match-context) (not editing?)) + (plugin-handler/get-matched-block-renderer block-renderer-base-match-context)) + block-renderer-match-context + (if (true? (:include-children matched-block-renderer)) + (build-block-renderer-match-context block true) + block-renderer-base-match-context) + block-renderer-props-js (:props block-renderer-match-context) + renderer-display-mode + (block-renderer-display-mode {:matched-block-renderer matched-block-renderer + :use-plugin-renderer? use-plugin-renderer? + :editing? editing? + :plugin-renderer-error? plugin-renderer-error?}) + switch-to-plugin-renderer-title + (t (if plugin-renderer-error? + :block/retry-plugin-renderer + :block/switch-to-plugin-renderer)) + switch-to-outline-view-title (t :block/switch-to-outline-view) + outline-view-cp + [:div.flex.flex-col.w-full + (block-renderer-outline-view config block uuid title table? property? edit-input-id editing? refs-count *hide-block-refs? *show-query? page-icon block-id collapsed?) + (when (show-block-renderer-plugin-toggle? + renderer-display-mode + {:matched-block-renderer matched-block-renderer + :editing? editing?}) + (shui/button + {:variant :ghost + :size :icon + :class "self-start h-5 w-5 opacity-20 hover:opacity-70" + :title switch-to-plugin-renderer-title + :aria-label switch-to-plugin-renderer-title + :on-pointer-down util/stop + :on-click (fn [e] + (util/stop e) + (switch-to-plugin-renderer!))} + (shui/tabler-icon "puzzle-piece" {:size 13})))]] + [:div.ls-block.swipe-item (cond-> - {:id (str "ls-block-" - ;; container-id "-" - uuid) - :blockid (str uuid) - :containerid container-id - :data-is-property (ldb/property? block) - :ref #(when (nil? @*ref) (reset! *ref %)) - :data-collapsed (and collapsed? has-child?) - :class (str (when selected? "selected") - (when (ldb/recycled? block) " line-through opacity-70") - (when order-list? " is-order-list") - (when (string/blank? title) " is-blank") - (when original-block " embed-block")) - :haschild (str (boolean has-child?)) - :on-touch-start (fn [event uuid] - (when-not (or @*dragging? (state/editing?)) - (block-handler/on-touch-start event uuid))) - :on-touch-end (fn [event] - (when-not @*dragging? - (block-handler/on-touch-end event)) - (reset! *dragging? false)) - :on-touch-cancel (fn [e] - (block-handler/on-touch-cancel e))} + {:id (str "ls-block-" + ;; container-id "-" + uuid) + :blockid (str uuid) + :containerid container-id + :data-is-property (ldb/property? block) + :ref #(when (nil? @*ref) (reset! *ref %)) + :data-collapsed (and collapsed? has-child?) + :class (str (when selected? "selected") + (when (ldb/recycled? block) " line-through opacity-70") + (when order-list? " is-order-list") + (when (string/blank? title) " is-blank") + (when original-block " embed-block")) + :haschild (str (boolean has-child?)) + :on-touch-start (fn [event uuid] + (when-not (or @*dragging? (state/editing?)) + (block-handler/on-touch-start event uuid))) + :on-touch-end (fn [event] + (when-not @*dragging? + (block-handler/on-touch-end event)) + (reset! *dragging? false)) + :on-touch-cancel (fn [e] + (block-handler/on-touch-cancel e))} (and (util/capacitor?) (not (ldb/page? block))) (assoc - :draggable true - :on-drag-start - (fn [event] - (when-not (state/editing?) - (util/stop-propagation event) - (let [target ^js (.-target event) - blocks (or (seq (state/get-selection-blocks)) [target]) - multiple? (> (count blocks) 1) - element (when multiple? - (let [element (dom/create-element "div")] - (-> element - (dom/set-attr! "id" "dragging-ghost-element") - (dom/set-text! (t :editor/moving-blocks-count (count blocks))) - (dom/set-class! "p-2 rounded text-sm")) - element))] - (doseq [block blocks] - (dom/add-class! block "dragging")) - (on-drag-start event block block-id) - (when element - (dom/append! js/document.body element) - (dnd/set-drag-image! event element (/ (.-offsetWidth target) 2) (/ (.-offsetHeight target) 2))))))) + :draggable true + :on-drag-start + (fn [event] + (when-not (state/editing?) + (util/stop-propagation event) + (let [target ^js (.-target event) + blocks (or (seq (state/get-selection-blocks)) [target]) + multiple? (> (count blocks) 1) + element (when multiple? + (let [element (dom/create-element "div")] + (-> element + (dom/set-attr! "id" "dragging-ghost-element") + (dom/set-text! (t :editor/moving-blocks-count (count blocks))) + (dom/set-class! "p-2 rounded text-sm")) + element))] + (doseq [block blocks] + (dom/add-class! block "dragging")) + (on-drag-start event block block-id) + (when element + (dom/append! js/document.body element) + (dnd/set-drag-image! event element (/ (.-offsetWidth target) 2) (/ (.-offsetHeight target) 2))))))) (:property-default-value? config) (assoc :data-is-property-default-value (:property-default-value? config)) @@ -3453,48 +3641,49 @@ (when (and (not property?) (not (:table-block-title? config))) (let [edit? (or editing? - (= uuid (:block/uuid (state/get-edit-block))))] + (= uuid (:block/uuid (state/get-edit-block))))] (block-control (assoc config :hide-bullet? (:page-title? config)) - block - (merge opts - {:uuid uuid - :block-id block-id - :collapsed? collapsed? - :*control-show? *control-show? - :edit? edit?})))) + block + (merge opts + {:uuid uuid + :block-id block-id + :collapsed? collapsed? + :*control-show? *control-show? + :edit? edit?})))) - [:div.flex.flex-col.w-full - [:div.block-main-content.flex.flex-row.gap-2 - (when page-icon - page-icon) + (if (= :plugin renderer-display-mode) + ;; --- Plugin renderer: full-block replacement --- + [:div.block-renderer-container.flex.flex-col.w-full + (when (show-block-renderer-outline-toggle? renderer-display-mode) + [:div.block-renderer-action-bar + (shui/button + {:variant :outline + :class "block-renderer-action-btn h-6 w-6" + :title switch-to-outline-view-title + :aria-label switch-to-outline-view-title + :on-pointer-down util/stop + :on-click (fn [e] + (util/stop e) + (switch-to-outline-view!))} + (shui/tabler-icon "list" {:size 13}))]) + [:div.ls-block-plugin-renderer + (rum/with-key + (block-renderer-error-boundary + {:on-error (fn [_error] + (set-plugin-renderer-error! true)) + :fallback-view outline-view-cp} + (when-some [renderer (:render matched-block-renderer)] + (js/React.createElement renderer block-renderer-props-js))) + (str "block-renderer-" (:key matched-block-renderer) "-" uuid))]] - ;; Not embed self - [:div.flex.flex-col.w-full - (let [block (merge block (block/parse-title-and-body uuid (get block :block/format :markdown) title)) - hide-block-refs-count? (or (and (:embed? config) - (= (:block/uuid block) (:embed-id config))) - table?)] - (block-content-or-editor config - block - {:edit-input-id edit-input-id - :block-id block-id - :edit? editing? - :refs-count refs-count - :*hide-block-refs? *hide-block-refs? - :hide-block-refs-count? hide-block-refs-count? - :*show-query? *show-query?}))]] - - (when (and (not collapsed?) (not (or table? property?))) - (block-positioned-properties config block :block-below)) - - (when-not (or (:table? config) (:property? config)) - (block-reactions block))]]) + ;; --- Original outline --- + outline-view-cp)]) (when (and (not (:library? config)) - (or (:tag-dialog? config) - (and - (not collapsed?) - (not (or table? property?))))) + (or (:tag-dialog? config) + (and + (not collapsed?) + (not (or table? property?))))) [:div (when-not (:page-title? config) {:style {:padding-left (if (util/mobile?) 12 45)}}) (db-properties-cp config block {:in-block-container? true})]) @@ -3510,15 +3699,15 @@ (block-container config query)])])) (when (and (not (or (:table? config) (:property? config))) - (not hide-block-refs?) - (> refs-count 0) - (not (:page-title? config))) + (not hide-block-refs?) + (> refs-count 0) + (not (:page-title? config))) (when-let [refs-cp (state/get-component :block/linked-references)] [:div.px-4.py-2.border.rounded.my-2.shadow-xs {:style {:margin-left 42}} (refs-cp block {})])) (when (and (not collapsed?) (not (or table? property?)) - (ldb/class-instance? (entity-plus/entity-memoized (db/get-db) :logseq.class/Query) block)) + (ldb/class-instance? (entity-plus/entity-memoized (db/get-db) :logseq.class/Query) block)) (let [query-block (:logseq.property/query (db/entity (:db/id block))) query-block (if query-block (db/sub-block (:db/id query-block)) query-block) query (:block/title query-block) @@ -3527,20 +3716,28 @@ (when query-block [:div {:style {:padding-left 42}} (query/custom-query (wrap-query-components (assoc config - :dsl-query? (not advanced-query?) - :cards? (ldb/class-instance? (entity-plus/entity-memoized - (db/get-db) - :logseq.class/Cards) block))) - (if advanced-query? result {:builder nil - :query (query-builder-component/sanitize-q query)}))]))) + :dsl-query? (not advanced-query?) + :cards? (ldb/class-instance? (entity-plus/entity-memoized + (db/get-db) + :logseq.class/Cards) block))) + (if advanced-query? result {:builder nil + :query (query-builder-component/sanitize-q query)}))]))) - (when-not (or (:hide-children? config) table? property?) + (when-not (or (:hide-children? config) + table? + property? + (block-renderer-hides-outline-children? + renderer-display-mode + {:matched-block-renderer matched-block-renderer})) (let [config' (-> (update config :level inc) - (dissoc :original-block :data))] + (dissoc :original-block :data))] (block-children config' block children collapsed?))) (when-not (or table? property?) - (dnd-separator-wrapper block block-id false))])) + (dnd-separator-wrapper block block-id false)) + + (when config/lsp-enabled? + (setup-plugin-renderer-effects! editing? switch-to-plugin-renderer!))])) (rum/defc block-container-inner [container-state repo config* block opts] @@ -3644,21 +3841,25 @@ (rum/defc block-container [config block* & {:as opts}] (let [[block set-block!] (hooks/use-state block*) - id (or (:db/id block*) (:block/uuid block*))] - (when-not (or (:page-title? config) - (:view? config)) - (hooks/use-effect! - (fn [] + id (or (:db/id block*) (:block/uuid block*)) + temporary-collapsed-state (state/get-block-collapsed (:block/uuid block) + (:container-id config)) + ignore-block-collapsed? (:ignore-block-collapsed? config) + load-children? (editor-handler/load-children? block + temporary-collapsed-state + ignore-block-collapsed?)] + (hooks/use-effect! + (fn [] + (when-not (or (:page-title? config) (:view? config)) (p/let [block (db-async/hiccup - [blocks config option] - [:div.content - (cond-> option - (:document/mode? config) (assoc :class "doc-mode")) - (cond - (and (:custom-query? config) (:group-by-page? config)) - [:div.flex.flex-col - (let [blocks (sort-by (comp :block/journal-day first) > blocks)] - (for [[page blocks] blocks] - (let [alias? (:block/alias? page) - page (db/entity (:db/id page)) - blocks (tree/non-consecutive-blocks->vec-tree blocks) - parent-blocks (group-by :block/parent blocks)] - [:div.custom-query-page-result {:key (str "page-" (:db/id page))} - (ui/foldable - [:div - (page-cp config page) - (when alias? [:span.text-sm.font-medium.opacity-50 (str " " (t :property.built-in/alias))])] - (fn [] - (let [{top-level-blocks true others false} (group-by - (fn [b] (= (:db/id page) (:db/id (first b)))) - parent-blocks) - sorted-parent-blocks (concat top-level-blocks others)] - (for [[parent blocks] sorted-parent-blocks] - (let [top-level? (= (:db/id parent) (:db/id page))] - (rum/with-key - (breadcrumb-with-container blocks (assoc config :top-level? top-level?)) - (:db/id parent)))))) - {:debug-id page})])))] + (defn ->hiccup + [blocks config option] + [:div.content + (cond-> option + (:document/mode? config) (assoc :class "doc-mode")) + (cond + (and (:custom-query? config) (:group-by-page? config)) + [:div.flex.flex-col + (let [blocks (sort-by (comp :block/journal-day first) > blocks)] + (for [[page blocks] blocks] + (let [alias? (:block/alias? page) + page (db/entity (:db/id page)) + blocks (tree/non-consecutive-blocks->vec-tree blocks) + parent-blocks (group-by :block/parent blocks)] + [:div.custom-query-page-result {:key (str "page-" (:db/id page))} + (ui/foldable + [:div + (page-cp config page) + (when alias? [:span.text-sm.font-medium.opacity-50 (str " " (t :property.built-in/alias))])] + (fn [] + (let [{top-level-blocks true others false} (group-by + (fn [b] (= (:db/id page) (:db/id (first b)))) + parent-blocks) + sorted-parent-blocks (concat top-level-blocks others)] + (for [[parent blocks] sorted-parent-blocks] + (let [top-level? (= (:db/id parent) (:db/id page))] + (rum/with-key + (breadcrumb-with-container blocks (assoc config :top-level? top-level?)) + (:db/id parent)))))) + {:debug-id page})])))] - (and (:ref? config) (:group-by-page? config) (vector? (first blocks))) - [:div.flex.flex-col.references-blocks-wrap - (let [blocks (sort-by (comp :block/journal-day first) > blocks) - scroll-container (or (:scroll-container config) - (util/app-scroll-container-node)) - scroll-container (if (fn? scroll-container) - (scroll-container) scroll-container)] - (when (seq blocks) - (if (:sidebar? config) - (for [block blocks] - (rum/with-key - (ref-block-container config block) - (str "ref-" (:container-id config) "-" (:db/id (first block))))) - (ui/virtualized-list - {:custom-scroll-parent scroll-container - :compute-item-key (fn [idx] - (let [block (nth blocks idx)] - (str "ref-" (:container-id config) "-" (:db/id (first block))))) - :total-count (count blocks) - :item-content (fn [idx] - (let [block (nth blocks idx)] - (ref-block-container config block)))}))))] + (and (:ref? config) (:group-by-page? config) (vector? (first blocks))) + [:div.flex.flex-col.references-blocks-wrap + (let [blocks (sort-by (comp :block/journal-day first) > blocks) + scroll-container (or (:scroll-container config) + (util/app-scroll-container-node)) + scroll-container (if (fn? scroll-container) + (scroll-container) scroll-container)] + (when (seq blocks) + (if (:sidebar? config) + (for [block blocks] + (rum/with-key + (ref-block-container config block) + (str "ref-" (:container-id config) "-" (:db/id (first block))))) + (ui/virtualized-list + {:custom-scroll-parent scroll-container + :compute-item-key (fn [idx] + (let [block (nth blocks idx)] + (str "ref-" (:container-id config) "-" (:db/id (first block))))) + :total-count (count blocks) + :item-content (fn [idx] + (let [block (nth blocks idx)] + (ref-block-container config block)))}))))] - (and (:group-by-page? config) - (vector? (first blocks))) - [:div.flex.flex-col - (let [blocks (sort-by (comp :block/journal-day first) > blocks)] - (for [[page blocks] blocks] - (let [blocks (remove nil? blocks)] - (when (seq blocks) - (let [alias? (:block/alias? page) - page (db/entity (:db/id page))] - [:div.my-2 {:key (str "page-" (:db/id page))} - (ui/foldable - [:div - (page-cp config page) - (when alias? [:span.text-sm.font-medium.opacity-50 (str " " (t :property.built-in/alias))])] - (fn [] - (blocks-container config blocks)) - {})])))))] + (and (:group-by-page? config) + (vector? (first blocks))) + [:div.flex.flex-col + (let [blocks (sort-by (comp :block/journal-day first) > blocks)] + (for [[page blocks] blocks] + (let [blocks (remove nil? blocks)] + (when (seq blocks) + (let [alias? (:block/alias? page) + page (db/entity (:db/id page))] + [:div.my-2 {:key (str "page-" (:db/id page))} + (ui/foldable + [:div + (page-cp config page) + (when alias? [:span.text-sm.font-medium.opacity-50 (str " " (t :property.built-in/alias))])] + (fn [] + (blocks-container config blocks)) + {})])))))] - :else - (blocks-container config blocks))]) + :else + (blocks-container config blocks))]) diff --git a/src/main/frontend/components/block.css b/src/main/frontend/components/block.css index 62933023ca..206f7f201c 100644 --- a/src/main/frontend/components/block.css +++ b/src/main/frontend/components/block.css @@ -1299,6 +1299,24 @@ html.is-mac { @apply relative inline-block w-full; } +.block-renderer-container { + @apply relative; + + .block-renderer-action-bar { + @apply top-1 right-1 absolute z-10 flex items-center opacity-0 transition-opacity; + } + + .block-renderer-action-btn { + @apply m-1 p-0.5 opacity-70 select-none hover:opacity-90 active:opacity-60; + } + + &:hover, &:active, &:focus-within { + .block-renderer-action-bar { + @apply opacity-100; + } + } +} + .asset-transfer-placeholder { @apply text-sm text-gray-11 mt-2; } diff --git a/src/main/frontend/components/plugin_logs.cljs b/src/main/frontend/components/plugin_logs.cljs new file mode 100644 index 0000000000..95d22e8919 --- /dev/null +++ b/src/main/frontend/components/plugin_logs.cljs @@ -0,0 +1,169 @@ +(ns frontend.components.plugin-logs + "Viewer panel for a single plugin's logs powered by PluginLogger." + (:require [cljs-bean.core :as bean] + [clojure.string :as string] + [frontend.context.i18n :refer [t]] + [frontend.handler.notification :as notification] + [frontend.ui :as ui] + [frontend.util :as util] + [logseq.shui.hooks :as hooks] + [logseq.shui.ui :as shui] + [rum.core :as rum])) + +(def ^:private levels ["DEBUG" "INFO" "WARN" "ERROR"]) + +(defn- ^js get-plugin-logger + [pid] + (when-let [^js core (and pid (.-LSPluginCore js/window))] + (try (.getPluginLogger core pid) (catch :default _ nil)))) + +(defn- get-entries + [pid] + (when-let [^js logger (get-plugin-logger pid)] + (->> (.getEntries logger) + (bean/->clj)))) + +(defn- format-time + [ts] + (let [d (js/Date. ts)] + (.toLocaleTimeString d))) + +(defn- entry->text + [{:keys [ts level tag message]}] + (str "[" (format-time ts) "] " level " [" tag "] " message)) + +(defn- copy-all! + [entries] + (-> (string/join "\n" (map entry->text entries)) + (util/copy-to-clipboard!)) + (notification/show! (t :plugin/logs-copied) :success)) + +(rum/defc ^:large-vars/cleanup-todo plugin-logs-panel + [{:keys [pid name]}] + (let [[entries set-entries!] (rum/use-state (or (get-entries pid) [])) + [level-filter set-level-filter!] (rum/use-state nil) + [keyword-filter set-keyword-filter!] (rum/use-state "") + ^js logger (get-plugin-logger pid) + refresh! (hooks/use-callback + (fn [] (set-entries! (or (get-entries pid) []))) + [pid])] + + (hooks/use-effect! + (fn [] + (when logger + (let [h (fn [] (refresh!))] + (.on logger "change" h) + #(.off logger "change" h)))) + [logger refresh!]) + + (let [filtered (cond->> entries + (seq level-filter) + (filter #(= (:level %) level-filter)) + + (not (string/blank? keyword-filter)) + (filter #(string/includes? + (string/lower-case (str (:message %))) + (string/lower-case keyword-filter))) + + ;; newest first + :always reverse)] + [:div.cp__plugins-logs.flex.flex-col.gap-3 + {:style {:min-width "720px" :max-width "960px"}} + + ;; Row 1 - title + meta + [:div.cp__plugins-logs-head.flex.items-center.justify-between.gap-3.flex-wrap + [:h1.text-lg.font-semibold.flex.items-center.gap-2.m-0 + (ui/icon "file-description") + [:span (t :plugin/logs-title)] + (when name + [:code.opacity-70.text-xs.px-1.5.py-0.5.rounded.bg-gray-03 name])] + + [:div.flex.items-center.gap-3.text-xs.opacity-70 + [:span (str (count filtered) " / " (count entries))] + (when logger + [:span.flex.items-center.gap-1 + "level:" [:code (.getLevel logger)]])]] + + ;; Row 2 - controls + [:div.cp__plugins-logs-toolbar.flex.items-center.gap-2.flex-wrap + ;; keyword filter (flex-grow) + [:div.relative.flex-1.min-w-48.flex.items-center + [:span.absolute.opacity-50.pointer-events-none.flex.items-center + {:style {:left "8px" :top "0" :bottom "0"}} + (ui/icon "search" {:size 14})] + [:input.form-input.text-xs.h-8.w-full + {:style {:paddingLeft "28px"} + :placeholder (t :plugin/logs-filter-placeholder) + :value keyword-filter + :on-change #(set-keyword-filter! (util/evalue %))}]] + + ;; level filter + (shui/select + {:value (or level-filter "*") + :on-value-change (fn [v] (set-level-filter! (if (= v "*") nil v)))} + (shui/select-trigger + {:class "w-32 h-8 text-xs shrink-0"} + (shui/select-value {:placeholder (t :plugin/logs-level-all)})) + (shui/select-content + (shui/select-item {:value "*"} (t :plugin/logs-level-all)) + (for [lvl levels] + (shui/select-item {:value lvl :key lvl} lvl)))) + + [:div.h-5.w-px.bg-gray-05.shrink-0] + + ;; copy (chronological order for readability when pasted) + (shui/button + {:size :sm :variant :outline + :class "h-8 shrink-0" + :on-click #(copy-all! (reverse filtered)) + :title (t :plugin/logs-copy)} + (ui/icon "copy" {:size 14}) + [:span.ml-1.5 (t :plugin/logs-copy)]) + + ;; clear + (shui/button + {:size :sm :variant :outline + :class "h-8 shrink-0" + :on-click (fn [] + (when logger + (.clear logger) + (refresh!))) + :title (t :plugin/logs-clear)} + (ui/icon "trash" {:size 14}) + [:span.ml-1.5 (t :plugin/logs-clear)])] + + ;; Body - log list + [:div.cp__plugins-logs-body.text-xs.font-mono.rounded.border + {:style {:height "60vh" :overflow "auto" :padding "8px" + :background "var(--ls-secondary-background-color)"}} + (if (empty? filtered) + [:div.opacity-60.p-4.text-center (t :plugin/logs-empty)] + (for [[idx e] (map-indexed vector filtered)] + [:div.cp__plugins-logs-row.flex.gap-1.py-0.5.items-start + {:key (str idx "-" (:ts e)) + :data-level (:level e)} + [:span.opacity-60.shrink-0 {:style {:width "80px"}} + (format-time (:ts e))] + [:span.shrink-0.font-bold + {:style {:width "56px" + :color (case (:level e) + "ERROR" "var(--ls-error-text-color, #ef4444)" + "WARN" "#d97706" + "INFO" "var(--ls-active-primary-color)" + "DEBUG" "var(--ls-icon-color)" + nil)}} + (:level e)] + [:span.opacity-70.shrink-0 (str "[" (:tag e) "]")] + [:span.whitespace-pre-wrap.break-all.flex-1 (:message e)]]))]]))) + +(defn open-plugin-logs! + [{:keys [_pid _name] :as opts}] + (shui/dialog-open! + (fn [] (plugin-logs-panel opts)) + {:label "plugin-logs-modal" + :class "lsp-plugin-logs-dialog" + :content-props {:on-open-auto-focus #(.preventDefault %)}})) + + + + diff --git a/src/main/frontend/components/plugins.cljs b/src/main/frontend/components/plugins.cljs index cde8bf2f05..8a852b9573 100644 --- a/src/main/frontend/components/plugins.cljs +++ b/src/main/frontend/components/plugins.cljs @@ -3,6 +3,7 @@ [clojure.string :as string] [electron.ipc :as ipc] [frontend.components.plugins-settings :as plugins-settings] + [frontend.components.plugin-logs :as plugin-logs] [frontend.components.svg :as svg] [frontend.config :as config] [frontend.context.i18n :refer [interpolate-rich-text interpolate-rich-text-node t]] @@ -258,6 +259,7 @@ [:strong (ui/icon "settings")] [:ul.menu-list [:li {:on-click #(plugin-handler/open-plugin-settings! id false)} (t :plugin/open-settings)] + [:li {:on-click #(plugin-logs/open-plugin-logs! {:pid id :name name})} (t :plugin/open-logs)] (when (util/electron?) [:li {:on-click #(js/apis.openPath url)} (t :plugin/open-package)]) [:li {:on-click #(plugin-handler/open-report-modal! id name)} (t :plugin/report-security)] diff --git a/src/main/frontend/components/property.cljs b/src/main/frontend/components/property.cljs index eecc1a98af..683c57743b 100644 --- a/src/main/frontend/components/property.cljs +++ b/src/main/frontend/components/property.cljs @@ -16,6 +16,7 @@ [frontend.db.model :as db-model] [frontend.handler.db-based.property :as db-property-handler] [frontend.handler.notification :as notification] + [frontend.handler.plugin :as plugin-handler] [frontend.handler.property :as property-handler] [frontend.handler.route :as route-handler] [frontend.mixins :as mixins] @@ -65,6 +66,13 @@ property) (notification/show! (t :property.validation/invalid-name) :error))))) +(defn- enable-block-properties-renderers? + [{:keys [sidebar? sidebar-properties?]} class?] + (and config/lsp-enabled? + (not class?) + (not sidebar?) + (not sidebar-properties?))) + ;; TODO: This component should be cleaned up as it's only used for new properties and used to be used for existing properties (rum/defcs property-type-select < shortcut/disable-all-shortcuts @@ -827,28 +835,66 @@ :else (let [remove-properties #{:logseq.property/icon :logseq.property/query} properties' (->> (remove (fn [[k _v]] (contains? remove-properties k)) - full-properties) - (remove (fn [[k _v]] (= k :logseq.property.class/properties)))) + full-properties) + (remove (fn [[k _v]] (= k :logseq.property.class/properties)))) page? (entity-util/page? block) - class? (entity-util/class? block)] + class? (entity-util/class? block) + plugin-properties (->> (concat full-properties hidden-properties) + (remove (fn [[k _v]] (= k :logseq.property.class/properties))) + (into {})) + props-for-plugin (when (enable-block-properties-renderers? opts class?) + (clj->js {:blockId (str (:block/uuid block)) + :properties (into {} (map (fn [[k v]] + [(subs (str k) 1) + (plugin-handler/serialize-property-value-for-plugin v)]) + plugin-properties))})) + plugin-renderers (when props-for-plugin + (plugin-handler/get-matched-block-properties-renderers + {:block-id (str (:block/uuid block)) + :properties-map plugin-properties + :props props-for-plugin})) + prepend-renderers (filter #(= "prepend" (:mode %)) plugin-renderers) + replace-renderer (first (filter #(= "replace" (:mode %)) plugin-renderers)) + append-renderers (remove #(contains? #{"prepend" "replace"} (:mode %)) plugin-renderers)] + [:div.ls-properties-area {:id id :class (util/classnames [{:ls-page-properties page?}]) :tab-index 0} [:<> - (properties-section block properties' opts) - (bidirectional-properties-section bidirectional-properties) + (mapv (fn [r] + (when (fn? (:render r)) + (rum/with-key + (js/React.createElement (:render r) props-for-plugin) + (str "plugin-prepend-" (:key r))))) + prepend-renderers) + + (if replace-renderer + (when (fn? (:render replace-renderer)) + (rum/with-key + (js/React.createElement (:render replace-renderer) props-for-plugin) + (str "plugin-replace-" (:key replace-renderer)))) + [:<> + (properties-section block properties' opts) + (bidirectional-properties-section bidirectional-properties)]) (when-not class? (hidden-properties-cp block hidden-properties - (assoc opts :root-block? root-block?))) + (assoc opts :root-block? root-block?))) (when (and page? (not class?)) (rum/with-key (new-property block opts) (str id "-add-property"))) + (mapv (fn [r] + (when (fn? (:render r)) + (rum/with-key + (js/React.createElement (:render r) props-for-plugin) + (str "plugin-append-" (:key r))))) + append-renderers) + (when class? (let [properties (->> (:logseq.property.class/properties block) - (map (fn [e] [(:db/ident e)]))) + (map (fn [e] [(:db/ident e)]))) opts' (assoc opts :class-schema? true)] [:div.flex.flex-col.gap-1 [:div {:style {:font-size 15}} @@ -860,5 +906,5 @@ [:div.ml-4 (properties-section block properties opts') (hidden-properties-cp block hidden-properties - (assoc opts :root-block? root-block?)) + (assoc opts :root-block? root-block?)) (rum/with-key (new-property block opts') (str id "-class-add-property"))]]))]])))])) diff --git a/src/main/frontend/components/settings.cljs b/src/main/frontend/components/settings.cljs index b0dcc118b1..0cdda99ef1 100644 --- a/src/main/frontend/components/settings.cljs +++ b/src/main/frontend/components/settings.cljs @@ -659,6 +659,63 @@ {:left-label (t :settings.sync-server/url) :action (sync-server-url-button)})) +(rum/defc publish-server-url-settings-container + [] + (let [current-url (config/get-custom-publish-server-url) + [url set-url!] (rum/use-state (or current-url "")) + reset-url! (fn [] + (config/set-custom-publish-server-url! nil) + (set-url! "") + (notification/show! (t :settings-page/publish-server-url-cleared) :success))] + [:div.cp__settings-publish-server-cnt + [:h1.mb-2.text-2xl.font-bold (t :settings-page/publish-server-url)] + [:div.p-2 + [:p.text-sm.opacity-70.mb-4 (t :settings-page/publish-server-url-desc)] + [:p + [:label + [:strong "URL"] + [:input.form-input.is-small + {:value url + :placeholder config/default-publish-api-base + :style {:width "100%"} + :on-change #(set-url! (util/evalue %))}]]] + [:p.pt-2.flex.gap-2 + (shui/button + {:size :sm + :on-click (fn [] + (let [trimmed (string/trim url)] + (if (string/blank? trimmed) + (reset-url!) + (if-not (config/valid-publish-server-url? trimmed) + (notification/show! (t :settings.sync-server/url-invalid-error) :error) + (do + (config/set-custom-publish-server-url! trimmed) + (notification/show! (t :settings-page/publish-server-url-saved) :success))))))} + (t :ui/save)) + (when (seq url) + (shui/button + {:size :sm + :variant :outline + :on-click (fn [] (reset-url!))} + (t :settings-page/publish-server-url-reset)))]]])) + +(rum/defc publish-server-url-button + [] + (let [current-url (config/get-custom-publish-server-url)] + (ui/button [:span.flex.items-center + [:span.pr-1 + (if (seq current-url) + current-url + (t :settings-page/publish-server-url-default))] + (ui/icon "edit")] + :class "text-sm" + :on-click #(state/pub-event! [:go/publish-server-settings])))) + +(defn publish-server-url-row [] + (row-with-button-action + {:left-label (t :settings-page/publish-server-url) + :action (publish-server-url-button)})) + (rum/defc user-proxy-settings [{:keys [type protocol host port] :as agent-opts}] (ui/button [:span.flex.items-center @@ -774,6 +831,7 @@ (usage-diagnostics-row t instrument-disabled?) (when-not (mobile-util/native-platform?) (developer-mode-row t developer-mode?)) (sync-server-url-row) + (publish-server-url-row) (when (util/electron?) (https-user-agent-row https-agent-opts)) (when (util/electron?) (auto-chmod-row t)) ;; (clear-cache-row t) diff --git a/src/main/frontend/config.cljs b/src/main/frontend/config.cljs index 45e07c0a59..cf7f48cd38 100644 --- a/src/main/frontend/config.cljs +++ b/src/main/frontend/config.cljs @@ -33,7 +33,7 @@ (def USER-POOL-ID "us-east-1_dtagLnju8") (def IDENTITY-POOL-ID "us-east-1:d6d3b034-1631-402b-b838-b44513e93ee0") (def OAUTH-DOMAIN "logseq-prod.auth.us-east-1.amazoncognito.com") - (def PUBLISH-API-BASE "https://logseq.io")) + (def default-publish-api-base "https://logseq.io")) (do (def API-DOMAIN "api-dev.logseq.com") (def COGNITO-IDP "https://cognito-idp.us-east-2.amazonaws.com/") @@ -42,10 +42,10 @@ (def USER-POOL-ID "us-east-2_kAqZcxIeM") (def IDENTITY-POOL-ID "us-east-2:cc7d2ad3-84d0-4faf-98fe-628f6b52c0a5") (def OAUTH-DOMAIN "logseq-test2.auth.us-east-2.amazoncognito.com") - (def PUBLISH-API-BASE "https://logseq-publish-staging.logseq.workers.dev"))) + (def default-publish-api-base "https://logseq-publish-staging.logseq.workers.dev"))) ;; Enable for local development -;; (def PUBLISH-API-BASE "http://localhost:8787") +;; (def default-publish-api-base "http://localhost:8787") (goog-define ENABLE-DB-SYNC-LOCAL false) (defonce db-sync-local? ENABLE-DB-SYNC-LOCAL) @@ -114,6 +114,43 @@ (custom-url->http-base custom) default-db-sync-http-base)) +(defn get-custom-publish-server-url + "Read the user-configured custom publish server URL from localStorage. + Returns nil when not set or empty." + [] + (when-not util/node-test? + (let [v (.getItem js/localStorage "publish-server-url")] + (when (and (string? v) (not (string/blank? v))) + v)))) + +(defn set-custom-publish-server-url! + "Persist the custom publish server URL to localStorage. Pass nil or empty string to clear." + [url] + (when-not util/node-test? + (if (or (nil? url) (string/blank? url)) + (.removeItem js/localStorage "publish-server-url") + (.setItem js/localStorage "publish-server-url" (string/trim url))))) + +(defn valid-publish-server-url? + "Return true when `url` looks like a valid HTTP(S) base URL." + [url] + (and (string? url) + (re-find #"^https?://" url))) + +(defn custom-url->publish-api-base + "Normalize a custom publish base URL by stripping trailing slashes. Pure function." + [custom-url] + (string/replace custom-url #"/+$" "")) + +(defn publish-api-base + "Return the base URL for the single-page publish service. Uses the user-configured + URL from localStorage when set, otherwise the default from the ENABLE-FILE-SYNC-PRODUCTION + branch above. Read on each call so URL changes take effect without a restart." + [] + (if-let [custom (get-custom-publish-server-url)] + (custom-url->publish-api-base custom) + default-publish-api-base)) + ;; Feature flags ;; ============= diff --git a/src/main/frontend/core.cljs b/src/main/frontend/core.cljs index cff73af1fe..018d71230e 100644 --- a/src/main/frontend/core.cljs +++ b/src/main/frontend/core.cljs @@ -22,19 +22,36 @@ [reitit.frontend :as rf] [reitit.frontend.easy :as rfe])) -(defn set-router! +(defn- build-router [] - (.addEventListener js/window "popstate" route-handler/restore-scroll-pos) - (rfe/start! - (rf/router (plugins/hook-custom-routes routes/routes) nil) - (fn [route] - (route-handler/set-route-match! route) - (plugin-handler/hook-plugin-app - :route-changed (select-keys route [:template :path :parameters]))) + (rf/router (plugins/hook-custom-routes routes/routes) nil)) +(defn- on-navigate + [route] + (route-handler/set-route-match! route) + (plugin-handler/hook-plugin-app + :route-changed (select-keys route [:template :path :parameters]))) + +(defn refresh-router! + "Rebuilds the reitit router so route renderers registered by plugins after + the app started take effect. Safe to call repeatedly; `rfe/start!` stops the + previous history instance internally." + [] + (rfe/start! + (build-router) + on-navigate ;; set to false to enable HistoryAPI {:use-fragment true})) +(defonce ^:private *popstate-installed? (atom false)) + +(defn set-router! + [] + (when (compare-and-set! *popstate-installed? false true) + (.addEventListener js/window "popstate" route-handler/restore-scroll-pos)) + (refresh-router!) + (plugin-handler/set-route-renderer-refresh-fn! refresh-router!)) + (defn display-welcome-message [] (js/console.log diff --git a/src/main/frontend/extensions/fsrs.cljs b/src/main/frontend/extensions/fsrs.cljs index e294dda625..44c68c6563 100644 --- a/src/main/frontend/extensions/fsrs.cljs +++ b/src/main/frontend/extensions/fsrs.cljs @@ -237,7 +237,8 @@ :show-cloze {:show-cloze? true :hide-children? true} - {:show-cloze? true})] + {:show-cloze? true + :ignore-block-collapsed? true})] (component-block/blocks-container option [block-entity])) [:div.mt-8.pb-2 (if (contains? #{:show-cloze :show-answer} next-phase) diff --git a/src/main/frontend/handler/editor.cljs b/src/main/frontend/handler/editor.cljs index 1074916840..2823dd5b4e 100644 --- a/src/main/frontend/handler/editor.cljs +++ b/src/main/frontend/handler/editor.cljs @@ -3455,7 +3455,8 @@ [block config] (let [block (or (db/entity (:db/id block)) block)] (or - (util/collapsed? block) + (and (not (:ignore-block-collapsed? config)) + (util/collapsed? block)) (and (util/mobile?) (ldb/class-instance? (entity-plus/entity-memoized (db/get-db) :logseq.class/Query) block)) (and (or (:list-view? config) (:ref? config)) (or (:block/_parent block) (:block.temp/has-children? block)) @@ -3466,6 +3467,13 @@ (or (ldb/page? block) (:table-block-title? config)))))) +(defn load-children? + [block temporary-collapsed-state ignore-block-collapsed?] + (or ignore-block-collapsed? + (not (if-some [result temporary-collapsed-state] + result + (:block/collapsed? block))))) + (defn batch-set-heading! [block-ids heading] (db-editor-handler/batch-set-heading! block-ids heading)) diff --git a/src/main/frontend/handler/events/rtc.cljs b/src/main/frontend/handler/events/rtc.cljs index 1b4100a2cc..170483684b 100644 --- a/src/main/frontend/handler/events/rtc.cljs +++ b/src/main/frontend/handler/events/rtc.cljs @@ -12,15 +12,9 @@ [missionary.core :as m] [promesa.core :as p])) -(defn rtc-collaborators-dialog? - [] - (= :rtc-collaborators (state/get-modal-id))) - (defmethod events/handle :rtc/decrypt-user-e2ee-private-key [[_ encrypted-private-key]] (let [private-key-promise (p/deferred) refresh-token (str (state/get-auth-refresh-token))] - (when-not (rtc-collaborators-dialog?) - (shui/dialog-close-all!)) (-> (p/let [{:keys [password]} (state/camelCase (name k)) v] [k v]))] (walk/postwalk - (fn [x] - (cond - (map? x) (into {} (map f x)) - (uuid? x) (str x) - :else x)) input)))) - -(defn- normalize-user-key-without-ns - [k] - (some-> k (name) - (string/replace "/" "$") - (string/replace " " "_") - (string/replace #"^[:_\s]+" ""))) + (fn [x] + (cond + (map? x) (into {} (map f x)) + (uuid? x) (str x) + :else x)) input)))) (defn invoke-exported-api [type & args] @@ -71,12 +65,12 @@ (let [name (some-> e (aget "name")) message (some-> e (aget "message")) url (or (some-> e (aget "url")) - (some->> message (re-matches illegal-plugin-package-error-pattern) second)) + (some->> message (re-matches illegal-plugin-package-error-pattern) second)) url (some-> url util/node-path.normalize) package-json-path (or (some-> e (aget "packageJsonPath")) - (some-> url (util/node-path.join "package.json")))] + (some-> url (util/node-path.join "package.json")))] (when (and (= "IllegalPluginPackageError" name) - (not (string/blank? url))) + (not (string/blank? url))) {:url url :package-json-path package-json-path}))) @@ -86,7 +80,7 @@ dotroot (some-> (get-ls-dotdir-root) util/node-path.normalize) dotplugins-root (some-> dotroot (util/node-path.join "plugins"))] (if (and dotplugins-root - (string/starts-with? url dotplugins-root)) + (string/starts-with? url dotplugins-root)) {:type :installed :id (util/node-path.basename url) :url url} @@ -103,7 +97,7 @@ removed? (not= externals updated-externals) _ (when removed? (invoke-exported-api :save_user_preferences - (clj->js (assoc prefs :externals updated-externals))))] + (clj->js (assoc prefs :externals updated-externals))))] removed?)) (defn- remove-problematic-plugin! @@ -114,8 +108,8 @@ (p/let [_ (when (util/electron?) (ipc/ipc :uninstallMarketPlugin id)) _ (-> (plugin-config-handler/remove-plugin id) - (p/catch (fn [error] - (log/warn :remove-broken-plugin-config-error error))))] + (p/catch (fn [error] + (log/warn :remove-broken-plugin-config-error error))))] source) :external @@ -128,37 +122,37 @@ (let [{:keys [type id]} (problematic-plugin-source url) uid (keyword (str "plugin-illegal-package-error-" (hash url)))] (notification/show! - [:div.flex.flex-col.gap-2 - [:div (t :plugin.package-config/parse-error)] - [:div.text-xs.opacity-70.break-all package-json-path] - (when (= type :external) - [:div.text-xs.opacity-70 - (t :plugin.package-config/detach-desc)]) - [:div.flex.items-center.gap-2.pt-1 - (shui/button - {:size :sm - :on-click (fn [] - (-> (remove-problematic-plugin! url) + [:div.flex.flex-col.gap-2 + [:div (t :plugin.package-config/parse-error)] + [:div.text-xs.opacity-70.break-all package-json-path] + (when (= type :external) + [:div.text-xs.opacity-70 + (t :plugin.package-config/detach-desc)]) + [:div.flex.items-center.gap-2.pt-1 + (shui/button + {:size :sm + :on-click (fn [] + (-> (remove-problematic-plugin! url) (p/then (fn [_] (notification/clear! uid) (notification/show! - (if (= type :installed) - (t :plugin.package-config/remove-installed-success id) - (t :plugin.package-config/remove-external-success)) - :success))) - (p/catch (fn [error] + (if (= type :installed) + (t :plugin.package-config/remove-installed-success id) + (t :plugin.package-config/remove-external-success)) + :success))) + (p/catch (fn [_error] (notification/show! - (str (t :plugin.package-config/remove-error) "\n" error) - :error)))))} - (t :plugin/uninstall))]] - :error false uid) + (t :plugin.package-config/remove-error) + :error)))))} + (t :plugin/uninstall))]] + :error false uid) true) (notification/show! (t :plugin/invalid-package) :error))) (defn setup-global-apis-for-web! [] (when (and util/web-platform? - (nil? js/window.apis)) + (nil? js/window.apis)) (let [^js e (js/window.EventEmitter3.)] (set! (. js/window -apis) e)))) @@ -171,25 +165,25 @@ [theme] (when theme (cond-> theme - (util/electron?) - (update :url #(some-> % (string/replace-first "assets://" "file://")))))) + (util/electron?) + (update :url #(some-> % (string/replace-first "assets://" "file://")))))) (defn load-plugin-preferences [] (-> (invoke-exported-api :load_user_preferences) - (p/then #(bean/->clj %)) - (p/then #(state/set-state! :plugin/preferences %)) - (p/catch - #(js/console.error %)))) + (p/then #(bean/->clj %)) + (p/then #(state/set-state! :plugin/preferences %)) + (p/catch + #(js/console.error %)))) (defn save-plugin-preferences! ([input] (save-plugin-preferences! input true)) ([input reload-state?] (when-let [^js input (and (map? input) (bean/->js input))] (p/then - (js/LSPluginCore.saveUserPreferences input) - #(when reload-state? - (load-plugin-preferences)))))) + (js/LSPluginCore.saveUserPreferences input) + #(when reload-state? + (load-plugin-preferences)))))) (defn gh-repo-url [repo] (str "https://github.com/" repo)) @@ -203,66 +197,66 @@ [refresh?] (if (or refresh? (nil? (:plugin/marketplace-pkgs @state/state))) (p/create - (fn [resolve reject] - (let [on-ok (fn [res] - (if-let [res (and res (bean/->clj res))] - (let [pkgs (:packages res) - pkgs (if (util/electron?) pkgs - (some->> pkgs (filterv #(or (true? (:web %)) (not (true? (:effect %)))))))] - (state/set-state! :plugin/marketplace-pkgs pkgs) - (resolve pkgs)) - (reject nil)))] - (if (state/http-proxy-enabled-or-val?) - (-> (ipc/ipc :httpFetchJSON plugins-url) - (p/then on-ok) - (p/catch reject)) - (util/fetch plugins-url on-ok reject))))) + (fn [resolve reject] + (let [on-ok (fn [res] + (if-let [res (and res (bean/->clj res))] + (let [pkgs (:packages res) + pkgs (if (util/electron?) pkgs + (some->> pkgs (filterv #(or (true? (:web %)) (not (true? (:effect %)))))))] + (state/set-state! :plugin/marketplace-pkgs pkgs) + (resolve pkgs)) + (reject nil)))] + (if (state/http-proxy-enabled-or-val?) + (-> (ipc/ipc :httpFetchJSON plugins-url) + (p/then on-ok) + (p/catch reject)) + (util/fetch plugins-url on-ok reject))))) (p/resolved (:plugin/marketplace-pkgs @state/state)))) (defn load-marketplace-stats [refresh?] (if (or refresh? (nil? (:plugin/marketplace-stats @state/state))) (p/create - (fn [resolve reject] - (let [on-ok (fn [^js res] - (if-let [res (and res (bean/->clj res))] - (do - (state/set-state! - :plugin/marketplace-stats - (into {} (map (fn [[k stat]] - [k (assoc stat - :total_downloads - (reduce (fn [a b] (+ a (get b 2))) 0 (:releases stat)))]) - res))) - (resolve nil)) - (reject nil)))] - (if (state/http-proxy-enabled-or-val?) - (-> (ipc/ipc :httpFetchJSON stats-url) - (p/then on-ok) - (p/catch reject)) - (util/fetch stats-url on-ok reject))))) + (fn [resolve reject] + (let [on-ok (fn [^js res] + (if-let [res (and res (bean/->clj res))] + (do + (state/set-state! + :plugin/marketplace-stats + (into {} (map (fn [[k stat]] + [k (assoc stat + :total_downloads + (reduce (fn [a b] (+ a (get b 2))) 0 (:releases stat)))]) + res))) + (resolve nil)) + (reject nil)))] + (if (state/http-proxy-enabled-or-val?) + (-> (ipc/ipc :httpFetchJSON stats-url) + (p/then on-ok) + (p/catch reject)) + (util/fetch stats-url on-ok reject))))) (p/resolved nil))) (defn check-or-update-marketplace-plugin! [{:keys [id] :as pkg} error-handler] (when-not (and (:plugin/installing @state/state) - (not (plugin-common-handler/installed? id))) + (not (plugin-common-handler/installed? id))) (state/set-state! :plugin/installing pkg) (-> (load-marketplace-plugins false) - (p/then (fn [manifests] - (let [mft (some #(when (= (:id %) id) %) manifests) - opts (merge (dissoc pkg :logger) mft)] - ;;TODO: (throw (js/Error. [:not-found-in-marketplace id])) - (if (util/electron?) - (ipc/ipc :updateMarketPlugin opts) - (plugin-common-handler/async-install-or-update-for-web! opts))) - true)) - (p/catch (fn [^js e] - (state/reset-all-updates-state) - (error-handler e) - (state/set-state! :plugin/installing nil) - (js/console.error e)))))) + (p/then (fn [manifests] + (let [mft (some #(when (= (:id %) id) %) manifests) + opts (merge (dissoc pkg :logger) mft)] + ;;TODO: (throw (js/Error. [:not-found-in-marketplace id])) + (if (util/electron?) + (ipc/ipc :updateMarketPlugin opts) + (plugin-common-handler/async-install-or-update-for-web! opts))) + true)) + (p/catch (fn [^js e] + (state/reset-all-updates-state) + (error-handler e) + (state/set-state! :plugin/installing nil) + (js/console.error e)))))) (defn get-plugin-inst [pid] @@ -283,17 +277,17 @@ (when-let [matched (medley/find-first #(= (:key (second %)) key) commands)] (let [[_ cmd action pid] matched] (state/pub-event! - [:exec-plugin-cmd {:type type :key key :pid pid :cmd (assoc cmd :args args) :action action}]))))) + [:exec-plugin-cmd {:type type :key key :pid pid :cmd (assoc cmd :args args) :action action}]))))) (defn open-updates-downloading [] (when (and (not (:plugin/updates-downloading? @state/state)) - (seq (state/all-available-coming-updates))) + (seq (state/all-available-coming-updates))) (->> (:plugin/updates-coming @state/state) - (map #(if (state/coming-update-new-version? (second %1)) - (update % 1 dissoc :error-code) %1)) - (into {}) - (state/set-state! :plugin/updates-coming)) + (map #(if (state/coming-update-new-version? (second %1)) + (update % 1 dissoc :error-code) %1)) + (into {}) + (state/set-state! :plugin/updates-coming)) (state/set-state! :plugin/updates-downloading? true))) (defn close-updates-downloading @@ -320,34 +314,34 @@ (case (keyword status) :completed (let [{:keys [id dst name title theme web-pkg]} payload - name (or title name (t :ui/untitled))] + name (or title name "Untitled")] (if only-check (state/consume-updates-from-coming-plugin! payload false) (if (plugin-common-handler/installed? id) ;; update plugin (when-let [^js pl (get-plugin-inst id)] (p/then - (.reload pl) - #(do - ;;(if theme (select-a-plugin-theme id)) - (when (not (util/electron?)) - (set! (.-version (.-options pl)) (:version web-pkg)) - (set! (.-webPkg (.-options pl)) (bean/->js web-pkg)) - (invoke-exported-api :save_installed_web_plugin (.toJSON pl false))) - (notification/show! - (t :plugin/update-plugin name (.-version (.-options pl))) :success) - (state/consume-updates-from-coming-plugin! payload true)))) + (.reload pl) + #(do + ;;(if theme (select-a-plugin-theme id)) + (when (not (util/electron?)) + (set! (.-version (.-options pl)) (:version web-pkg)) + (set! (.-webPkg (.-options pl)) (bean/->js web-pkg)) + (invoke-exported-api :save_installed_web_plugin (.toJSON pl false))) + (notification/show! + (t :plugin/update-plugin name (.-version (.-options pl))) :success) + (state/consume-updates-from-coming-plugin! payload true)))) ;; register plugin (-> (js/LSPluginCore.register (bean/->js {:key id :url dst :webPkg web-pkg})) - (p/then (fn [] - (when-let [^js pl (get-plugin-inst id)] - (when theme (js/setTimeout #(select-a-plugin-theme id) 300)) - (when (.-isWebPlugin pl) - (invoke-exported-api :save_installed_web_plugin (.toJSON pl false))) - (notification/show! - (t :plugin/installed-plugin name) :success)))) - (p/catch (fn [^js e] - (notification/show! + (p/then (fn [] + (when-let [^js pl (get-plugin-inst id)] + (when theme (js/setTimeout #(select-a-plugin-theme id) 300)) + (when (.-isWebPlugin pl) + (invoke-exported-api :save_installed_web_plugin (.toJSON pl false))) + (notification/show! + (t :plugin/installed-plugin name) :success)))) + (p/catch (fn [^js e] + (notification/show! (t :plugin/install-error name (.-message e)) :error))))))) @@ -360,9 +354,6 @@ [(t :plugin/up-to-date ":)") :success] [error-code :error]) - msg (cond-> msg - (keyword? msg) - name) pending? (seq (:plugin/updates-pending @state/state))] (if (and only-check pending?) @@ -375,11 +366,10 @@ ;; notify human tips (notification/show! - (str - (if (= :error type) (t :ui/error) "") - "<" (:id payload) "> " - msg) - type))) + (str + (if (= :error type) "[Error]" "") + "<" (:id payload) "> " + msg) type))) (when-not fake-error? (js/console.error "Update Error:" (:error-code payload)))) @@ -397,15 +387,15 @@ (defn- normalize-plugin-metadata [metadata] (cond-> metadata - (not (string? (:author metadata))) - (assoc :author (or (get-in metadata [:author :name]) "")))) + (not (string? (:author metadata))) + (assoc :author (or (get-in metadata [:author :name]) "")))) (defn register-plugin [plugin-metadata] (when-let [pid (keyword (:id plugin-metadata))] (some->> plugin-metadata - (normalize-plugin-metadata) - (swap! state/state update-in [:plugin/installed-plugins] assoc pid)))) + (normalize-plugin-metadata) + (swap! state/state update-in [:plugin/installed-plugins] assoc pid)))) (defn host-mounted! [] @@ -416,7 +406,7 @@ (when-let [pid (keyword pid)] (when (contains? (:plugin/installed-plugins @state/state) pid) (swap! state/state update-in [:plugin/installed-slash-commands pid] - (fnil merge {}) (hash-map cmd (mapv #(conj % {:pid pid}) actions))) + (fnil merge {}) (hash-map cmd (mapv #(conj % {:pid pid}) actions))) (state/pub-event! [:rebuild-slash-commands-list]) true))) @@ -442,7 +432,7 @@ (get keybinding-mode-handler-map (keyword mode))) :action (fn [] (state/pub-event! - [:exec-plugin-cmd {:type type :key key :pid pid :cmd cmd :action action}]))}] + [:exec-plugin-cmd {:type type :key key :pid pid :cmd cmd :action action}]))}] palette-cmd)) @@ -451,8 +441,8 @@ (let [id (keyword (str "plugin." pid "/" key)) binding (:binding keybinding) binding (some->> (if (string? binding) [binding] (vec binding)) - (remove string/blank?) - (map shortcut-utils/undecorate-binding)) + (remove string/blank?) + (map shortcut-utils/undecorate-binding)) binding (if util/mac? (or (:mac keybinding) binding) binding) mode (or (:mode keybinding) :global) @@ -465,7 +455,7 @@ (when-let [pid (keyword pid)] (when (contains? (:plugin/installed-plugins @state/state) pid) (swap! state/state update-in [:plugin/simple-commands pid] - (fnil conj []) [type cmd action pid]) + (fnil conj []) [type cmd action pid]) true))) (defn unregister-plugin-simple-command @@ -479,13 +469,15 @@ (let [items (or (get-in @state/state [:plugin/installed-ui-items pid]) []) items (filter #(not= key (:key (second %))) items)] (swap! state/state assoc-in [:plugin/installed-ui-items pid] - (conj items [type opts pid]))) + (conj items [type opts pid]))) true))) (defn unregister-plugin-ui-items [pid] (swap! state/state assoc-in [:plugin/installed-ui-items (keyword pid)] [])) +(declare *route-renderer-providers schedule-route-renderer-refresh!) + (defn register-plugin-resources [pid type {:keys [key] :as opts}] (when-let [pid (keyword pid)] @@ -494,13 +486,31 @@ ;; TODO: conditions ;; (when (contains? #{:error nil} (get-in @state/state (conj path key)))) (swap! state/state update-in path - (fnil assoc {}) key (merge opts {:pid pid})) + (fnil assoc {}) key (merge opts {:pid pid})) + true)))) + +(defn unregister-plugin-resource + [pid type key] + (when-let [pid (keyword pid)] + (when-let [type (and key (keyword type))] + (let [path [:plugin/installed-resources pid type]] + (swap! state/state + (fn [state] + (let [resources (get-in state path) + resources' (some-> resources (dissoc key))] + (if (seq resources') + (assoc-in state path resources') + (medley/dissoc-in state path))))) true)))) (defn unregister-plugin-resources [pid] (when-let [pid (keyword pid)] - (swap! state/state medley/dissoc-in [:plugin/installed-resources pid]) + (let [had-routes? (contains? @*route-renderer-providers pid)] + (swap! state/state medley/dissoc-in [:plugin/installed-resources pid]) + (swap! *route-renderer-providers disj pid) + (when had-routes? + (schedule-route-renderer-refresh!))) true)) (defn register-plugin-search-service @@ -535,87 +545,294 @@ (defn- create-local-renderer-register [type *providers] (fn [pid key {subs' :subs :keys [render] :as opts}] - (when-let [key (some-> key (normalize-user-key-without-ns) (keyword))] - (register-plugin-resources pid type - (merge opts {:key key :subs subs' :render render})) - (swap! *providers conj pid) - #(swap! *providers disj pid)))) + (when-let [key (and key (keyword key))] + (let [pid (keyword pid)] + (register-plugin-resources pid type + (merge opts {:key key :subs subs' :render render})) + (swap! *providers conj pid) + #(do + (unregister-plugin-resource pid type key) + (when-not (seq (state/get-plugin-resources-with-type pid type)) + (swap! *providers disj pid))))))) (defn- create-local-renderer-getter ([type *providers] (create-local-renderer-getter type *providers false)) ([type *providers many?] (fn [key] (when (seq @*providers) - (if-let [key (some-> key (normalize-user-key-without-ns) (keyword))] + (if key (when-let [rs (->> @*providers - (map (fn [pid] (state/get-plugin-resource pid type key))) - (remove nil?) - (flatten) - (seq))] + (map (fn [pid] (state/get-plugin-resource pid type key))) + (remove nil?) + (flatten) + (seq))] (if many? rs (first rs))) (->> @*providers - (mapcat (fn [pid] - (some-> (state/get-plugin-resources-with-type pid type) - (vals)))) - (seq))))))) + (mapcat (fn [pid] + (some-> (state/get-plugin-resources-with-type pid type) + (vals)))) + (seq))))))) (defonce *fenced-code-providers (atom #{})) (def register-fenced-code-renderer ;; [pid key payload] (create-local-renderer-register - :fenced-code-renderers *fenced-code-providers)) + :fenced-code-renderers *fenced-code-providers)) (def hook-fenced-code-by-lang ;; [key] (create-local-renderer-getter - :fenced-code-renderers *fenced-code-providers)) + :fenced-code-renderers *fenced-code-providers)) (def *extensions-enhancer-providers (atom #{})) (def register-extensions-enhancer ;; a plugin can only register one enhancer for a type (create-local-renderer-register - :extensions-enhancers *extensions-enhancer-providers)) + :extensions-enhancers *extensions-enhancer-providers)) (def hook-extensions-enhancers-by-key ;; multiple plug-ins can obtain more than one enhancer (create-local-renderer-getter - :extensions-enhancers *extensions-enhancer-providers true)) + :extensions-enhancers *extensions-enhancer-providers true)) (defonce *route-renderer-providers (atom #{})) -(def register-route-renderer - ;; [pid key payload] - (create-local-renderer-register - :route-renderers *route-renderer-providers)) +;; Indirection to avoid a circular dependency on `frontend.core`. The frontend +;; entry ns installs a fn here that rebuilds the reitit router so plugin routes +;; registered after initial app start actually take effect. +(defonce *route-renderer-refresh-fn (atom nil)) +(defonce ^:private *route-renderer-refresh-scheduled? (atom false)) + +(defn set-route-renderer-refresh-fn! + "Registers a 0-arg fn invoked (debounced via microtask) whenever the set of + plugin route renderers changes. Called from `frontend.core/set-router!`." + [f] + (reset! *route-renderer-refresh-fn f)) + +(defn- schedule-route-renderer-refresh! + [] + (when-let [f @*route-renderer-refresh-fn] + (when (compare-and-set! *route-renderer-refresh-scheduled? false true) + ;; Coalesce bursts of register/unregister calls into a single rebuild. + (js/setTimeout + (fn [] + (reset! *route-renderer-refresh-scheduled? false) + (try (f) + (catch :default e + (js/console.error "[plugin] refresh route renderer failed" e)))) + 0)))) + +(let [base-register (create-local-renderer-register + :route-renderers *route-renderer-providers)] + (defn register-route-renderer + ;; [pid key payload] + [pid key opts] + (let [unregister (base-register pid key opts)] + (schedule-route-renderer-refresh!) + (when (fn? unregister) + (fn [] + (let [r (unregister)] + (schedule-route-renderer-refresh!) + r)))))) + (def get-route-renderers ;; [key] optional (create-local-renderer-getter - :route-renderers *route-renderer-providers true)) + :route-renderers *route-renderer-providers true)) (defonce *daemon-renderer-providers (atom #{})) (def register-daemon-renderer ;; [pid key payload] (create-local-renderer-register - :daemon-renderers *daemon-renderer-providers)) + :daemon-renderers *daemon-renderer-providers)) (def get-daemon-renderers ;; [key] (create-local-renderer-getter - :daemon-renderers *daemon-renderer-providers true)) + :daemon-renderers *daemon-renderer-providers true)) (defonce *hosted-renderer-providers (atom #{})) -(def register-hosted-renderer - ;; [pid key payload] + +;; Pre-created internal register functions — all share *hosted-renderer-providers +;; but use separate storage type keywords so keys don't collide across renderer types. +(def ^:private -register-hosted (create-local-renderer-register - :hosted-renderers *hosted-renderer-providers)) + :hosted-renderers *hosted-renderer-providers)) + +(def ^:private -register-block + (create-local-renderer-register + :block-renderers *hosted-renderer-providers)) + +(def ^:private -register-block-properties + (create-local-renderer-register + :block-properties-renderers *hosted-renderer-providers)) + +(defn register-hosted-renderer + "Unified renderer registration. Routes by `:type` in opts: + \"block\" → block renderer storage + \"block-properties\" → block-properties renderer storage + else → hosted renderer storage (sidebar, etc.)" + [pid key opts] + (let [register-fn (case (:type opts) + "block" -register-block + "block-properties" -register-block-properties + -register-hosted)] + (register-fn pid key opts))) + (def get-hosted-renderers ;; [key] (create-local-renderer-getter - :hosted-renderers *hosted-renderer-providers true)) + :hosted-renderers *hosted-renderer-providers true)) (defn resolve-hosted-render [pid key type] (some->> (get-hosted-renderers) - (medley/find-first #(and (some-> (:pid %) (name) (= pid)) - (or (some-> (:key %) (name) (= key)) - (some-> (:key %) (str) (string/includes? (str "." key)))) - (some->> type (name) (= (:type %))))))) + (medley/find-first #(and (some-> (:pid %) (name) (= pid)) + (or (some-> (:key %) (name) (= key)) + (some-> (:key %) (str) (string/includes? (str "." key)))) + (some->> type (name) (= (:type %))))))) + +;; Block renderers +(defn- ->block-renderer-properties-js + [properties-map] + (into {} (map (fn [[k v]] [(subs (str k) 1) v]) properties-map))) + +(defn- normalize-block-renderer-match-context + [{:keys [block-id properties-map props uuid page content format] :as match-context}] + (if (contains? match-context :properties-map) + (assoc match-context + :props + (or props + (clj->js (cond-> {:blockId block-id + :properties (->block-renderer-properties-js properties-map)} + uuid (assoc :uuid uuid) + page (assoc :page page) + content (assoc :content content) + format (assoc :format format))))) + (normalize-block-renderer-match-context {:properties-map match-context}))) + +(defn- promise-like? + [result] + (or (instance? js/Promise result) + (some-> result (aget "then") fn?))) + +(defn- match-renderer-predicate + "Run a synchronous predicate against JS props for any block renderer type. + `error-tag` is a keyword prefix used to distinguish log messages, + e.g. :block-renderer or :block-properties-renderer." + [error-tag predicate {:keys [props]} {:keys [pid key]}] + (try + (let [result (predicate props)] + (cond + (promise-like? result) + (do + (log/error (keyword (str (name error-tag) "-predicate-async")) + {:pid pid + :key key + :message (str "`when` predicate for " (name error-tag) " must return synchronously.")}) + false) + + :else + (boolean result))) + (catch :default error + (log/error (keyword (str (name error-tag) "-predicate-exception")) + {:pid pid + :key key + :error error}) + false))) + +(defn match-block-properties-condition + "Match a block-properties renderer condition against a block. + condition may be nil, a declarative condition map like {:has `ident`}, + or a synchronous predicate receiving JS props {:blockId :properties}. + properties-map is a map of keyword db-idents -> values." + [condition match-context renderer] + (let [{:keys [properties-map] :as match-context'} (normalize-block-renderer-match-context match-context)] + (cond + (nil? condition) + true + + (fn? condition) + (match-renderer-predicate :block-properties-renderer condition match-context' renderer) + + :else + (let [op (some-> condition first key) + arg (some-> condition first val)] + (case op + :has (contains? properties-map (keyword arg)) + :equals (let [[prop-key expected] arg] + (= (get properties-map (keyword prop-key)) expected)) + :in (let [[prop-key coll] arg] + (contains? (set coll) (get properties-map (keyword prop-key)))) + :not (not (match-block-properties-condition arg match-context' renderer)) + :any (some #(match-block-properties-condition % match-context' renderer) arg) + :all (every? #(match-block-properties-condition % match-context' renderer) arg) + true))))) + +(defn serialize-property-value-for-plugin + "Serialize a property value so it survives `clj->js`. + Datascript Entity implements `IEncodeJS` as returning nil, + so raw entities would become null in JS. This fn converts: + - Entity → {:uuid \"...\" :title \"...\"} (js object) + - Set/coll of entities → JS array of the above + - keyword → \":ns/name\" string + - uuid → string + - other values → as-is" + [v] + (cond + (de/entity? v) + (let [m (cond-> {} + (:block/uuid v) (assoc :uuid (str (:block/uuid v))) + (:block/title v) (assoc :title (:block/title v)))] + (if (seq m) m (str (:db/id v)))) + + (set? v) + (mapv serialize-property-value-for-plugin v) + + (and (sequential? v) (some de/entity? v)) + (mapv serialize-property-value-for-plugin v) + + (keyword? v) + (subs (str v) 1) + + (uuid? v) + (str v) + + :else v)) + +(def get-block-renderers + ;; [] - get all + (create-local-renderer-getter + :block-renderers *hosted-renderer-providers true)) + +(defn any-block-renderers? + [] + (boolean (seq (get-block-renderers nil)))) + +(defn get-matched-block-renderer + "Return the highest priority matched block renderer for a block." + [match-context] + (when-let [rs (get-block-renderers nil)] + (let [match-context' (normalize-block-renderer-match-context match-context)] + (->> rs + (filter (fn [renderer] + (let [predicate (:when renderer)] + (if predicate + (match-renderer-predicate :block-renderer predicate match-context' renderer) + true)))) + (sort-by #(- (or (:priority %) 0))) + first)))) + +(def get-block-properties-renderers + ;; [] - get all + (create-local-renderer-getter + :block-properties-renderers *hosted-renderer-providers true)) + +(defn get-matched-block-properties-renderers + "Return all registered block-properties renderers whose :when condition + matches the given properties-map. Sorted by :priority descending." + [match-context] + (when-let [rs (get-block-properties-renderers nil)] + (let [match-context' (normalize-block-renderer-match-context match-context)] + (->> rs + (filter #(match-block-properties-condition (:when %) match-context' %)) + (sort-by #(- (or (:priority %) 0))))))) (defn select-a-plugin-theme [pid] @@ -626,10 +843,10 @@ (defn update-plugin-settings-state [id settings] (state/set-state! [:plugin/installed-plugins id :settings] - ;; TODO: force settings related ui reactive - ;; Sometimes toggle to `disable` not working - ;; But related-option data updated? - (assoc settings :disabled (boolean (:disabled settings))))) + ;; TODO: force settings related ui reactive + ;; Sometimes toggle to `disable` not working + ;; But related-option data updated? + (assoc settings :disabled (boolean (:disabled settings))))) (defn open-settings-file-in-default-app! [id-or-plugin] @@ -650,18 +867,18 @@ ([] (open-report-modal! nil nil)) ([pid name] (shui/dialog-open! - [:div.p-1 - (when pid - [:h1.opacity-90.font-bold.pb-1.flex.item-center.gap-1 - [:span.text-red-rx-10.flex.items-center (shui/tabler-icon "alert-triangle-filled" {:size 20})] - [:span name " " [:code "#" (str pid)]]]) - (into [:p] - (interpolate-rich-text - (t :plugin/report-modal-desc) - [[:a.hover:underline - {:href (str "mailto://support@logseq.com?subject=Report plugin from Logseq Marketplace" - (when pid (str " (#" pid ")")))} - "support@logseq.com"]]))]))) + [:div.p-1 + (when pid + [:h1.opacity-90.font-bold.pb-1.flex.item-center.gap-1 + [:span.text-red-rx-10.flex.items-center (shui/tabler-icon "alert-triangle-filled" {:size 20})] + [:span name " " [:code "#" (str pid)]]]) + [:p + (interpolate-rich-text-node + (t :plugin/report-modal-desc) + [[:a.hover:underline + {:href (str "mailto://support@logseq.com?subject=Report plugin from Logseq Marketplace" + (when pid (str " (#" pid ")")))} + "support@logseq.com"]])]]))) (defn parse-user-md-content [content {:keys [url]}] @@ -669,11 +886,11 @@ (when-not (string/blank? content) (let [content (if-not (string/blank? url) (string/replace - content #"!\[[^\]]*\]\((.*?)\s*(\"(?:.*[^\"])\")?\s*\)" - (fn [[matched link]] - (if (and link (not (string/starts-with? link "http"))) - (string/replace matched link (util/node-path.join url link)) - matched))) + content #"!\[[^\]]*\]\((.*?)\s*(\"(?:.*[^\"])\")?\s*\)" + (fn [[matched link]] + (if (and link (not (string/starts-with? link "http"))) + (string/replace matched link (util/node-path.join url link)) + matched))) content)] (format/to-html content (gp-mldoc/default-config :markdown)))) (catch :default e @@ -685,17 +902,17 @@ (let [repo (:repo item)] (if (nil? repo) ;; local - (-> (p/let [content (invoke-exported-api :load_plugin_readme url) + (-> (p/let [content (invoke-exported-api "load_plugin_readme" url) content (parse-user-md-content content item)] (and (string/blank? (string/trim content)) (throw (js/Error. "blank readme content"))) (state/set-state! :plugin/active-readme [content item]) (shui/dialog-open! (fn [_] (display)) - {:label :plugin-readme - :content-props {:class "max-h-[86vh] overflow-auto"}})) - (p/catch #(do (js/console.warn %) - (notification/show! (t :plugin/readme-empty-warning) :warning)))) + {:label "plugin-readme" + :content-props {:class "max-h-[86vh] overflow-auto"}})) + (p/catch #(do (js/console.warn %) + (notification/show! (t :plugin/readme-empty-warning) :warning)))) ;; market - (shui/dialog-open! (fn [_] (display item nil)) {:label :plugin-readme})))) + (shui/dialog-open! (fn [_] (display item nil)) {:label "plugin-readme"})))) (defn load-unpacked-plugin [] @@ -713,12 +930,12 @@ (when config/lsp-enabled? (try (js-invoke js/LSPluginCore - (str "hook" (string/capitalize (name tag))) - (name type) - (if (coll? payload) - (bean/->js (normalize-keyword-for-json payload)) - payload) - (if (keyword? plugin-id) (name plugin-id) plugin-id)) + (str "hook" (string/capitalize (name tag))) + (name type) + (if (coll? payload) + (bean/->js (normalize-keyword-for-json payload)) + payload) + (if (keyword? plugin-id) (name plugin-id) plugin-id)) (catch :default e (log/error :invoke-hook-exception e))))) @@ -756,7 +973,7 @@ (-> (if (util/electron?) (ipc/ipc "getLogseqDotDirRoot") "LSPUserDotRoot/") - (p/then #(do (reset! *ls-dotdir-root %) %)))) + (p/then #(do (reset! *ls-dotdir-root %) %)))) (defn make-fn-to-load-dotdir-json [dirname ^js default] @@ -812,14 +1029,14 @@ (-> (if (util/electron?) (ipc/ipc "getUserDefaultPlugins") (invoke-exported-api :load_installed_web_plugins)) - (p/then #(bean/->clj %)) - (p/then (fn [plugins] - (if (util/electron?) - (map #(hash-map :url %) plugins) - (some->> (vals plugins) - (filter #(:url %)))))) - (p/catch (fn [e] - (js/console.error "[get-user-default-plugins:error]" e))))) + (p/then #(bean/->clj %)) + (p/then (fn [plugins] + (if (util/electron?) + (map #(hash-map :url %) plugins) + (some->> (vals plugins) + (filter #(:url %)))))) + (p/catch (fn [e] + (js/console.error "[get-user-default-plugins:error]" e))))) (defn set-auto-checking! [v] @@ -842,7 +1059,7 @@ (defn cancel-user-checking! [] (when (and (get-user-checking?) - (not (get-auto-checking?))) + (not (get-auto-checking?))) (state/set-state! :plugin/updates-pending {}))) (defn user-check-enabled-for-updates! @@ -852,25 +1069,25 @@ (when auto-checking? (set-auto-checking! false)) (when (or auto-checking? (not user-checking?)) - ;; TODO: too many requests may be limited by GitHub api + ;; TODO: too many requests may be limited by GitHub API (when-let [plugins (seq (take 32 (state/get-enabled?-installed-plugins theme?)))] (->> plugins - (map (fn [v] [(keyword (:id v)) v])) - (into {}) - (state/set-state! :plugin/updates-pending)) + (map (fn [v] [(keyword (:id v)) v])) + (into {}) + (state/set-state! :plugin/updates-pending)) (state/pub-event! [:plugin/consume-updates]))))) (defn auto-check-enabled-for-updates! [] (when (and (not (get-updates-downloading?)) - (not (get-auto-checking?)) - (not (get-user-checking?))) + (not (get-auto-checking?)) + (not (get-user-checking?))) ;; TODO: take some plugins used recently (when-let [plugins (seq (take 16 (shuffle (state/get-enabled?-installed-plugins nil))))] (->> plugins - (map (fn [v] [(keyword (:id v)) v])) - (into {}) - (state/set-state! :plugin/updates-pending)) + (map (fn [v] [(keyword (:id v)) v])) + (into {}) + (state/set-state! :plugin/updates-pending)) (state/pub-event! [:plugin/consume-updates]) (set-auto-checking! true)))) @@ -920,16 +1137,16 @@ (some-> (re-find #"github.com/([^/]+/[^/]+)" url) (last))) package-url (if github? (some-> github-repo - (plugin-common-handler/get-web-plugin-checker-url!)) + (plugin-common-handler/get-web-plugin-checker-url!)) (str url "/package.json")) ^js res (js/window.fetch (str package-url "?v=" (js/Date.now))) package (if (and (.-ok res) - (= (.-status res) 200)) + (= (.-status res) 200)) (-> (.json res) - (p/then bean/->clj)) + (p/then bean/->clj)) (throw (js/Error. (.text res)))) logseq (or (:logseq package) - (throw (js/Error. "Illegal logseq package")))] + (throw (js/Error. "Illegal logseq package")))] (let [id (if github? (some-> github-repo (string/replace "/" "_")) (or (:id logseq) (:name package))) @@ -937,16 +1154,16 @@ theme? (some? (or (:theme logseq) (:themes logseq)))] (plugin-common-handler/emit-lsp-updates! - {:status :completed - :only-check false - :payload {:id id - :repo repo - :dst repo - :theme theme? - :web-pkg (cond-> package + {:status :completed + :only-check false + :payload {:id id + :repo repo + :dst repo + :theme theme? + :web-pkg (cond-> package - (not github?) - (assoc :installedFromUserWebUrl url))}})) + (not github?) + (assoc :installedFromUserWebUrl url))}})) url))) ;; components @@ -966,7 +1183,7 @@ (let [el (js/document.createElement "div")] (.appendChild js/document.body el) (rum/mount - (lsp-indicator) el)) + (lsp-indicator) el)) (-> (p/let [root (init-ls-dotdir-root) _ (.setupPluginCore js/LSPlugin (bean/->js {:localUserConfigRoot root :dotConfigRoot root})) @@ -974,33 +1191,33 @@ clear-commands! (fn [pid] ;; commands (unregister-plugin-slash-command pid) - (invoke-exported-api :unregister_plugin_simple_command pid) - (invoke-exported-api :uninstall_plugin_hook pid) + (invoke-exported-api "unregister_plugin_simple_command" pid) + (invoke-exported-api "uninstall_plugin_hook" pid) (unregister-plugin-ui-items pid) (unregister-plugin-resources pid) (unregister-plugin-search-services pid)) _ (doto js/LSPluginCore (.on "registered" - (fn [^js pl] - (register-plugin - (bean/->clj (.parse js/JSON (.stringify js/JSON pl)))))) + (fn [^js pl] + (register-plugin + (bean/->clj (.parse js/JSON (.stringify js/JSON pl)))))) (.on "error" - (fn [^js e] - (when (illegal-plugin-package-error->data e) - (show-illegal-plugin-package-notification! e)))) + (fn [^js e] + (when (illegal-plugin-package-error->data e) + (show-illegal-plugin-package-notification! e)))) (.on "beforeload" - (fn [^js pl] - (let [text (when (util/electron?) - (t :plugin/load-plugin-indicator (.-id pl)))] - (some->> text (state/set-state! :plugin/indicator-text))))) + (fn [^js pl] + (let [text (when (util/electron?) + (t :plugin/load-plugin-indicator (.-id pl)))] + (some->> text (state/set-state! :plugin/indicator-text))))) (.on "reloaded" - (fn [^js pl] - (register-plugin - (bean/->clj (.parse js/JSON (.stringify js/JSON pl)))))) + (fn [^js pl] + (register-plugin + (bean/->clj (.parse js/JSON (.stringify js/JSON pl)))))) (.on "unregistered" (fn [pid] (let [pid (keyword pid)] @@ -1028,7 +1245,7 @@ (.on "themes-changed" (fn [^js themes] (swap! state/state assoc :plugin/installed-themes - (vec (mapcat (fn [[pid vs]] (mapv #(assoc % :pid pid) (bean/->clj vs))) (bean/->clj themes)))))) + (vec (mapcat (fn [[pid vs]] (mapv #(assoc % :pid pid) (bean/->clj vs))) (bean/->clj themes)))))) (.on "theme-selected" (fn [^js theme] (let [theme (bean/->clj theme) @@ -1044,10 +1261,7 @@ (.on "reset-custom-theme" (fn [^js themes] (let [themes (bean/->clj themes) custom-theme (dissoc themes :mode) - ;; Fall back to the user's current theme so that - ;; installing a non-theme plugin does not flash - ;; the UI back to light mode (#12434). - mode (or (:mode themes) (:ui/theme @state/state))] + mode (:mode themes)] (state/set-custom-theme! {:light (if (nil? (:light custom-theme)) {:mode "light"} (:light custom-theme)) :dark (if (nil? (:dark custom-theme)) {:mode "dark"} (:dark custom-theme))}) (state/set-theme-mode! mode)))) @@ -1055,51 +1269,51 @@ (.on "settings-changed" (fn [id ^js settings] (let [id (keyword id)] (when (and settings - (contains? (:plugin/installed-plugins @state/state) id)) + (contains? (:plugin/installed-plugins @state/state) id)) (update-plugin-settings-state id (bean/->clj settings)))))) (.on "ready" (fn [^js perf-table] (when-let [plugins (and perf-table (.entries perf-table))] (->> plugins - (keep - (fn [[_k ^js v]] - (when-let [end (and (some-> v (.-o) (.-disabled) (not)) - (.-e v))] - (when (and (number? end) - ;; valid end time - (> end 0) - ;; greater than 6s - (> (- end (.-s v)) 6000)) - v)))) - ((fn [perfs] - (doseq [perf perfs] - (state/pub-event! [:plugin/loader-perf-tip (bean/->clj perf)]))))))))) + (keep + (fn [[_k ^js v]] + (when-let [end (and (some-> v (.-o) (.-disabled) (not)) + (.-e v))] + (when (and (number? end) + ;; valid end time + (> end 0) + ;; greater than 6s + (> (- end (.-s v)) 6000)) + v)))) + ((fn [perfs] + (doseq [perf perfs] + (state/pub-event! [:plugin/loader-perf-tip (bean/->clj perf)]))))))))) default-plugins (get-user-default-plugins) [plugins0, plugins-async] (if (and (seq default-plugins) - (not (util/electron?))) + (not (util/electron?))) ((juxt (fn [its] (filterv #(:theme %) its)) - (fn [its] (filterv #(not (:theme %)) its))) + (fn [its] (filterv #(not (:theme %)) its))) default-plugins) [default-plugins]) _ (.register js/LSPluginCore (bean/->js (if (seq plugins0) plugins0 [])) true)] plugins-async) - (p/then - (fn [plugins-async] - ;; true indicate for preboot finished - (state/set-state! :plugin/indicator-text true) - ;; wait for the plugin register async messages - (js/setTimeout + (p/then + (fn [plugins-async] + ;; true indicate for preboot finished + (state/set-state! :plugin/indicator-text true) + ;; wait for the plugin register async messages + (js/setTimeout (fn [] (some-> (seq plugins-async) - (p/delay 16) - (p/then #(.register js/LSPluginCore (bean/->js plugins-async) true)))) + (p/delay 16) + (p/then #(.register js/LSPluginCore (bean/->js plugins-async) true)))) (if (util/electron?) 64 0)))) - (p/catch - (fn [^js e] - (log/error :setup-plugin-system-error e) - (state/set-state! :plugin/indicator-text (t :plugin/fatal-error (str e))))))) + (p/catch + (fn [^js e] + (log/error :setup-plugin-system-error e) + (state/set-state! :plugin/indicator-text (t :plugin/fatal-error e)))))) (defn setup! "setup plugin core handler" @@ -1109,8 +1323,8 @@ (init-plugins!))) (comment - {:pending (count (:plugin/updates-pending @state/state)) - :auto-checking? (boolean (:plugin/updates-auto-checking? @state/state)) - :coming (count (:plugin/updates-coming @state/state)) - :installing (:plugin/installing @state/state) - :downloading? (boolean (:plugin/updates-downloading? @state/state))}) + {:pending (count (:plugin/updates-pending @state/state)) + :auto-checking? (boolean (:plugin/updates-auto-checking? @state/state)) + :coming (count (:plugin/updates-coming @state/state)) + :installing (:plugin/installing @state/state) + :downloading? (boolean (:plugin/updates-downloading? @state/state))}) diff --git a/src/main/frontend/handler/publish.cljs b/src/main/frontend/handler/publish.cljs index b2327e5400..8f60898547 100644 --- a/src/main/frontend/handler/publish.cljs +++ b/src/main/frontend/handler/publish.cljs @@ -30,15 +30,15 @@ (defn- publish-endpoint [] - (str config/PUBLISH-API-BASE "/pages")) + (str (config/publish-api-base) "/pages")) (defn- publish-page-endpoint [graph-uuid page-uuid] - (str config/PUBLISH-API-BASE "/pages/" graph-uuid "/" page-uuid)) + (str (config/publish-api-base) "/pages/" graph-uuid "/" page-uuid)) (defn- asset-upload-endpoint [] - (str config/PUBLISH-API-BASE "/assets")) + (str (config/publish-api-base) "/assets")) (defn- asset-content-type [ext] @@ -382,10 +382,11 @@ data (bean/->clj json)] (let [short-url (:short_url data) page-id (str (:block/uuid page)) + api-base (config/publish-api-base) fallback-url (when (and graph-id page-id) - (str config/PUBLISH-API-BASE "/page/" graph-id "/" page-id)) + (str api-base "/page/" graph-id "/" page-id)) url (or (when short-url - (str config/PUBLISH-API-BASE short-url)) + (str api-base short-url)) fallback-url)] (when (and url (:db/id page)) (property-handler/set-block-property! (:db/id page) diff --git a/src/main/frontend/modules/outliner/op.cljs b/src/main/frontend/modules/outliner/op.cljs index 0e9325e41a..b6fa2b0b0c 100644 --- a/src/main/frontend/modules/outliner/op.cljs +++ b/src/main/frontend/modules/outliner/op.cljs @@ -50,7 +50,8 @@ [block & {:as opts}] (op-transact! (when-let [block' (if (de/entity? block) - (dissoc (.-kv ^js block) :db/id) + (-> (dissoc (.-kv ^js block) :db/id) + (assoc :block/uuid (:block/uuid block))) block)] [:save-block [block' opts]]))) diff --git a/src/main/frontend/spec/storage.cljc b/src/main/frontend/spec/storage.cljc index 41d97d7913..89924fb58c 100644 --- a/src/main/frontend/spec/storage.cljc +++ b/src/main/frontend/spec/storage.cljc @@ -29,6 +29,7 @@ (s/def :copy/export-block-text-remove-options set?) (s/def :copy/export-block-text-other-options map?) (s/def ::sync-server-url string?) +(s/def ::publish-server-url string?) ;; Dynamic keys which aren't as easily validated: ;; :ls-pdf-last-page-* ;; :ls-js-allowed-* @@ -68,4 +69,5 @@ :copy/export-block-text-indent-style :copy/export-block-text-remove-options :copy/export-block-text-other-options - ::sync-server-url])) + ::sync-server-url + ::publish-server-url])) diff --git a/src/main/logseq/sdk/experiments.cljs b/src/main/logseq/sdk/experiments.cljs index 2736b9b74b..c69328bba2 100644 --- a/src/main/logseq/sdk/experiments.cljs +++ b/src/main/logseq/sdk/experiments.cljs @@ -1,6 +1,7 @@ (ns logseq.sdk.experiments (:require [frontend.components.page :as page] [frontend.handler.plugin :as plugin-handler] + [lambdaisland.glogi :as log] [frontend.state :as state] [frontend.util :as util] [logseq.sdk.utils :as sdk-util])) @@ -11,43 +12,89 @@ page-name (some-> props1 :page)] (when-let [entity (page/get-page-entity page-name)] (page/page-blocks-cp - entity {:container-id (state/get-next-container-id)})))) + entity {:container-id (state/get-next-container-id)})))) (defn ^:export register_fenced_code_renderer [pid type ^js opts] (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] (plugin-handler/register-fenced-code-renderer - (keyword pid) type (reduce #(assoc %1 %2 (aget opts (name %2))) {} - [:edit :before :subs :render])))) + (keyword pid) type (reduce #(assoc %1 %2 (aget opts (name %2))) {} + [:edit :before :subs :render])))) (defn ^:export register_route_renderer [pid key ^js opts] (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] (let [key (util/safe-keyword key)] (plugin-handler/register-route-renderer - (keyword pid) key - (reduce (fn [r k] - (assoc r k (cond-> (aget opts (name k)) - (= :name k) - (#(if % (util/safe-keyword %) key))))) - {} [:v :name :path :subs :render]))))) + (keyword pid) key + (reduce (fn [r k] + (assoc r k (cond-> (aget opts (name k)) + (= :name k) + (#(if % (util/safe-keyword %) key))))) + {} [:v :name :path :subs :render]))))) (defn ^:export register_daemon_renderer [pid key ^js opts] (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] (plugin-handler/register-daemon-renderer - (keyword pid) key (reduce #(assoc %1 %2 (aget opts (name %2))) {} - [:before :subs :render])))) + (keyword pid) key (reduce #(assoc %1 %2 (aget opts (name %2))) {} + [:before :subs :render])))) + +(defn- extract-js-renderer-opts + "Extract keys from a JS opts object into a clj map. + `transforms` is an optional map of {keyword transform-fn} for per-key processing. + Keys whose JS value is nil/undefined are omitted." + [^js opts ks transforms] + (reduce (fn [r k] + (let [v (aget opts (name k))] + (if (some? v) + (assoc r k (if-let [xf (get transforms k)] + (xf v) + v)) + r))) + {} ks)) (defn ^:export register_hosted_renderer [pid key ^js opts] (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] (plugin-handler/register-hosted-renderer - (keyword pid) key (reduce #(assoc %1 %2 (aget opts (name %2))) {} - [:title :type :mode :subs :render])))) + (keyword pid) key + (extract-js-renderer-opts opts [:title :type :mode :subs :render] nil)))) + +(defn ^:export register_block_properties_renderer + [pid key ^js opts] + (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] + (let [clj-opts (extract-js-renderer-opts + opts + [:when :mode :priority :subs :render] + {:when (fn [v] + (if (fn? v) + v + (js->clj v :keywordize-keys true)))})] + (plugin-handler/register-hosted-renderer + (keyword pid) key (assoc clj-opts :type "block-properties"))))) + +(defn ^:export register_block_renderer + [pid key ^js opts] + (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] + (let [when-predicate (aget opts "when")] + (if (and (some? when-predicate) (not (fn? when-predicate))) + (log/error :register-block-renderer-invalid-when + {:pid pid + :key key + :message "`when` for registerBlockRenderer must be a synchronous predicate function."}) + (let [include-children (aget opts "includeChildren") + clj-opts (extract-js-renderer-opts + opts + [:when :priority :subs :render] + nil)] + (plugin-handler/register-hosted-renderer + (keyword pid) key (cond-> (assoc clj-opts :type "block") + (some? include-children) + (assoc :include-children include-children)))))))) (defn ^:export register_extensions_enhancer [pid type enhancer] (when-let [^js _pl (and (fn? enhancer) (plugin-handler/get-plugin-inst pid))] (plugin-handler/register-extensions-enhancer - (keyword pid) type {:enhancer enhancer}))) + (keyword pid) type {:enhancer enhancer}))) diff --git a/src/resources/dicts/af.edn b/src/resources/dicts/af.edn index ade3dd9ec7..3ca22de019 100644 --- a/src/resources/dicts/af.edn +++ b/src/resources/dicts/af.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nieuwe plug-in geregistreerd" :plugin/no-settings-schema "Geen instellingskema nie!" :plugin/not-installed "Nie geïnstalleer nie" + :plugin/open-logs "Bekyk logboeke" :plugin/open-logseq-dir "Logseq-map maak oop" :plugin/open-package "Maak pakket oop" :plugin/open-preferences "Voorkeuren maak oop" @@ -1729,7 +1730,6 @@ :ui/deleted "Verwyder" :ui/dont-remind-me-again "Moet my nie weer herinner nie" :ui/empty "Leeg" - :ui/error "Fout" :ui/error-boundary-error "Fout gevang deur UI!\n {1}" :ui/export "Voer uit" :ui/false "Onwaar" diff --git a/src/resources/dicts/ar.edn b/src/resources/dicts/ar.edn index b3c800159e..1ca4fe2496 100644 --- a/src/resources/dicts/ar.edn +++ b/src/resources/dicts/ar.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "إضافة جديدة مسجلة" :plugin/no-settings-schema "لا يوجد مخطط إعدادات!" :plugin/not-installed "غير مثبتة" + :plugin/open-logs "عرض السجلات" :plugin/open-logseq-dir "فتح مجلد Logseq" :plugin/open-package "فتح الحزمة" :plugin/open-preferences "فتح التفضيلات" @@ -1729,7 +1730,6 @@ :ui/deleted "محذوف" :ui/dont-remind-me-again "لا تذكرني مجددًا" :ui/empty "فارغ" - :ui/error "خطأ" :ui/error-boundary-error "خطأ التقطته واجهة المستخدم!\n {1}" :ui/export "تصدير" :ui/false "خطأ" diff --git a/src/resources/dicts/ca.edn b/src/resources/dicts/ca.edn index 24a161a40e..a8e10d542e 100644 --- a/src/resources/dicts/ca.edn +++ b/src/resources/dicts/ca.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nou connector registrat" :plugin/no-settings-schema "No hi ha esquema de configuració!" :plugin/not-installed "No instal·lat" + :plugin/open-logs "Veure registres" :plugin/open-logseq-dir "Obrir carpeta Logseq" :plugin/open-package "Obrir paquet" :plugin/open-preferences "Obrir preferències" @@ -1729,7 +1730,6 @@ :ui/deleted "Suprimit" :ui/dont-remind-me-again "No m'ho tornis a recordar" :ui/empty "Buit" - :ui/error "Error" :ui/error-boundary-error "Error detectat per la interfície!\n {1}" :ui/export "Exportar" :ui/false "Fals" diff --git a/src/resources/dicts/cs.edn b/src/resources/dicts/cs.edn index 0fa5ff4ac1..7afb69c60e 100644 --- a/src/resources/dicts/cs.edn +++ b/src/resources/dicts/cs.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nový plugin zaregistrován" :plugin/no-settings-schema "Žádné schéma nastavení!" :plugin/not-installed "Nenainstalováno" + :plugin/open-logs "Zobrazit protokoly" :plugin/open-logseq-dir "Otevřít složku Logseq" :plugin/open-package "Otevřít balík" :plugin/open-preferences "Otevřít předvolby" @@ -1729,7 +1730,6 @@ :ui/deleted "Smazáno" :ui/dont-remind-me-again "Už mi nepřipomínat" :ui/empty "Prázdné" - :ui/error "Chyba" :ui/error-boundary-error "Chyba zachycená uživatelským rozhraním!\n {1}" :ui/export "Export" :ui/false "Ne" diff --git a/src/resources/dicts/de.edn b/src/resources/dicts/de.edn index 77f4cec302..9a29fd9c14 100644 --- a/src/resources/dicts/de.edn +++ b/src/resources/dicts/de.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Neues Plugin registriert" :plugin/no-settings-schema "Kein Einstellungsschema!" :plugin/not-installed "Nicht installiert" + :plugin/open-logs "Protokolle anzeigen" :plugin/open-logseq-dir "Öffnen" :plugin/open-package "Paket öffnen" :plugin/open-preferences "Einstellungen öffnen" @@ -1729,7 +1730,6 @@ :ui/deleted "Gelöscht" :ui/dont-remind-me-again "Nicht mehr erinnern" :ui/empty "Leer" - :ui/error "Fehler" :ui/error-boundary-error "Fehler von der Oberfläche abgefangen!\n {1}" :ui/export "Exportieren" :ui/false "Falsch" diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index 02664c4638..b1fa2d4c9e 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -97,10 +97,13 @@ :block/remove-tag "Remove tag" :block/remove-this-tag "Remove this tag" :block/render-error "Block Render Error:" + :block/retry-plugin-renderer "Retry plugin renderer" :block/set-query "Set query" :block/set-query-label "Set query:" :block/sort-order "Sort order" :block/status-history "Status history" + :block/switch-to-outline-view "Switch to outline view" + :block/switch-to-plugin-renderer "Switch to plugin renderer" :block/untitled-query "Untitled query" :block.macro/embed-deprecated "{{embed}} is deprecated. Use '/Node embed' command instead." @@ -1172,11 +1175,19 @@ :plugin/load-plugin-indicator "Load plugin: {1}..." :plugin/load-unpacked "Load unpacked plugin" :plugin/loading-indicator "LOADING" + :plugin/logs-clear "Clear" + :plugin/logs-copied "Logs copied to clipboard." + :plugin/logs-copy "Copy all" + :plugin/logs-empty "No logs yet." + :plugin/logs-filter-placeholder "Filter logs..." + :plugin/logs-level-all "All levels" + :plugin/logs-title "Plugin logs" :plugin/malformed-plugins-edn "Malformed plugins.edn provided. Please check the file has correct edn syntax." :plugin/marketplace "Marketplace" :plugin/new-registered "New plugin registered!" :plugin/no-settings-schema "No Settings Schema!" :plugin/not-installed "Not installed" + :plugin/open-logs "View logs" :plugin/open-logseq-dir "Open" :plugin/open-package "Open package" :plugin/open-preferences "Open Preferences" @@ -1652,6 +1663,12 @@ :settings.sync-server/url "Sync Server URL" :settings.sync-server/url-desc "Set a custom HTTPS sync server URL for self-hosted sync. Your Logseq authentication tokens will be sent to this server, so only use a trusted URL. Leave empty to use the official Logseq Sync." :settings.sync-server/url-invalid-error "URL must start with https:// or http://" + :settings-page/publish-server-url "Publish server URL" + :settings-page/publish-server-url-desc "Set a custom HTTPS publish server URL for self-hosted single-page publishing. Your Logseq authentication tokens will be sent to this server, so only use a trusted URL. Leave empty to use the official Logseq publish service." + :settings-page/publish-server-url-saved "Publish server URL saved." + :settings-page/publish-server-url-cleared "Publish server URL cleared. Using official Logseq publish." + :settings-page/publish-server-url-default "Logseq publish" + :settings-page/publish-server-url-reset "Reset to default" :shell/input-command-title "Input command" @@ -1733,7 +1750,6 @@ :ui/deleted "Deleted" :ui/dont-remind-me-again "Don't remind me again" :ui/empty "Empty" - :ui/error "Error" :ui/error-boundary-error "Error caught by UI!\n {1}" :ui/export "Export" :ui/false "False" diff --git a/src/resources/dicts/es.edn b/src/resources/dicts/es.edn index ee4eb5f936..0573eb5c92 100644 --- a/src/resources/dicts/es.edn +++ b/src/resources/dicts/es.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nueva extensión registrada" :plugin/no-settings-schema "¡Sin esquema de configuración!" :plugin/not-installed "No instalado" + :plugin/open-logs "Ver registros" :plugin/open-logseq-dir "Abrir" :plugin/open-package "Abrir paquete" :plugin/open-preferences "Abrir preferencias" @@ -1729,7 +1730,6 @@ :ui/deleted "Eliminado" :ui/dont-remind-me-again "No recordarme de nuevo" :ui/empty "Vacío" - :ui/error "Error" :ui/error-boundary-error "¡Error capturado por la interfaz!\n {1}" :ui/export "Exportar" :ui/false "Falso" diff --git a/src/resources/dicts/fa.edn b/src/resources/dicts/fa.edn index be73542b93..2fe92c1af9 100644 --- a/src/resources/dicts/fa.edn +++ b/src/resources/dicts/fa.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "افزونه جدید ثبت شد" :plugin/no-settings-schema "بدون طرح تنظیمات!" :plugin/not-installed "نصب نشده" + :plugin/open-logs "مشاهده گزارش‌ها" :plugin/open-logseq-dir "باز کردن پوشه Logseq" :plugin/open-package "باز کردن بسته" :plugin/open-preferences "باز کردن تنظیمات" @@ -1729,7 +1730,6 @@ :ui/deleted "حذف‌شده" :ui/dont-remind-me-again "دیگر یادآوری نکن" :ui/empty "خالی" - :ui/error "خطا" :ui/error-boundary-error "خطا توسط رابط کاربری گرفته شد!\n {1}" :ui/export "صدور" :ui/false "نادرست" diff --git a/src/resources/dicts/fr.edn b/src/resources/dicts/fr.edn index 9319c7621b..6be01c600d 100644 --- a/src/resources/dicts/fr.edn +++ b/src/resources/dicts/fr.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nouvelle extension enregistrée" :plugin/no-settings-schema "Pas de schéma de paramètres !" :plugin/not-installed "Non installée" + :plugin/open-logs "Voir les journaux" :plugin/open-logseq-dir "Ouvrir" :plugin/open-package "Ouvrir le paquet" :plugin/open-preferences "Ouvrir les préférences" @@ -1729,7 +1730,6 @@ :ui/deleted "Supprimé" :ui/dont-remind-me-again "Ne plus me le rappeler" :ui/empty "Vide" - :ui/error "Erreur" :ui/error-boundary-error "Erreur interceptée par l'interface !\n {1}" :ui/export "Exporter" :ui/false "Faux" diff --git a/src/resources/dicts/id.edn b/src/resources/dicts/id.edn index 123d78908d..dae26d894c 100644 --- a/src/resources/dicts/id.edn +++ b/src/resources/dicts/id.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Plugin baru terdaftar" :plugin/no-settings-schema "Tidak Ada Skema Pengaturan!" :plugin/not-installed "Belum Terpasang" + :plugin/open-logs "Lihat log" :plugin/open-logseq-dir "Buka" :plugin/open-package "Buka paket" :plugin/open-preferences "Buka Preferensi" @@ -1729,7 +1730,6 @@ :ui/deleted "Dihapus" :ui/dont-remind-me-again "Jangan ingatkan saya lagi" :ui/empty "Kosong" - :ui/error "Kesalahan" :ui/error-boundary-error "Kesalahan tertangkap oleh UI!\n {1}" :ui/export "Ekspor" :ui/false "Salah" diff --git a/src/resources/dicts/it.edn b/src/resources/dicts/it.edn index c2f40a0252..ee215b10f8 100644 --- a/src/resources/dicts/it.edn +++ b/src/resources/dicts/it.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nuova estensione registrata" :plugin/no-settings-schema "Nessuno schema impostazioni!" :plugin/not-installed "Non installati" + :plugin/open-logs "Visualizza log" :plugin/open-logseq-dir "Apri" :plugin/open-package "Apri pacchetto" :plugin/open-preferences "Apri preferenze" @@ -1729,7 +1730,6 @@ :ui/deleted "Eliminato" :ui/dont-remind-me-again "Non ricordarmelo più" :ui/empty "Vuoto" - :ui/error "Errore" :ui/error-boundary-error "Errore rilevato dall'interfaccia!\n {1}" :ui/export "Esporta" :ui/false "Falso" diff --git a/src/resources/dicts/ja.edn b/src/resources/dicts/ja.edn index dc3298a141..cd83cbf92b 100644 --- a/src/resources/dicts/ja.edn +++ b/src/resources/dicts/ja.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "新しいプラグインが登録されました" :plugin/no-settings-schema "設定スキーマがありません!" :plugin/not-installed "未インストール" + :plugin/open-logs "ログを表示" :plugin/open-logseq-dir "Logseqディレクトリを開く" :plugin/open-package "パッケージを開く" :plugin/open-preferences "環境設定を開く" @@ -1729,7 +1730,6 @@ :ui/deleted "削除済み" :ui/dont-remind-me-again "今後表示しない" :ui/empty "空" - :ui/error "エラー" :ui/error-boundary-error "UIでエラーが発生しました!\n {1}" :ui/export "エクスポート" :ui/false "偽" diff --git a/src/resources/dicts/ko.edn b/src/resources/dicts/ko.edn index 0b7fff880c..c8256ac6d3 100644 --- a/src/resources/dicts/ko.edn +++ b/src/resources/dicts/ko.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "새 플러그인이 등록되었습니다" :plugin/no-settings-schema "설정 스키마 없음!" :plugin/not-installed "설치되지 않음" + :plugin/open-logs "로그 보기" :plugin/open-logseq-dir "Logseq 디렉토리 열기" :plugin/open-package "패키지 열기" :plugin/open-preferences "환경설정 열기" @@ -1729,7 +1730,6 @@ :ui/deleted "삭제됨" :ui/dont-remind-me-again "다시 알리지 않기" :ui/empty "비어 있음" - :ui/error "오류" :ui/error-boundary-error "UI 오류 발생!\n {1}" :ui/export "내보내기" :ui/false "거짓" diff --git a/src/resources/dicts/nb-no.edn b/src/resources/dicts/nb-no.edn index a4380bb92c..a6325f1353 100644 --- a/src/resources/dicts/nb-no.edn +++ b/src/resources/dicts/nb-no.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Ny utvidelse registrert" :plugin/no-settings-schema "Ingen innstillingsskjema!" :plugin/not-installed "Ikke installert" + :plugin/open-logs "Vis logger" :plugin/open-logseq-dir "Åpne Logseq-mappe" :plugin/open-package "Åpne pakke" :plugin/open-preferences "Åpne innstillinger" @@ -1729,7 +1730,6 @@ :ui/deleted "Slettet" :ui/dont-remind-me-again "Ikke påminn meg igjen" :ui/empty "Tom" - :ui/error "Feil" :ui/error-boundary-error "Feil fanget av brukergrensesnittet!\n {1}" :ui/export "Eksporter" :ui/false "Usann" diff --git a/src/resources/dicts/nl.edn b/src/resources/dicts/nl.edn index dd1273c56b..683ce0fa29 100644 --- a/src/resources/dicts/nl.edn +++ b/src/resources/dicts/nl.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nieuwe plug-in geregistreerd" :plugin/no-settings-schema "Geen instellingsschema!" :plugin/not-installed "Niet geïnstalleerd" + :plugin/open-logs "Logboeken bekijken" :plugin/open-logseq-dir "Logseq-map openen" :plugin/open-package "Open pakket" :plugin/open-preferences "Voorkeuren openen" @@ -1729,7 +1730,6 @@ :ui/deleted "Verwijderd" :ui/dont-remind-me-again "Herinner me hier niet meer aan" :ui/empty "Leeg" - :ui/error "Fout" :ui/error-boundary-error "Fout opgevangen door UI!\n {1}" :ui/export "Exporteren" :ui/false "Onwaar" diff --git a/src/resources/dicts/pl.edn b/src/resources/dicts/pl.edn index 070202a5f7..06b433faf8 100644 --- a/src/resources/dicts/pl.edn +++ b/src/resources/dicts/pl.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nowa wtyczka zarejestrowana" :plugin/no-settings-schema "Brak schematu ustawień!" :plugin/not-installed "Nie zainstalowany" + :plugin/open-logs "Wyświetl dzienniki" :plugin/open-logseq-dir "Otwórz folder Logseq" :plugin/open-package "Otwórz paczkę" :plugin/open-preferences "Otwórz preferencje" @@ -1729,7 +1730,6 @@ :ui/deleted "Usunięto" :ui/dont-remind-me-again "Nie przypominaj mi ponownie" :ui/empty "Puste" - :ui/error "Błąd" :ui/error-boundary-error "Błąd przechwycony przez UI!\n {1}" :ui/export "Eksportuj" :ui/false "Fałsz" diff --git a/src/resources/dicts/pt-br.edn b/src/resources/dicts/pt-br.edn index b91080b6b1..50e87744a1 100644 --- a/src/resources/dicts/pt-br.edn +++ b/src/resources/dicts/pt-br.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Novo plugin registrado" :plugin/no-settings-schema "Sem esquema de configurações!" :plugin/not-installed "Não instalado" + :plugin/open-logs "Ver registros" :plugin/open-logseq-dir "Abrir" :plugin/open-package "Abrir pacote" :plugin/open-preferences "Abrir Preferências" @@ -1729,7 +1730,6 @@ :ui/deleted "Excluído" :ui/dont-remind-me-again "Não me lembrar novamente" :ui/empty "Vazio" - :ui/error "Erro" :ui/error-boundary-error "Erro capturado pela UI!\n {1}" :ui/export "Exportar" :ui/false "Falso" diff --git a/src/resources/dicts/pt-pt.edn b/src/resources/dicts/pt-pt.edn index 12dc682d7c..b0ba023ff3 100644 --- a/src/resources/dicts/pt-pt.edn +++ b/src/resources/dicts/pt-pt.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nova extensão registada" :plugin/no-settings-schema "Sem esquema de definições!" :plugin/not-installed "Não instalado" + :plugin/open-logs "Ver registos" :plugin/open-logseq-dir "Abrir pasta Logseq" :plugin/open-package "Abrir pacote" :plugin/open-preferences "Abrir preferências" @@ -1729,7 +1730,6 @@ :ui/deleted "Eliminado" :ui/dont-remind-me-again "Não me lembrar novamente" :ui/empty "Vazio" - :ui/error "Erro" :ui/error-boundary-error "Erro capturado pela interface!\n {1}" :ui/export "Exportar" :ui/false "Falso" diff --git a/src/resources/dicts/ru.edn b/src/resources/dicts/ru.edn index 348fd9afc1..4772a754d6 100644 --- a/src/resources/dicts/ru.edn +++ b/src/resources/dicts/ru.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Новый плагин зарегистрирован" :plugin/no-settings-schema "Нет схемы настроек!" :plugin/not-installed "Не установлено" + :plugin/open-logs "Просмотр журналов" :plugin/open-logseq-dir "Открыть папку Logseq" :plugin/open-package "Открыть пакет" :plugin/open-preferences "Открыть настройки" @@ -1729,7 +1730,6 @@ :ui/deleted "Удалено" :ui/dont-remind-me-again "Больше не напоминать" :ui/empty "Пусто" - :ui/error "Ошибка" :ui/error-boundary-error "Ошибка перехвачена интерфейсом!\n {1}" :ui/export "Экспорт" :ui/false "Ложь" diff --git a/src/resources/dicts/sk.edn b/src/resources/dicts/sk.edn index bad4a21e51..bdc281e056 100644 --- a/src/resources/dicts/sk.edn +++ b/src/resources/dicts/sk.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nový plugin zaregistrován" :plugin/no-settings-schema "Žiadna schéma nastavení!" :plugin/not-installed "Nenainštalované" + :plugin/open-logs "Zobraziť záznamy" :plugin/open-logseq-dir "Otvoriť složku Logseq" :plugin/open-package "Otvoriť balík" :plugin/open-preferences "Otvoriť předvolby" @@ -1729,7 +1730,6 @@ :ui/deleted "Odstránené" :ui/dont-remind-me-again "Už mi nepripomínať" :ui/empty "Prázdne" - :ui/error "Chyba" :ui/error-boundary-error "Chyba zachytená v UI!\n {1}" :ui/export "Exportovať" :ui/false "Nepravda" diff --git a/src/resources/dicts/tr.edn b/src/resources/dicts/tr.edn index b295d53dca..5d42e71bbb 100644 --- a/src/resources/dicts/tr.edn +++ b/src/resources/dicts/tr.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Yeni eklenti kaydedildi" :plugin/no-settings-schema "Ayar Şeması Yok!" :plugin/not-installed "Yüklü olmayan" + :plugin/open-logs "Günlükleri görüntüle" :plugin/open-logseq-dir "Logseq klasörünü aç" :plugin/open-package "Eklentinin içeriğini aç" :plugin/open-preferences "Tercihleri aç" @@ -1729,7 +1730,6 @@ :ui/deleted "Silindi" :ui/dont-remind-me-again "Bir daha hatırlatma" :ui/empty "Boş" - :ui/error "Hata" :ui/error-boundary-error "Arayüz tarafından yakalanan hata!\n {1}" :ui/export "Dışa Aktar" :ui/false "Yanlış" diff --git a/src/resources/dicts/uk.edn b/src/resources/dicts/uk.edn index a3c93f6019..fbca952ff2 100644 --- a/src/resources/dicts/uk.edn +++ b/src/resources/dicts/uk.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Новый плагін зарегистрирован" :plugin/no-settings-schema "Немає схеми налаштувань!" :plugin/not-installed "Не встановленно" + :plugin/open-logs "Переглянути журнали" :plugin/open-logseq-dir "Відкрити папку Logseq" :plugin/open-package "Відкрити пакунок" :plugin/open-preferences "Відкрити налаштування" @@ -1729,7 +1730,6 @@ :ui/deleted "Видалено" :ui/dont-remind-me-again "Більше не нагадувати" :ui/empty "Порожньо" - :ui/error "Помилка" :ui/error-boundary-error "Помилка перехоплена інтерфейсом!\n {1}" :ui/export "Експорт" :ui/false "Хибно" diff --git a/src/resources/dicts/zh-cn.edn b/src/resources/dicts/zh-cn.edn index 4697853c11..990fa749a4 100644 --- a/src/resources/dicts/zh-cn.edn +++ b/src/resources/dicts/zh-cn.edn @@ -1177,6 +1177,7 @@ :plugin/new-registered "新插件已注册!" :plugin/no-settings-schema "没有设置模式!" :plugin/not-installed "未安装" + :plugin/open-logs "查看日志" :plugin/open-logseq-dir "打开" :plugin/open-package "打开包目录" :plugin/open-preferences "打开插件偏好设置" @@ -1733,7 +1734,6 @@ :ui/deleted "已删除" :ui/dont-remind-me-again "不再提醒" :ui/empty "空" - :ui/error "错误" :ui/error-boundary-error "界面捕获到错误!\n {1}" :ui/export "导出" :ui/false "否" diff --git a/src/resources/dicts/zh-hant.edn b/src/resources/dicts/zh-hant.edn index 7bd1689a99..d132c42120 100644 --- a/src/resources/dicts/zh-hant.edn +++ b/src/resources/dicts/zh-hant.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "新外掛已註冊!" :plugin/no-settings-schema "沒有設定結構!" :plugin/not-installed "尚未下載" + :plugin/open-logs "查看日誌" :plugin/open-logseq-dir "開啟" :plugin/open-package "開啟封包" :plugin/open-preferences "開啟外掛偏好設定" @@ -1729,7 +1730,6 @@ :ui/deleted "已刪除" :ui/dont-remind-me-again "不再提醒" :ui/empty "空" - :ui/error "錯誤" :ui/error-boundary-error "UI 攔截到錯誤!\n {1}" :ui/export "匯出" :ui/false "否" diff --git a/src/test/electron/spell_check_test.cljs b/src/test/electron/spell_check_test.cljs new file mode 100644 index 0000000000..0b4f1358a1 --- /dev/null +++ b/src/test/electron/spell_check_test.cljs @@ -0,0 +1,35 @@ +(ns electron.spell-check-test + (:require [cljs.test :refer [deftest is testing]] + [electron.spell-check])) + +(defn- session-spellcheck-enabled? + [value] + (when-let [f (resolve 'electron.spell-check/session-spellcheck-enabled?)] + (f value))) + +(defn- apply-window-spellcheck! + [win enabled?] + (when-let [f (resolve 'electron.spell-check/apply-window-spellcheck!)] + (f win enabled?))) + +(deftest session-spellcheck-enabled?-test + (testing "defaults to enabled unless the stored config is explicitly false" + (is (true? (session-spellcheck-enabled? nil))) + (is (true? (session-spellcheck-enabled? true))) + (is (false? (session-spellcheck-enabled? false))))) + +(deftest apply-window-spellcheck!-test + (testing "updates the BrowserWindow session spell checker state" + (let [calls (atom []) + session (js-obj "spellCheckerEnabled" true) + web-contents (js-obj "session" session) + win (js-obj "webContents" web-contents)] + (aset session "setSpellCheckerEnabled" + (fn [enabled?] + (swap! calls conj enabled?) + (aset session "spellCheckerEnabled" enabled?))) + + (apply-window-spellcheck! win false) + + (is (= [false] @calls)) + (is (false? (.-spellCheckerEnabled session)))))) diff --git a/src/test/frontend/handler/editor_test.cljs b/src/test/frontend/handler/editor_test.cljs index cb6f6a273a..09ba4180c8 100644 --- a/src/test/frontend/handler/editor_test.cljs +++ b/src/test/frontend/handler/editor_test.cljs @@ -209,6 +209,43 @@ (editor/save-block! repo block-uuid "# bar") (is (= "bar" (:block/title (model/query-block-by-uuid block-uuid))))))) +(deftest block-default-collapsed-respects-ignore-block-collapsed-flag + (with-redefs [db/entity (constantly nil)] + (is (true? (editor/block-default-collapsed? + {:block/collapsed? true} + {}))) + (is (not (editor/block-default-collapsed? + {:block/collapsed? true} + {:ignore-block-collapsed? true})) + "Flashcard review should be able to ignore persisted collapsed state") + (is (true? (editor/block-default-collapsed? + {:block/collapsed? false} + {:ignore-block-collapsed? true + :default-collapsed? true})) + "Ignore flag should not disable other default-collapsed rules"))) + +(deftest load-children-respects-ignore-block-collapsed-flag + (is (false? (#'editor/load-children? + {:block/collapsed? true} + nil + false)) + "Collapsed blocks should not load children by default") + (is (true? (#'editor/load-children? + {:block/collapsed? true} + nil + true)) + "Flashcard answer mode should force loading children for collapsed blocks") + (is (true? (#'editor/load-children? + {:block/collapsed? true} + false + false)) + "Temporary expanded UI state should load children") + (is (false? (#'editor/load-children? + {:block/collapsed? false} + true + false)) + "Temporary collapsed UI state should skip children loading")) + (deftest paste-cut-recycled-block-moves-existing-node-out-of-recycle (test-helper/load-test-files [{:page {:block/title "Page 1"} :blocks [{:block/title "source"}]} diff --git a/src/test/frontend/modules/outliner/core_test.cljs b/src/test/frontend/modules/outliner/core_test.cljs index 20bf9c60ef..cc816a4845 100644 --- a/src/test/frontend/modules/outliner/core_test.cljs +++ b/src/test/frontend/modules/outliner/core_test.cljs @@ -514,6 +514,58 @@ (is (= 1 (count children))) (is (= "child" (:block/title (get-block (first children))))))))) +(deftest test-paste-property-values-into-empty-property-value-block + (testing "replace-empty-target remaps pasted property value uuids before adding many-property refs" + (transact-tree! [[25]]) + (db/transact! test-db [{:block/uuid 25 + :block/title ""}]) + (let [conn (db/get-db test-db false) + _ (db/transact! test-db [{:db/ident :logseq.property/created-from-property + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true}]) + property-ident :user.property/reproduciblesteps + _ (db/transact! test-db [{:db/ident property-ident + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/many + :logseq.property/type :default}]) + property (d/entity @conn property-ident) + property-ident (:db/ident property) + target-block (get-block 25) + _ (db/transact! test-db [{:db/id (:db/id target-block) + :logseq.property/created-from-property (:db/id property)} + [:db/add (:db/id (:block/parent target-block)) + property-ident + (:db/id target-block)]]) + target-block (get-block 25) + _ (is (some? (:logseq.property/created-from-property target-block))) + copied-blocks [{:block/uuid 101 + :block/title "1" + :block/parent 1} + {:block/uuid 102 + :block/title "2" + :block/parent [:block/uuid 101]} + {:block/uuid 103 + :block/title "3" + :block/parent 1}]] + (outliner-tx/transact! + (transact-opts) + (outliner-core/insert-blocks! conn + copied-blocks + target-block + {:sibling? true + :keep-uuid? true + :outliner-op :paste + :replace-empty-target? true})) + (let [parent (d/entity @conn (:db/id (:block/parent target-block))) + values (get parent property-ident) + titles (set (map :block/title values))] + ;; Old copied uuid should not survive as a dangling property value ref. + (is (nil? (get-block 101))) + ;; Pasted values should be the replaced target and the new sibling top-level block. + (is (= #{"1" "3"} titles)) + (is (contains? (set (map :db/id values)) (:db/id (get-block 25)))))))) + (deftest test-batch-transact (testing "add 4, 5 after 2 and delete 3" (let [tree' [[10 [[2] [3]]]]]