diff --git a/.gitignore b/.gitignore index 9efc3a3ad0..0291da0a6c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,10 @@ packages/ui/.storybook/cljs deps/shui/.lsp deps/shui/.lsp-cache deps/shui/.clj-kondo +deps/publish/worker/.wrangler +deps/publish/worker/dist +deps/publish/worker/.env* + tx-log* clj-e2e/.wally clj-e2e/resources diff --git a/deps.edn b/deps.edn index 4990db9211..b07329829e 100644 --- a/deps.edn +++ b/deps.edn @@ -30,7 +30,6 @@ expound/expound {:mvn/version "0.8.6"} com.lambdaisland/glogi {:git/url "https://github.com/lambdaisland/glogi" :git/sha "30328a045141717aadbbb693465aed55f0904976"} - binaryage/devtools {:mvn/version "1.0.5"} camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"} instaparse/instaparse {:mvn/version "1.4.10"} org.clojars.mmb90/cljs-cache {:mvn/version "0.1.4"} @@ -56,9 +55,9 @@ :extra-deps {org.clojure/tools.namespace {:mvn/version "0.2.11"} cider/cider-nrepl {:mvn/version "0.55.1"} org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"} - tortue/spy {:mvn/version "2.14.0"}} + tortue/spy {:mvn/version "2.14.0"} + binaryage/devtools {:mvn/version "1.0.5"}} :main-opts ["-m" "shadow.cljs.devtools.cli"]} - :test {:extra-paths ["src/test/"] :extra-deps {org.clojure/test.check {:mvn/version "1.1.1"} pjstadig/humane-test-output {:mvn/version "0.11.0"} diff --git a/deps/db/src/logseq/db/common/entity_plus.cljc b/deps/db/src/logseq/db/common/entity_plus.cljc index 85b02552ce..9266589c74 100644 --- a/deps/db/src/logseq/db/common/entity_plus.cljc +++ b/deps/db/src/logseq/db/common/entity_plus.cljc @@ -53,25 +53,21 @@ [] (vreset! *seen-immutable-entities {})) -(def ^:private *reset-cache-background-task-running? - ;; missionary is not compatible with nbb, so entity-memoized is disabled in nbb - (delay - ;; FIXME: Correct dependency ordering instead of resolve workaround - #?(:org.babashka/nbb false - :cljs (when-let [f (resolve 'frontend.common.missionary/background-task-running?)] - (f :logseq.db.common.entity-plus/reset-immutable-entities-cache!))))) +(defonce *reset-cache-background-task-running-f (atom nil)) (defn entity-memoized [db eid] (if (and (qualified-keyword? eid) (not (exists? js/process))) ; don't memoize on node (when-not (contains? nil-db-ident-entities eid) ;fast return nil - (if (and @*reset-cache-background-task-running? - (contains? immutable-db-ident-entities eid)) ;return cache entity if possible which isn't nil - (or (get @*seen-immutable-entities eid) - (let [r (d/entity db eid)] - (when r (vswap! *seen-immutable-entities assoc eid r)) - r)) - (d/entity db eid))) + (let [f @*reset-cache-background-task-running-f] + (if (and (fn? f) + (f :logseq.db.common.entity-plus/reset-immutable-entities-cache!) + (contains? immutable-db-ident-entities eid)) ;return cache entity if possible which isn't nil + (or (get @*seen-immutable-entities eid) + (let [r (d/entity db eid)] + (when r (vswap! *seen-immutable-entities assoc eid r)) + r)) + (d/entity db eid)))) (d/entity db eid))) (defn unsafe->Entity diff --git a/deps/db/src/logseq/db/frontend/malli_schema.cljs b/deps/db/src/logseq/db/frontend/malli_schema.cljs index 6208c38f17..838e5f557f 100644 --- a/deps/db/src/logseq/db/frontend/malli_schema.cljs +++ b/deps/db/src/logseq/db/frontend/malli_schema.cljs @@ -285,7 +285,8 @@ [:block/tags {:optional true} block-tags] [:block/refs {:optional true} [:set :int]] [:block/tx-id {:optional true} :int] - [:block/collapsed? {:optional true} :boolean]]) + [:block/collapsed? {:optional true} :boolean] + [:block/warning {:optional true} [:string]]]) (def page-attrs "Common attributes for pages" diff --git a/deps/db/src/logseq/db/frontend/property.cljs b/deps/db/src/logseq/db/frontend/property.cljs index 8fa1f7041b..ad4cd5d397 100644 --- a/deps/db/src/logseq/db/frontend/property.cljs +++ b/deps/db/src/logseq/db/frontend/property.cljs @@ -389,6 +389,11 @@ :hide? true :view-context :page :public? true}} + :logseq.property.publish/published-url {:title "Published URL" + :schema + {:type :url + :view-context :page + :public? true}} :logseq.property/exclude-from-graph-view {:title "Excluded from Graph view?" :schema {:type :checkbox @@ -651,7 +656,8 @@ "logseq.property.linked-references" "logseq.property.asset" "logseq.property.table" "logseq.property.node" "logseq.property.code" "logseq.property.repeat" "logseq.property.journal" "logseq.property.class" "logseq.property.view" - "logseq.property.user" "logseq.property.history" "logseq.property.embedding"}) + "logseq.property.user" "logseq.property.history" "logseq.property.embedding" + "logseq.property.publish"}) (defn logseq-property? "Determines if keyword is a logseq property" diff --git a/deps/db/src/logseq/db/frontend/schema.cljs b/deps/db/src/logseq/db/frontend/schema.cljs index 69c40a5684..2ae81a97b0 100644 --- a/deps/db/src/logseq/db/frontend/schema.cljs +++ b/deps/db/src/logseq/db/frontend/schema.cljs @@ -37,7 +37,7 @@ (map (juxt :major :minor) [(parse-schema-version x) (parse-schema-version y)]))) -(def version (parse-schema-version "65.16")) +(def version (parse-schema-version "65.18")) (defn major-version "Return a number. diff --git a/deps/db/src/logseq/db/sqlite/create_graph.cljs b/deps/db/src/logseq/db/sqlite/create_graph.cljs index edb366de78..401f3a85b0 100644 --- a/deps/db/src/logseq/db/sqlite/create_graph.cljs +++ b/deps/db/src/logseq/db/sqlite/create_graph.cljs @@ -207,6 +207,16 @@ :file/path (str "logseq/" "custom.js") :file/content "" :file/created-at (js/Date.) + :file/last-modified-at (js/Date.)} + {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "logseq/publish.css") + :file/path (str "logseq/" "publish.css") + :file/content "" + :file/created-at (js/Date.) + :file/last-modified-at (js/Date.)} + {:block/uuid (common-uuid/gen-uuid :builtin-block-uuid "logseq/publish.js") + :file/path (str "logseq/" "publish.js") + :file/content "" + :file/created-at (js/Date.) :file/last-modified-at (js/Date.)}]) (defn build-db-initial-data @@ -225,7 +235,9 @@ import-type (into (sqlite-util/import-tx import-type)) graph-git-sha - (conj (sqlite-util/kv :logseq.kv/graph-git-sha graph-git-sha))) + (conj (sqlite-util/kv :logseq.kv/graph-git-sha graph-git-sha)) + true + (conj (sqlite-util/kv :logseq.kv/graph-uuid (common-uuid/gen-uuid)))) initial-files (build-initial-files config-content) {properties-tx :tx :keys [properties]} (build-initial-properties) db-ident->properties (zipmap (map :db/ident properties) properties) diff --git a/deps/db/test/logseq/db/sqlite/create_graph_test.cljs b/deps/db/test/logseq/db/sqlite/create_graph_test.cljs index aaf0ad3926..2d4e89e020 100644 --- a/deps/db/test/logseq/db/sqlite/create_graph_test.cljs +++ b/deps/db/test/logseq/db/sqlite/create_graph_test.cljs @@ -155,8 +155,10 @@ (deftest build-db-initial-data-test (testing "idempotent initial-data" (letfn [(remove-ignored-attrs&entities [init-data] - (let [[before after] (split-with #(not= :logseq.kv/graph-created-at (:db/ident %)) init-data) - init-data* (concat before (rest after))] + (let [ignored-idents #{:logseq.kv/graph-created-at :logseq.kv/graph-uuid} + init-data* (remove (fn [ent] + (contains? ignored-idents (:db/ident ent))) + init-data)] (map (fn [ent] (dissoc ent :block/created-at :block/updated-at :file/last-modified-at :file/created-at diff --git a/deps/db/test/logseq/db/sqlite/export_test.cljs b/deps/db/test/logseq/db/sqlite/export_test.cljs index c1af1b0769..32129ccd71 100644 --- a/deps/db/test/logseq/db/sqlite/export_test.cljs +++ b/deps/db/test/logseq/db/sqlite/export_test.cljs @@ -749,7 +749,11 @@ {:file/path "logseq/custom.css" :file/content ".foo {background-color: blue}"} {:file/path "logseq/custom.js" - :file/content "// comment"}] + :file/content "// comment"} + {:file/path "logseq/publish.css" + :file/content ""} + {:file/path "logseq/publish.js" + :file/content ""}] :build-existing-tx? true}] original-data)) diff --git a/deps/publish/README.md b/deps/publish/README.md new file mode 100644 index 0000000000..452a458ea8 --- /dev/null +++ b/deps/publish/README.md @@ -0,0 +1,22 @@ +## Description + +Shared library for page publishing (snapshot payloads, SSR helpers, shared schemas, and storage contracts). + +The Cloudflare Durable Object implementation is expected to use SQLite with the +Logseq datascript fork layered on top. Page publish payloads are expected to +send datoms (transit) so the DO can reconstruct/query datascript state. + +See `deps/publish/worker` for a Cloudflare Worker skeleton that stores transit +blobs in R2 and metadata in a SQLite-backed Durable Object. + +## API + +Namespaces live under `logseq.publish`. + +## Usage + +This module is intended to be consumed by the Logseq app and the publishing worker. + +## Dev + +Keep this module aligned with the main repo's linting and testing conventions. diff --git a/deps/publish/deps.edn b/deps/publish/deps.edn new file mode 100644 index 0000000000..50676b69be --- /dev/null +++ b/deps/publish/deps.edn @@ -0,0 +1,27 @@ +{:paths ["src" "../../resources"] + :deps + {org.clojure/clojure {:mvn/version "1.11.1"} + rum/rum {:git/url "https://github.com/logseq/rum" ;; fork + :sha "5d672bf84ed944414b9f61eeb83808ead7be9127"} + + datascript/datascript {:git/url "https://github.com/logseq/datascript" ;; fork + :sha "ff5a7d5326e2546f40146e4a489343f557519bc3"} + datascript-transit/datascript-transit {:mvn/version "0.3.0" + :exclusions [datascript/datascript]} + funcool/promesa {:mvn/version "11.0.678"} + thheller/shadow-cljs {:mvn/version "3.3.4"} + logseq/common {:local/root "../common"} + logseq/graph-parser {:local/root "../graph-parser"} + logseq/db {:local/root "../db"} + missionary/missionary {:mvn/version "b.46"} + com.cognitect/transit-cljs {:mvn/version "0.8.280"} + hiccups/hiccups {:mvn/version "0.3.0"}} + :aliases + {:cljs {:extra-deps {org.clojure/tools.namespace {:mvn/version "0.2.11"} + cider/cider-nrepl {:mvn/version "0.55.1"} + org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"} + tortue/spy {:mvn/version "2.14.0"}} + :main-opts ["-m" "shadow.cljs.devtools.cli"]} + :clj-kondo + {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2024.09.27"}} + :main-opts ["-m" "clj-kondo.main"]}}} diff --git a/deps/publish/package.json b/deps/publish/package.json new file mode 100644 index 0000000000..e79315febb --- /dev/null +++ b/deps/publish/package.json @@ -0,0 +1,16 @@ +{ + "name": "@logseq/publish", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "cd ./worker && npx wrangler dev", + "watch": "clojure -M:cljs watch publish-worker", + "release": "clojure -M:cljs release publish-worker", + "clean": "rm -rf ./worker/dist/", + "bump-publish-version": "node ./scripts/bump-publish-version.js", + "deploy": "yarn bump-publish-version && yarn clean && yarn release && cd ./worker && wrangler deploy --env prod" + }, + "dependencies": { + "shadow-cljs": "^3.3.4" + } +} diff --git a/deps/publish/scripts/bump-publish-version.js b/deps/publish/scripts/bump-publish-version.js new file mode 100644 index 0000000000..9c60e96afe --- /dev/null +++ b/deps/publish/scripts/bump-publish-version.js @@ -0,0 +1,17 @@ +const fs = require("fs"); +const path = require("path"); + +const renderPath = path.join(__dirname, "..", "src", "logseq", "publish", "render.cljs"); +const source = fs.readFileSync(renderPath, "utf8"); +const timestamp = Date.now(); + +const next = source.replace( + /\(defonce version [^)]+\)/, + `(defonce version ${timestamp})` +); + +if (next === source) { + throw new Error("Failed to update logseq.publish.render/version."); +} + +fs.writeFileSync(renderPath, next); diff --git a/deps/publish/shadow-cljs.edn b/deps/publish/shadow-cljs.edn new file mode 100644 index 0000000000..396ea96b6e --- /dev/null +++ b/deps/publish/shadow-cljs.edn @@ -0,0 +1,12 @@ +;; shadow-cljs configuration +{:deps true + :http {:port 9631} + :nrepl {:port 8702} + :builds + {:publish-worker {:target :esm + :output-dir "worker/dist/worker" + :modules {:main {:exports {default logseq.publish.worker/worker + PublishMetaDO logseq.publish.worker/PublishMetaDO}}} + :js-options {:js-provider :import} + :closure-defines {shadow.cljs.devtools.client.env/enabled false} + :devtools {:enabled false}}}} diff --git a/deps/publish/src/logseq/publish/assets.cljs b/deps/publish/src/logseq/publish/assets.cljs new file mode 100644 index 0000000000..b554ae56ab --- /dev/null +++ b/deps/publish/src/logseq/publish/assets.cljs @@ -0,0 +1,66 @@ +(ns logseq.publish.assets + (:require [clojure.string :as string] + [logseq.publish.common :as publish-common]) + (:require-macros [logseq.publish.async :refer [js-await]])) + +(defn asset-content-type [ext] + (case (string/lower-case (or ext "")) + ("png") "image/png" + ("jpg" "jpeg") "image/jpeg" + ("gif") "image/gif" + ("webp") "image/webp" + ("svg") "image/svg+xml" + ("bmp") "image/bmp" + ("avif") "image/avif" + ("mp4") "video/mp4" + ("webm") "video/webm" + ("mov") "video/quicktime" + ("mp3") "audio/mpeg" + ("wav") "audio/wav" + ("ogg") "audio/ogg" + ("pdf") "application/pdf" + "application/octet-stream")) + +(defn parse-asset-meta-header [request] + (let [meta-header (.get (.-headers request) "x-asset-meta")] + (when meta-header + (try + (publish-common/normalize-meta (js/JSON.parse meta-header)) + (catch :default _ + nil))))) + +(defn handle-post-asset [request env] + (js-await [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 (publish-common/verify-jwt token env))] + (if (nil? claims) + (publish-common/unauthorized) + (let [meta (parse-asset-meta-header request) + graph-uuid (get meta :graph) + asset-uuid (get meta :asset_uuid) + asset-type (get meta :asset_type) + checksum (get meta :checksum)] + (if (or (nil? meta) (string/blank? graph-uuid) (string/blank? asset-uuid) (string/blank? asset-type)) + (publish-common/bad-request "missing asset metadata") + (js-await [body (.arrayBuffer request) + r2 (aget env "PUBLISH_R2") + r2-key (str "publish/assets/" graph-uuid "/" asset-uuid "." asset-type) + ^js existing (.head r2 r2-key) + existing-checksum (when existing + (when-let [meta (.-customMetadata existing)] + (aget meta "checksum"))) + content-type (or (get meta :content_type) + (asset-content-type asset-type)) + put? (not (and existing-checksum checksum (= existing-checksum checksum))) + _ (when put? + (.put r2 r2-key body + #js {:httpMetadata #js {:contentType content-type} + :customMetadata #js {:checksum (or checksum "") + :owner_sub (aget claims "sub")}}))] + (publish-common/json-response {:asset_uuid asset-uuid + :graph_uuid graph-uuid + :asset_type asset-type + :asset_url (str "/asset/" graph-uuid "/" asset-uuid "." asset-type)}))))))) diff --git a/deps/publish/src/logseq/publish/async.clj b/deps/publish/src/logseq/publish/async.clj new file mode 100644 index 0000000000..303f1431f2 --- /dev/null +++ b/deps/publish/src/logseq/publish/async.clj @@ -0,0 +1,11 @@ +(ns logseq.publish.async + (:require [shadow.cljs.modern])) + +(defmacro js-await + "Like `let` but for async values, executed sequentially. + Non-async values are wrapped in `js/Promise.resolve`." + [[a b & bindings] & body] + (let [b `(~'js/Promise.resolve ~b)] + (if (seq bindings) + `(shadow.cljs.modern/js-await ~[a b] (js-await ~bindings ~@body)) + `(shadow.cljs.modern/js-await ~[a b] ~@body)))) diff --git a/deps/publish/src/logseq/publish/common.cljs b/deps/publish/src/logseq/publish/common.cljs new file mode 100644 index 0000000000..8955987064 --- /dev/null +++ b/deps/publish/src/logseq/publish/common.cljs @@ -0,0 +1,332 @@ +(ns logseq.publish.common + (:require [clojure.string :as string] + [cognitect.transit :as transit] + [datascript.transit :as dt] + [logseq.db :as ldb]) + (:require-macros [logseq.publish.async :refer [js-await]])) + +(def text-decoder (js/TextDecoder.)) +(def text-encoder (js/TextEncoder.)) + +(def ^:private fallback-transit-reader + (let [handlers (assoc dt/read-handlers + "datascript/Entity" identity + "error" (fn [m] (ex-info (:message m) (:data m))) + "js/Error" (fn [m] (js/Error. (:message m)))) + reader (transit/reader :json {:handlers handlers})] + (fn [s] + (transit/read reader s)))) + +(defn read-transit-safe [s] + (try + (ldb/read-transit-str s) + (catch :default _ + (fallback-transit-reader s)))) + +(defn cors-headers + [] + #js {"access-control-allow-origin" "*" + "access-control-allow-methods" "GET,POST,DELETE,OPTIONS" + "access-control-allow-headers" "content-type,authorization,x-publish-meta,x-asset-meta,if-none-match" + "access-control-expose-headers" "etag"}) + +(defn merge-headers [base extra] + (let [headers (js/Headers. base)] + (doseq [[k v] (js/Object.entries extra)] + (.set headers k v)) + headers)) + +(defn json-response + ([data] (json-response data 200)) + ([data status] + (js/Response. + (js/JSON.stringify (clj->js data)) + #js {:status status + :headers (merge-headers + #js {"content-type" "application/json"} + (cors-headers))}))) + +(defn unauthorized [] + (json-response {:error "unauthorized"} 401)) + +(defn forbidden [] + (json-response {:error "forbidden"} 403)) + +(defn bad-request [message] + (json-response {:error message} 400)) + +(defn not-found [] + (json-response {:error "not found"} 404)) + +(defn normalize-meta [meta] + (when meta + (if (map? meta) + meta + (js->clj meta :keywordize-keys true)))) + +(defn parse-meta-header [request] + (let [meta-header (.get (.-headers request) "x-publish-meta")] + (when meta-header + (try + (normalize-meta (js/JSON.parse meta-header)) + (catch :default _ + nil))))) + +(defn get-publish-meta [payload] + (when payload + (:meta payload))) + +(defn meta-from-body [buffer] + (try + (let [payload (read-transit-safe (.decode text-decoder buffer)) + meta (get-publish-meta payload)] + (normalize-meta meta)) + (catch :default e + (js/console.warn "publish: failed to parse meta from body" e) + nil))) + +(defn valid-meta? [{:keys [content_hash graph page_uuid]}] + (and content_hash graph page_uuid)) + +(defn get-sql-rows [^js result] + (let [iter-fn (when result (aget result js/Symbol.iterator))] + (cond + (nil? result) [] + (fn? (.-toArray result)) (.toArray result) + (fn? iter-fn) (vec (js/Array.from result)) + (array? (.-results result)) (.-results result) + (array? (.-rows result)) (.-rows result) + (array? result) (if (empty? result) + [] + (let [first-row (first result)] + (cond + (array? (.-results first-row)) (.-results first-row) + (array? (.-rows first-row)) (.-rows first-row) + :else result))) + :else []))) + +(defn sql-exec + [sql sql-str & args] + (.apply (.-exec sql) sql (to-array (cons sql-str args)))) + +(defn to-hex [buffer] + (->> (js/Uint8Array. buffer) + (array-seq) + (map (fn [b] (.padStart (.toString b 16) 2 "0"))) + (apply str))) + +(defn sha256-hex [message] + (js-await [data (.encode text-encoder message) + digest (.digest js/crypto.subtle "SHA-256" data)] + (to-hex digest))) + +(def password-kdf-iterations 210000) + +(defn bytes->base64url [bytes] + (let [binary (apply str (map #(js/String.fromCharCode %) (array-seq bytes))) + b64 (js/btoa binary)] + (-> b64 + (string/replace #"\+" "-") + (string/replace #"/" "_") + (string/replace #"=+$" "")))) + +(defn hash-password [password] + (js-await [salt (doto (js/Uint8Array. 16) + (js/crypto.getRandomValues)) + crypto-key (.importKey js/crypto.subtle + "raw" + (.encode text-encoder password) + #js {:name "PBKDF2"} + false + #js ["deriveBits"]) + derived (.deriveBits js/crypto.subtle + #js {:name "PBKDF2" + :hash "SHA-256" + :salt salt + :iterations password-kdf-iterations} + crypto-key + 256) + derived-bytes (js/Uint8Array. derived) + salt-encoded (bytes->base64url salt) + hash-encoded (bytes->base64url derived-bytes)] + (str "pbkdf2$sha256$" + password-kdf-iterations + "$" + salt-encoded + "$" + hash-encoded))) + +(defn base64url->uint8array [input] + (let [pad (if (pos? (mod (count input) 4)) + (apply str (repeat (- 4 (mod (count input) 4)) "=")) + "") + base64 (-> (str input pad) + (string/replace "-" "+") + (string/replace "_" "/")) + raw (js/atob base64) + data (js/Uint8Array. (.-length raw))] + (dotimes [i (.-length raw)] + (aset data i (.charCodeAt raw i))) + data)) + +(defn verify-password [password stored-hash] + (let [parts (when (string? stored-hash) + (string/split stored-hash #"\$"))] + (if-not (and (= 5 (count parts)) + (= "pbkdf2" (nth parts 0)) + (= "sha256" (nth parts 1))) + false + (js-await [iterations (js/parseInt (nth parts 2)) + salt (base64url->uint8array (nth parts 3)) + expected (base64url->uint8array (nth parts 4)) + crypto-key (.importKey js/crypto.subtle + "raw" + (.encode text-encoder password) + #js {:name "PBKDF2"} + false + #js ["deriveBits"]) + derived (.deriveBits js/crypto.subtle + #js {:name "PBKDF2" + :hash "SHA-256" + :salt salt + :iterations iterations} + crypto-key + (* 8 (.-length expected))) + derived-bytes (js/Uint8Array. derived)] + (if (not= (.-length derived-bytes) (.-length expected)) + false + (let [mismatch (reduce (fn [acc idx] + (bit-or acc + (bit-xor (aget derived-bytes idx) + (aget expected idx)))) + 0 + (range (.-length expected)))] + (zero? mismatch))))))) + +(defn hmac-sha256 [key message] + (js-await [crypto-key (.importKey js/crypto.subtle + "raw" + key + #js {:name "HMAC" :hash "SHA-256"} + false + #js ["sign"])] + (.sign js/crypto.subtle "HMAC" crypto-key message))) + +(defn encode-rfc3986 [value] + (-> (js/encodeURIComponent value) + (.replace #"[!'()*]" (fn [c] + (str "%" + (.toUpperCase (.toString (.charCodeAt c 0) 16))))))) + +(defn encode-path [path] + (->> (string/split path #"/") + (map encode-rfc3986) + (string/join "/"))) + +(defn get-signature-key [secret date-stamp region service] + (js-await [k-date (hmac-sha256 + (.encode text-encoder (str "AWS4" secret)) + (.encode text-encoder date-stamp)) + k-region (hmac-sha256 k-date (.encode text-encoder region)) + k-service (hmac-sha256 k-region (.encode text-encoder service))] + (hmac-sha256 k-service (.encode text-encoder "aws4_request")))) + +(defn presign-r2-url [r2-key env] + (js-await [region "auto" + service "s3" + host (str (aget env "R2_ACCOUNT_ID") ".r2.cloudflarestorage.com") + bucket (aget env "R2_BUCKET") + method "GET" + now (js/Date.) + amz-date (.replace (.toISOString now) #"[ :-]|\.\d{3}" "") + date-stamp (.slice amz-date 0 8) + credential-scope (str date-stamp "/" region "/" service "/aws4_request") + params (->> [["X-Amz-Algorithm" "AWS4-HMAC-SHA256"] + ["X-Amz-Credential" (str (aget env "R2_ACCESS_KEY_ID") "/" credential-scope)] + ["X-Amz-Date" amz-date] + ["X-Amz-Expires" "300"] + ["X-Amz-SignedHeaders" "host"]] + (sort-by first)) + canonical-query (->> params + (map (fn [[k v]] + (str (encode-rfc3986 k) "=" (encode-rfc3986 v)))) + (string/join "&")) + canonical-uri (str "/" bucket "/" (encode-path r2-key)) + canonical-headers (str "host:" host "\n") + signed-headers "host" + payload-hash "UNSIGNED-PAYLOAD" + canonical-request (string/join "\n" + [method + canonical-uri + canonical-query + canonical-headers + signed-headers + payload-hash]) + canonical-hash (sha256-hex canonical-request) + string-to-sign (string/join "\n" + ["AWS4-HMAC-SHA256" + amz-date + credential-scope + canonical-hash]) + signing-key (get-signature-key (aget env "R2_SECRET_ACCESS_KEY") + date-stamp + region + service) + raw-signature (hmac-sha256 signing-key (.encode text-encoder string-to-sign)) + signature (to-hex raw-signature) + signed-query (str canonical-query "&X-Amz-Signature=" signature)] + (str "https://" host canonical-uri "?" signed-query))) + +(defn decode-jwt-part [part] + (let [data (base64url->uint8array part)] + (js/JSON.parse (.decode text-decoder data)))) + +(defn import-rsa-key [jwk] + (.importKey js/crypto.subtle + "jwk" + jwk + #js {:name "RSASSA-PKCS1-v1_5" :hash "SHA-256"} + false + #js ["verify"])) + +(defn verify-jwt [token env] + (js-await [parts (string/split token #"\.") + _ (when (not= 3 (count parts)) (throw (ex-info "invalid" {}))) + header-part (nth parts 0) + payload-part (nth parts 1) + signature-part (nth parts 2) + header (decode-jwt-part header-part) + payload (decode-jwt-part payload-part) + issuer (aget env "COGNITO_ISSUER") + client-id (aget env "COGNITO_CLIENT_ID") + _ (when (not= (aget payload "iss") issuer) (throw (ex-info "iss not found" {}))) + _ (when (not= (aget payload "aud") client-id) (throw (ex-info "aud not found" {}))) + now (js/Math.floor (/ (.now js/Date) 1000)) + _ (when (and (aget payload "exp") (< (aget payload "exp") now)) + (throw (ex-info "exp" {}))) + jwks-resp (js/fetch (aget env "COGNITO_JWKS_URL")) + _ (when-not (.-ok jwks-resp) (throw (ex-info "jwks" {}))) + jwks (.json jwks-resp) + keys (or (aget jwks "keys") #js []) + key (.find keys (fn [k] (= (aget k "kid") (aget header "kid")))) + _ (when-not key (throw (ex-info "kid" {}))) + crypto-key (import-rsa-key key) + data (.encode text-encoder (str header-part "." payload-part)) + signature (base64url->uint8array signature-part) + ok (.verify js/crypto.subtle + "RSASSA-PKCS1-v1_5" + crypto-key + signature + data)] + (when ok payload))) + +(defn normalize-etag [etag] + (when etag + (string/replace etag #"\"" ""))) + +(defn short-id-for-page [graph-uuid page-uuid] + (js-await [payload (.encode text-encoder (str graph-uuid ":" page-uuid)) + digest (.digest js/crypto.subtle "SHA-256" payload)] + (let [data (js/Uint8Array. digest) + encoded (bytes->base64url data)] + (subs encoded 0 10)))) diff --git a/deps/publish/src/logseq/publish/index.cljs b/deps/publish/src/logseq/publish/index.cljs new file mode 100644 index 0000000000..4f9ed8e80d --- /dev/null +++ b/deps/publish/src/logseq/publish/index.cljs @@ -0,0 +1,94 @@ +(ns logseq.publish.index + (:require [clojure.string :as string] + [logseq.publish.model :as publish-model])) + +(defn page-refs-from-payload [payload page-eid page-uuid page-title graph-uuid] + (let [entities (publish-model/datoms->entities (:datoms payload)) + refs (->> entities + (mapcat (fn [[_e entity]] + (when (and (= (:block/page entity) page-eid) + (not= (:block/uuid entity) page-uuid)) + (let [block-uuid (some-> (:block/uuid entity) str) + block-content (or (:block/content entity) + (:block/title entity) + (:block/name entity) + "") + block-format (name (or (:block/format entity) :markdown)) + refs (:block/refs entity) + refs (if (sequential? refs) refs (when refs [refs])) + targets (->> refs + (map publish-model/ref-eid) + (keep #(get entities %)) + distinct)] + (when (seq targets) + (map (fn [target-entity] + (let [target-uuid (some-> (:block/uuid target-entity) str) + target-title (publish-model/entity->title target-entity) + target-name (or (:block/name target-entity) + target-title) + target-name (when target-name + (string/lower-case (str target-name)))] + {:graph_uuid graph-uuid + :target_page_uuid target-uuid + :target_page_title target-title + :target_page_name target-name + :source_page_uuid (str page-uuid) + :source_page_title page-title + :source_block_uuid block-uuid + :source_block_content block-content + :source_block_format block-format + :updated_at (.now js/Date)})) + targets)))))))] + (vec refs))) + +(defn page-tagged-nodes-from-payload [payload page-eid page-uuid page-title graph-uuid] + (let [entities (publish-model/datoms->entities (:datoms payload)) + normalize-tags (fn [tags] + (let [tags (if (sequential? tags) tags (when tags [tags]))] + (->> tags + (map publish-model/ref-eid) + (keep #(get entities %)) + (keep (fn [entity] + (when-let [uuid (:block/uuid entity)] + {:tag_page_uuid (str uuid) + :tag_title (publish-model/entity->title entity)}))) + distinct))) + page-entity (get entities page-eid) + page-tags (normalize-tags (:block/tags page-entity)) + page-entries (when (seq page-tags) + (map (fn [tag] + {:graph_uuid graph-uuid + :tag_page_uuid (:tag_page_uuid tag) + :tag_title (:tag_title tag) + :source_page_uuid (str page-uuid) + :source_page_title page-title + :source_block_uuid (str page-uuid) + :source_block_content nil + :source_block_format "page" + :updated_at (.now js/Date)}) + page-tags)) + block-entries (mapcat (fn [[_e entity]] + (when (and (= (:block/page entity) page-eid) + (not= (:block/uuid entity) page-uuid) + (not (:logseq.property/created-from-property entity))) + (let [block-uuid (some-> (:block/uuid entity) str) + block-content (or (:block/content entity) + (:block/title entity) + (:block/name entity) + "") + block-format (name (or (:block/format entity) :markdown)) + tags (normalize-tags (:block/tags entity))] + (when (seq tags) + (map (fn [tag] + {:graph_uuid graph-uuid + :tag_page_uuid (:tag_page_uuid tag) + :tag_title (:tag_title tag) + :source_page_uuid (str page-uuid) + :source_page_title page-title + :source_block_uuid block-uuid + :source_block_content block-content + :source_block_format block-format + :updated_at (.now js/Date)}) + tags))))) + entities)] + (vec (distinct (concat page-entries block-entries))))) diff --git a/deps/publish/src/logseq/publish/meta_store.cljs b/deps/publish/src/logseq/publish/meta_store.cljs new file mode 100644 index 0000000000..16b064b27c --- /dev/null +++ b/deps/publish/src/logseq/publish/meta_store.cljs @@ -0,0 +1,457 @@ +(ns logseq.publish.meta-store + (:require [clojure.string :as string] + [logseq.publish.common :as publish-common]) + (:require-macros [logseq.publish.async :refer [js-await]])) + +(defn init-schema! [sql] + (let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(pages);")) + drop? (some #(contains? #{"page_id" "graph"} (aget % "name")) cols)] + (when drop? + (publish-common/sql-exec sql "DROP TABLE IF EXISTS pages;")) + (publish-common/sql-exec sql + (str "CREATE TABLE IF NOT EXISTS pages (" + "page_uuid TEXT NOT NULL," + "page_title TEXT," + "page_tags TEXT," + "graph_uuid TEXT NOT NULL," + "schema_version TEXT," + "block_count INTEGER," + "content_hash TEXT NOT NULL," + "content_length INTEGER," + "r2_key TEXT NOT NULL," + "owner_sub TEXT," + "owner_username TEXT," + "created_at INTEGER," + "updated_at INTEGER," + "password_hash TEXT," + "PRIMARY KEY (graph_uuid, page_uuid)" + ");")) + (let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(pages);")) + col-names (set (map #(aget % "name") cols))] + (when-not (contains? col-names "page_title") + (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN page_title TEXT;")) + (when-not (contains? col-names "page_tags") + (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN page_tags TEXT;")) + (when-not (contains? col-names "short_id") + (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN short_id TEXT;")) + (when-not (contains? col-names "owner_username") + (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN owner_username TEXT;")) + (when-not (contains? col-names "password_hash") + (publish-common/sql-exec sql "ALTER TABLE pages ADD COLUMN password_hash TEXT;"))) + (let [cols (publish-common/get-sql-rows (publish-common/sql-exec sql "PRAGMA table_info(page_refs);")) + col-names (set (map #(aget % "name") cols))] + (when (seq col-names) + (when-not (contains? col-names "target_page_title") + (publish-common/sql-exec sql "ALTER TABLE page_refs ADD COLUMN target_page_title TEXT;")) + (when-not (contains? col-names "target_page_name") + (publish-common/sql-exec sql "ALTER TABLE page_refs ADD COLUMN target_page_name TEXT;")))) + (publish-common/sql-exec sql + (str "CREATE TABLE IF NOT EXISTS page_refs (" + "graph_uuid TEXT NOT NULL," + "target_page_uuid TEXT NOT NULL," + "target_page_title TEXT," + "target_page_name TEXT," + "source_page_uuid TEXT NOT NULL," + "source_page_title TEXT," + "source_block_uuid TEXT," + "source_block_content TEXT," + "source_block_format TEXT," + "updated_at INTEGER," + "PRIMARY KEY (graph_uuid, target_page_uuid, source_block_uuid)" + ");")) + (publish-common/sql-exec sql + (str "CREATE TABLE IF NOT EXISTS page_tags (" + "graph_uuid TEXT NOT NULL," + "tag_page_uuid TEXT NOT NULL," + "tag_title TEXT," + "source_page_uuid TEXT NOT NULL," + "source_page_title TEXT," + "source_block_uuid TEXT NOT NULL," + "source_block_content TEXT," + "source_block_format TEXT," + "updated_at INTEGER," + "PRIMARY KEY (graph_uuid, tag_page_uuid, source_block_uuid)" + ");")) + (publish-common/sql-exec sql + (str "CREATE TABLE IF NOT EXISTS page_blocks (" + "graph_uuid TEXT NOT NULL," + "page_uuid TEXT NOT NULL," + "block_uuid TEXT NOT NULL," + "block_content TEXT," + "updated_at INTEGER," + "PRIMARY KEY (graph_uuid, block_uuid)" + ");")))) + +(defn parse-page-tags [value] + (cond + (nil? value) #js [] + (array? value) value + (string? value) (try + (js/JSON.parse value) + (catch :default _ + #js [])) + :else #js [])) + +(defn row->meta [row] + (let [data (js->clj row :keywordize-keys false) + page-tags (parse-page-tags (get data "page_tags")) + short-id (get data "short_id")] + (assoc data + "graph" (get data "graph_uuid") + "page_tags" page-tags + "short_id" short-id + "short_url" (when short-id (str "/p/" short-id)) + "content_hash" (get data "content_hash") + "content_length" (get data "content_length")))) + +(defn do-fetch [^js self request] + (let [sql (.-sql self)] + (init-schema! sql) + (cond + (= "POST" (.-method request)) + (js-await [body (.json request)] + (let [page-uuid (aget body "page_uuid") + graph-uuid (aget body "graph")] + (if (and (string? page-uuid) (string? graph-uuid)) + (publish-common/sql-exec sql + (str "INSERT INTO pages (" + "page_uuid," + "page_title," + "page_tags," + "graph_uuid," + "schema_version," + "block_count," + "content_hash," + "content_length," + "r2_key," + "owner_sub," + "owner_username," + "created_at," + "updated_at," + "short_id," + "password_hash" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + " ON CONFLICT(graph_uuid, page_uuid) DO UPDATE SET" + " page_uuid=excluded.page_uuid," + " page_title=excluded.page_title," + " page_tags=excluded.page_tags," + " schema_version=excluded.schema_version," + " block_count=excluded.block_count," + " content_hash=excluded.content_hash," + " content_length=excluded.content_length," + " r2_key=excluded.r2_key," + " owner_sub=excluded.owner_sub," + " owner_username=excluded.owner_username," + " updated_at=excluded.updated_at," + " short_id=excluded.short_id," + " password_hash=excluded.password_hash;") + page-uuid + (aget body "page_title") + (aget body "page_tags") + graph-uuid + (aget body "schema_version") + (aget body "block_count") + (aget body "content_hash") + (aget body "content_length") + (aget body "r2_key") + (aget body "owner_sub") + (aget body "owner_username") + (aget body "created_at") + (aget body "updated_at") + (aget body "short_id") + (aget body "password_hash")) + (throw (js/Error. "publish: missing page_uuid or graph"))) + (let [refs (aget body "refs") + tagged-nodes (aget body "tagged_nodes") + blocks (aget body "blocks") + graph-uuid (aget body "graph") + page-uuid (aget body "page_uuid")] + (when (and graph-uuid page-uuid) + (publish-common/sql-exec sql + "DELETE FROM page_refs WHERE graph_uuid = ? AND source_page_uuid = ?;" + graph-uuid + page-uuid) + (publish-common/sql-exec sql + "DELETE FROM page_tags WHERE graph_uuid = ? AND source_page_uuid = ?;" + graph-uuid + page-uuid) + (doseq [ref refs] + (publish-common/sql-exec sql + (str "INSERT OR REPLACE INTO page_refs (" + "graph_uuid, target_page_uuid, target_page_title, target_page_name, source_page_uuid, " + "source_page_title, source_block_uuid, source_block_content, " + "source_block_format, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);") + (aget ref "graph_uuid") + (aget ref "target_page_uuid") + (aget ref "target_page_title") + (aget ref "target_page_name") + (aget ref "source_page_uuid") + (aget ref "source_page_title") + (aget ref "source_block_uuid") + (aget ref "source_block_content") + (aget ref "source_block_format") + (aget ref "updated_at"))) + + (doseq [tag tagged-nodes] + (publish-common/sql-exec sql + (str "INSERT OR REPLACE INTO page_tags (" + "graph_uuid, tag_page_uuid, tag_title, source_page_uuid, " + "source_page_title, source_block_uuid, source_block_content, " + "source_block_format, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);") + (aget tag "graph_uuid") + (aget tag "tag_page_uuid") + (aget tag "tag_title") + (aget tag "source_page_uuid") + (aget tag "source_page_title") + (aget tag "source_block_uuid") + (aget tag "source_block_content") + (aget tag "source_block_format") + (aget tag "updated_at")))) + (publish-common/sql-exec sql + "DELETE FROM page_blocks WHERE graph_uuid = ? AND page_uuid = ?;" + graph-uuid + page-uuid) + (doseq [block blocks] + (publish-common/sql-exec sql + (str "INSERT OR REPLACE INTO page_blocks (" + "graph_uuid, page_uuid, block_uuid, block_content, updated_at" + ") VALUES (?, ?, ?, ?, ?);") + (aget body "graph") + (aget block "page_uuid") + (aget block "block_uuid") + (aget block "block_content") + (aget block "updated_at")))) + (publish-common/json-response {:ok true}))) + + (= "GET" (.-method request)) + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (cond + (= (nth parts 1 nil) "search") + (let [graph-uuid (nth parts 2 nil) + query (.get (.-searchParams url) "q") + query (some-> query string/trim) + query (when (and query (not (string/blank? query))) + (string/lower-case query))] + (if (or (string/blank? graph-uuid) (string/blank? query)) + (publish-common/bad-request "missing graph uuid or query") + (let [like-query (str "%" query "%") + pages (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, page_title, short_id " + "FROM pages " + "WHERE graph_uuid = ? " + "AND password_hash IS NULL " + "AND page_title IS NOT NULL " + "AND lower(page_title) LIKE ? " + "ORDER BY updated_at DESC " + "LIMIT 20;") + graph-uuid + like-query)) + blocks (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_blocks.page_uuid, page_blocks.block_uuid, " + "page_blocks.block_content, pages.page_title, pages.short_id " + "FROM page_blocks " + "LEFT JOIN pages " + "ON pages.graph_uuid = page_blocks.graph_uuid " + "AND pages.page_uuid = page_blocks.page_uuid " + "WHERE page_blocks.graph_uuid = ? " + "AND pages.password_hash IS NULL " + "AND page_blocks.block_content IS NOT NULL " + "AND lower(page_blocks.block_content) LIKE ? " + "ORDER BY page_blocks.updated_at DESC " + "LIMIT 50;") + graph-uuid + like-query))] + (publish-common/json-response {:pages pages :blocks blocks})))) + + (= (nth parts 1 nil) "tag") + (let [tag-name (when-let [raw (nth parts 2 nil)] + (js/decodeURIComponent raw)) + tagged-rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (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, " + "pages.short_id " + "FROM page_tags " + "LEFT JOIN pages " + "ON pages.graph_uuid = page_tags.graph_uuid " + "AND pages.page_uuid = page_tags.source_page_uuid " + "WHERE page_tags.tag_title = ? " + "ORDER BY page_tags.updated_at DESC;") + tag-name)) + page-rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_tags.graph_uuid, page_tags.source_page_uuid, page_tags.source_page_title, " + "pages.short_id, " + "MAX(page_tags.updated_at) AS updated_at " + "FROM page_tags " + "LEFT JOIN pages " + "ON pages.graph_uuid = page_tags.graph_uuid " + "AND pages.page_uuid = page_tags.source_page_uuid " + "WHERE page_tags.tag_title = ? " + "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))] + (publish-common/json-response {:pages (map (fn [row] + (js->clj row :keywordize-keys false)) + page-rows) + :tagged_nodes (map (fn [row] + (js->clj row :keywordize-keys false)) + tagged-rows)})) + + (= (nth parts 1 nil) "ref") + (let [ref-name (when-let [raw (nth parts 2 nil)] + (js/decodeURIComponent raw)) + rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_refs.graph_uuid, page_refs.source_page_uuid, page_refs.source_page_title, " + "pages.short_id, " + "MAX(page_refs.updated_at) AS updated_at " + "FROM page_refs " + "LEFT JOIN pages " + "ON pages.graph_uuid = page_refs.graph_uuid " + "AND pages.page_uuid = page_refs.source_page_uuid " + "WHERE (lower(page_refs.target_page_title) = lower(?)) " + "OR (page_refs.target_page_name = lower(?)) " + "GROUP BY page_refs.graph_uuid, page_refs.source_page_uuid, page_refs.source_page_title, pages.short_id " + "ORDER BY updated_at DESC;") + ref-name + ref-name))] + (publish-common/json-response {:pages (map (fn [row] + (js->clj row :keywordize-keys false)) + rows)})) + + (= (nth parts 1 nil) "short") + (let [short-id (nth parts 2 nil) + rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, graph_uuid, page_title, short_id " + "FROM pages WHERE short_id = ? LIMIT 1;") + short-id)) + row (first rows)] + (publish-common/json-response {:page (when row (js->clj row :keywordize-keys false))})) + + (= (nth parts 1 nil) "user") + (let [raw-username (nth parts 2 nil) + username (when raw-username (js/decodeURIComponent raw-username)) + rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, page_title, short_id, graph_uuid, updated_at, owner_username " + "FROM pages WHERE owner_username = ? ORDER BY updated_at DESC;") + username))] + (publish-common/json-response {:user {:username username} + :pages (map (fn [row] + (js->clj row :keywordize-keys false)) + rows)})) + + (= (nth parts 4 nil) "password") + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT password_hash " + "FROM pages WHERE graph_uuid = ? AND page_uuid = ? LIMIT 1;") + graph-uuid + page-uuid)) + row (first rows)] + (if-not row + (publish-common/not-found) + (publish-common/json-response {:password_hash (aget row "password_hash")}))) + + (= (nth parts 4 nil) "refs") + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT graph_uuid, target_page_uuid, source_page_uuid, " + "target_page_title, target_page_name, source_page_title, source_block_uuid, source_block_content, " + "source_block_format, updated_at " + "FROM page_refs WHERE graph_uuid = ? AND target_page_uuid = ? " + "ORDER BY updated_at DESC;") + graph-uuid + page-uuid))] + (publish-common/json-response {:refs (map (fn [row] + (js->clj row :keywordize-keys false)) + rows)})) + + (= (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;") + graph-uuid + page-uuid))] + (publish-common/json-response {:tagged_nodes (map (fn [row] + (js->clj row :keywordize-keys false)) + rows)})) + + (and graph-uuid page-uuid) + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, page_title, page_tags, short_id, graph_uuid, schema_version, block_count, " + "content_hash, content_length, r2_key, owner_sub, owner_username, created_at, updated_at " + "FROM pages WHERE graph_uuid = ? AND page_uuid = ? LIMIT 1;") + graph-uuid + page-uuid)) + row (first rows)] + (if-not row + (publish-common/not-found) + (publish-common/json-response (row->meta row)))) + + graph-uuid + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, page_title, page_tags, short_id, graph_uuid, schema_version, block_count, " + "content_hash, content_length, r2_key, owner_sub, owner_username, created_at, updated_at " + "FROM pages WHERE graph_uuid = ? ORDER BY updated_at DESC;") + graph-uuid))] + (publish-common/json-response {:pages (map row->meta rows)})) + + :else + (let [rows (publish-common/get-sql-rows + (publish-common/sql-exec sql + (str "SELECT page_uuid, page_title, page_tags, short_id, graph_uuid, schema_version, block_count, " + "content_hash, content_length, r2_key, owner_sub, owner_username, created_at, updated_at " + "FROM pages ORDER BY updated_at DESC;")))] + (publish-common/json-response {:pages (map row->meta rows)})))) + + (= "DELETE" (.-method request)) + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (cond + (and graph-uuid page-uuid) + (do + (publish-common/sql-exec sql + "DELETE FROM pages WHERE graph_uuid = ? AND page_uuid = ?;" + graph-uuid + page-uuid) + (publish-common/sql-exec sql + "DELETE FROM page_refs WHERE graph_uuid = ? AND source_page_uuid = ?;" + graph-uuid + page-uuid) + (publish-common/sql-exec sql + "DELETE FROM page_tags WHERE graph_uuid = ? AND source_page_uuid = ?;" + graph-uuid + page-uuid) + (publish-common/json-response {:ok true})) + + graph-uuid + (do + (publish-common/sql-exec sql "DELETE FROM pages WHERE graph_uuid = ?;" graph-uuid) + (publish-common/sql-exec sql "DELETE FROM page_refs WHERE graph_uuid = ?;" graph-uuid) + (publish-common/sql-exec sql "DELETE FROM page_tags WHERE graph_uuid = ?;" graph-uuid) + (publish-common/json-response {:ok true})) + + :else + (publish-common/bad-request "missing graph uuid or page uuid"))) + + :else + (publish-common/json-response {:error "method not allowed"} 405)))) diff --git a/deps/publish/src/logseq/publish/model.cljs b/deps/publish/src/logseq/publish/model.cljs new file mode 100644 index 0000000000..988fba3620 --- /dev/null +++ b/deps/publish/src/logseq/publish/model.cljs @@ -0,0 +1,41 @@ +(ns logseq.publish.model) + +(defn merge-attr + [entity attr value] + (let [existing (get entity attr ::none)] + (cond + (= existing ::none) (assoc entity attr value) + (vector? existing) (assoc entity attr (conj existing value)) + (set? existing) (assoc entity attr (conj existing value)) + :else (assoc entity attr [existing value])))) + +(defn datoms->entities + [datoms] + (reduce + (fn [acc datom] + (let [[e a v _tx added?] datom] + (if added? + (update acc e (fn [entity] + (merge-attr (or entity {:db/id e}) a v))) + acc))) + {} + datoms)) + +(defn entity->title + [entity] + (or (:block/title entity) + (:block/name entity) + (str (:logseq.property/value entity)) + "Untitled")) + +(defn page-entity? + [entity] + (and (nil? (:block/page entity)) + (or (:block/name entity) + (:block/title entity)))) + +(defn ref-eid [value] + (cond + (number? value) value + (map? value) (:db/id value) + :else nil)) diff --git a/deps/publish/src/logseq/publish/publish.css b/deps/publish/src/logseq/publish/publish.css new file mode 100644 index 0000000000..9ff804c84d --- /dev/null +++ b/deps/publish/src/logseq/publish/publish.css @@ -0,0 +1,945 @@ +@import url("https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css"); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap"); + +:root { + color-scheme: light; + --bg: #fffcf0; + --bg-accent: #f2f0e5; + --surface: #fffcf0; + --surface-strong: #f2f0e5; + --ink: #282726; + --muted: #6f6e69; + --border: #cecdc3; + --link: #282726; + --action: #24837B; + --shadow: 0 18px 40px rgba(40, 39, 38, 0.1); + --bg-gradient-1: rgba(218, 112, 44, 0.12); + --bg-gradient-2: rgba(67, 133, 190, 0.1); + --button-bg: #f2e9d6; + --button-bg-hover: #efe0c2; + --code-bg: #1f2933; + --code-ink: #f8f4ec; + --code-muted: #b59d82; + --math-bg: #f6ede2; + --quote-border: #282726; + --card-bg: #fff7ee; + --input-bg: #fffcf0; + --image-shadow: 0 12px 24px rgba(40, 39, 38, 0.08); + --icon-day: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor' %3E%3Cpath fill-rule='evenodd' d='M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z' clip-rule='evenodd' /%3E%3C/svg%3E"); + --icon-night: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24' %3E%3Cpath d='M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z' /%3E%3C/svg%3E"); +} + +[data-theme="dark"] { + color-scheme: dark; + --bg: #100f0f; + --bg-accent: #1c1b1a; + --surface: #1c1b1a; + --surface-strong: #282726; + --ink: #e6e4d9; + --muted: #b7b5ac; + --border: #403e3c; + --link: #e6e4d9; + --action: #3AA99F; + --shadow: 0 18px 40px rgba(0, 0, 0, 0.45); + --bg-gradient-1: rgba(218, 112, 44, 0.18); + --bg-gradient-2: rgba(67, 133, 190, 0.14); + --button-bg: #2f2c2b; + --button-bg-hover: #3a3635; + --code-bg: #1a1f24; + --code-ink: #f8f4ec; + --code-muted: #a3a091; + --math-bg: #242220; + --quote-border: #e6e4d9; + --card-bg: #1f1d1c; + --input-bg: #1c1b1a; + --image-shadow: 0 10px 24px rgba(0, 0, 0, 0.35); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(1200px 600px at 10% -10%, var(--bg-gradient-1), transparent 60%), + radial-gradient(900px 400px at 90% 0%, var(--bg-gradient-2), transparent 60%), + linear-gradient(180deg, var(--bg) 0%, var(--bg-accent) 100%); + color: var(--ink); + font-family: "Inter", "Segoe UI", sans-serif; + line-height: 1.65; + letter-spacing: 0.01em; +} + +.publish-home { + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + overflow: hidden; +} + +.publish-home-bg { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; +} + +.publish-home-card { + position: relative; + z-index: 1; + max-width: 520px; + padding: 32px 28px 28px; + border-radius: 20px; + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow); + text-align: center; +} + +.publish-home-logo { + font-size: 12px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 18px; +} + +.publish-home-title { + margin: 0 0 12px; + font-size: clamp(26px, 3.6vw, 36px); + font-weight: 600; + letter-spacing: -0.02em; + color: var(--ink); +} + +.publish-home-subtitle { + margin: 0; + font-size: 14px; + color: var(--muted); + line-height: 1.6; +} + +.publish-home-subtitle code { + font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + background: var(--bg-accent); + padding: 1px 4px; + border-radius: 4px; +} + +@media (max-width: 600px) { + .publish-home-card { + margin: 0 16px; + padding: 24px 20px 22px; + } +} + +.wrap { + max-width: 920px; + margin: 32px auto 56px; + padding: 32px 28px 40px; + overflow-x: hidden; +} + +h1 { + font-size: clamp(30px, 3.4vw, 44px); + margin-top: 1.5em; + margin-bottom: 0.25em; + font-weight: 600; + letter-spacing: 0.01em; +} + +h2 { + font-weight: 500; + letter-spacing: 0.01em; +} + +.block-heading { + margin: 0; + font-weight: 600; + line-height: 1.4; +} + +h1.block-heading { + font-size: 1.6em; +} + +h2.block-heading { + font-size: 1.4em; +} + +h3.block-heading { + font-size: 1.25em; +} + +h4.block-heading { + font-size: 1.1em; +} + +h5.block-heading, +h6.block-heading { + font-size: 1em; +} + +a { + color: var(--link); + text-decoration: underline; +} + +a:hover { + color: var(--action); +} + +.page-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + padding: 12px 0; + margin: -8px 0 24px; +} + +.page-toolbar .toolbar-btn:first-child { + margin-right: auto; +} + +.publish-search { + position: relative; + display: flex; + align-items: center; + gap: 6px; + min-width: 240px; + flex-direction: row-reverse; + height: 32px; +} + +.publish-search-toggle { + border: none; + background: var(--button-bg); + color: var(--ink); + width: 32px; + height: 32px; + padding: 0; + border-radius: 999px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.publish-search-toggle:hover { + background: var(--button-bg-hover); + box-shadow: 0 6px 14px rgba(40, 39, 38, 0.12); + transform: translateY(-1px); +} + +.publish-search-toggle .ti { + font-size: 16px; + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.publish-search-input { + width: 0; + opacity: 0; + padding: 0 12px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--ink); + font-size: 13px; + border-radius: 999px; + pointer-events: none; + transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease; +} + +.publish-search.is-expanded .publish-search-input { + width: min(320px, 70vw); + padding: 8px 12px; + opacity: 1; + pointer-events: auto; +} + +.publish-search-input:focus { + outline: none; +} + +.publish-search-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.publish-search-results { + opacity: 0; + transform: translateY(-6px); + position: absolute; + right: 0; + top: calc(100% + 24px); + z-index: 10; + width: min(480px, 90vw); + max-height: 320px; + overflow: auto; + border-radius: 16px; + background: var(--surface-strong); + box-shadow: var(--shadow); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.publish-search.is-expanded .publish-search-results:not([hidden]) { + opacity: 1; + transform: translateY(0); +} + +.publish-search-hint { + position: absolute; + right: 40px; + top: calc(100% + 6px); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.publish-search.is-expanded .publish-search-hint { + opacity: 1; +} +.publish-search-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.publish-search-section { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--muted); + margin: 6px 6px 2px; +} + +.publish-search-result { + background: transparent; + text-align: left; + display: grid; + padding: 4px 12px; + cursor: pointer; + color: var(--ink); + text-decoration: none; +} + +.publish-search-result:hover { + background: var(--surface); +} + +.publish-search-result:first-child { + padding-top: 12px; +} + +.publish-search-result:last-child { + padding-bottom: 12px; +} + +.publish-search-result.is-active { + background: var(--surface); +} + +.publish-search-kind { + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--muted); +} + +.publish-search-title { + font-size: 13px; + font-weight: 600; +} + +.publish-search-snippet { + font-size: 12px; + color: var(--muted); +} + +.publish-search-empty { + font-size: 12px; + color: var(--muted); + padding: 8px; +} + +.toolbar-btn { + background: transparent; + border: none; + color: var(--ink); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.08em; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.theme-toggle { + position: relative; + width: 62px; + height: 30px; + padding: 0 8px; + background: var(--button-bg); + border: none; + border-radius: 999px; + color: var(--ink); + display: inline-flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + transition: background 0.2s ease, box-shadow 0.2s ease; +} + +.theme-toggle:hover { + background: var(--button-bg-hover); + box-shadow: 0 6px 16px rgba(40, 39, 38, 0.12); +} + +.theme-toggle__thumb { + position: absolute; + top: 3px; + left: 3px; + width: 24px; + height: 24px; + border-radius: 999px; + background: var(--surface); + box-shadow: 0 4px 10px rgba(40, 39, 38, 0.12); + transition: transform 0.2s ease; +} + +.theme-toggle.is-dark .theme-toggle__thumb { + transform: translateX(32px); +} + +.theme-toggle__icon { + width: 14px; + height: 14px; + background-color: currentColor; + opacity: 0.6; + transition: opacity 0.2s ease; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; +} + +.theme-toggle__icon--day { + mask-image: var(--icon-day); + -webkit-mask-image: var(--icon-day); +} + +.theme-toggle__icon--night { + mask-image: var(--icon-night); + -webkit-mask-image: var(--icon-night); +} + +.theme-toggle.is-dark .theme-toggle__icon--night, +.theme-toggle:not(.is-dark) .theme-toggle__icon--day { + opacity: 1; +} + +.blocks { + margin: 0; + padding-left: 0; + list-style: none; +} + +.block-children .blocks { + list-style: initial; + padding-left: 18px; +} + +.block { + margin: 8px 0; +} + +.block-content { + white-space: pre-wrap; + display: flex; + gap: 4px; + align-items: flex-start; +} + +.positioned-properties { + display: inline-flex; + align-items: center; + gap: 2px; + flex-wrap: wrap; +} + +.positioned-properties.block-left, .positioned-properties.block-right { + display: flex; + align-items: center; + margin-top: 2px; +} + +.positioned-properties.block-right { + margin-left: auto; +} + +.positioned-properties.block-below { + margin: 6px 0 0 22px; + gap: 8px 12px; +} + +.positioned-property { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.positioned-property .property-name { + color: var(--muted); + font-weight: 500; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.positioned-property .property-value { + color: var(--ink); +} + +.property-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + line-height: 1; + font-size: 1rem; + color: currentColor; +} + +.property-icon svg { + width: 1rem; + height: 1rem; +} + +.property-value-with-icon { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.block-text { + flex: 1; + font-size: 16px; +} + +.macro-embed { + width: min(100%, 560px); + aspect-ratio: 16 / 9; + margin: 10px 0; + border-radius: 14px; + overflow: hidden; + background: var(--surface-strong); + box-shadow: var(--image-shadow); +} + +.macro-embed iframe { + width: 100%; + height: 100%; + border: 0; +} + +.macro-embed--tweet { + aspect-ratio: 4 / 5; +} + +.cloze { + padding: 0 6px; + border-radius: 6px; + background: var(--surface-strong); + box-shadow: inset 0 0 0 1px rgba(40, 39, 38, 0.12); +} + +.code-block { + flex: 1; + position: relative; + margin: 1.5em 0; + border-radius: 14px; + overflow: hidden; + /* background: var(--code-bg); */ +} + +.code-block[data-lang]:before { + content: attr(data-lang); + position: absolute; + top: 10px; + right: 12px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--code-muted); + z-index: 2; +} + +.code-block .cm-editor { + height: auto; + /* background: var(--code-bg); */ + /* color: var(--code-ink); */ + font-size: 13px; + line-height: 1.6; +} + +.code-block .cm-scroller { + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; +} + +.ͼ2 .cm-gutters.cm-gutters-before { + border-right-width: 0; +} + +.math-block { + flex: 1; + padding: 12px 14px; +} + +.quote-block, blockquote { + padding-left: 1.5em; + margin: 1.5em 0; + border-left: 2px solid var(--quote-border); +} + +.asset-image { + max-width: 100%; + border-radius: 14px; + box-shadow: var(--image-shadow); +} + +.asset-video, +.asset-audio { + width: 100%; +} + +.asset-link { + color: var(--ink); + font-weight: 500; +} + +.page-properties { + margin: 0 0 28px; + padding: 16px 18px; + border-radius: 16px; + background: var(--surface-strong); +} + +.properties { + margin: 0; + display: grid; + grid-template-columns: 160px 1fr; + gap: 6px 16px; +} + +.property { + display: contents; +} + +.property-name { + margin: 0; + color: var(--muted); + font-weight: 500; + font-size: 13px; + letter-spacing: 0.08em; +} + +.property-value { + margin: 0; + color: var(--ink); +} + +.block-properties { + margin: 8px 0 0 22px; +} + +.block-properties .properties { + grid-template-columns: 120px 1fr; + font-size: 13px; +} + +.block-toggle { + border: none; + background: transparent; + cursor: pointer; + font-size: 14px; + line-height: 1; + margin-top: 4px; + margin-left: auto; + color: var(--muted); +} + +.block.is-collapsed > .block-content > .block-toggle { + transform: rotate(-90deg); +} + +.block-children { + margin-left: 16px; +} + +.block.is-collapsed > .block-children { + display: none; +} + +.linked-refs, +.tagged-pages { + margin-top: 36px; +} + +.linked-refs h2, +.tagged-pages h2 { + font-size: 14px; + margin: 64px 0 16px 0; + color: var(--muted); +} + +.ref-page { + margin: 0 0 16px; +} + +.ref-blocks { + margin: 8px 0 0 18px; + padding: 0; + list-style: disc; +} + +.ref-block { + margin: 6px 0; +} + +.tagged-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.tagged-item { + padding: 12px 14px; + border-radius: 14px; + background: var(--surface-strong); + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.tagged-main { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.tagged-block { + font-size: 13px; + color: var(--ink); + white-space: pre-wrap; +} + +.tagged-sub, +.tagged-meta { + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} + +.graph-meta, +.tag-sub { + color: var(--muted); + font-size: 13px; + margin: 0 0 20px; + letter-spacing: 0.12em; +} + +.password-card { + margin: 20px auto 0; + max-width: 460px; + padding: 24px; + border-radius: 18px; + background: var(--card-bg); + box-shadow: 0 12px 24px rgba(40, 39, 38, 0.12); + text-align: center; +} + +.password-form { + margin-top: 18px; + display: grid; + gap: 12px; +} + +.password-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--muted); +} + +.password-input { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: none; + background: var(--input-bg); + box-shadow: inset 0 0 0 1px rgba(40, 39, 38, 0.1); + font-size: 15px; + font-family: inherit; +} + +.password-input:focus { + outline: 2px solid var(--action); +} + +.password-error { + margin: 8px 0 0; + color: #b42318; + font-size: 13px; + font-weight: 600; +} + +.not-found { + text-align: center; + padding: 32px 16px 8px; +} + +.not-found-eyebrow { + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 14px; + letter-spacing: 0.4em; + text-transform: uppercase; + color: var(--ink); + margin: 0 0 12px; +} + +.page-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.page-item { + padding: 14px 16px; + border-radius: 16px; + background: var(--surface-strong); + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.page-item:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(40, 39, 38, 0.12); +} + +.page-links { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.short-link { + color: var(--muted); + font-size: 12px; + letter-spacing: 0.04em; +} + +.page-link, +.page-ref { + color: var(--link); + overflow-wrap: anywhere; +} + +.page-updated-at { + color: var(--muted); + white-space: nowrap; + margin-left: 2px; +} + +.page-authors { + color: var(--muted); + white-space: nowrap; + margin-left: 2px; +} + +.page-meta { + display: flex; + margin-bottom: 2rem; + gap: 8px; + font-size: 13px; +} + +@media (max-width: 720px) { + .wrap { + margin: 16px; + padding: 4px; + } + + .page-toolbar { + gap: 10px; + justify-content: flex-end; + } + + .publish-search { + min-width: 100%; + } + + .publish-search-input { + width: 0; + } + + .publish-search.is-expanded .publish-search-input { + width: 100%; + } + + .block-children { + margin-left: 12px; + } + + .block-children .blocks { + padding-left: 12px; + } + + .properties { + grid-template-columns: 1fr; + } + + .block-properties .properties { + grid-template-columns: 1fr; + } + + .page-item, + .tagged-item { + flex-direction: column; + align-items: flex-start; + } + + .page-updated-at, + .page-authors, + .tagged-meta { + white-space: normal; + } +} diff --git a/deps/publish/src/logseq/publish/publish.js b/deps/publish/src/logseq/publish/publish.js new file mode 100644 index 0000000000..9a3da60939 --- /dev/null +++ b/deps/publish/src/logseq/publish/publish.js @@ -0,0 +1,712 @@ +import katexPkg from "https://esm.sh/katex@0.16.10?bundle"; + +// Core CodeMirror pieces +import { EditorState } from "https://esm.sh/@codemirror/state@6"; +import { + EditorView, + lineNumbers, +} from "https://esm.sh/@codemirror/view@6"; + +// Highlighting +import { + syntaxHighlighting, + defaultHighlightStyle, +} from "https://esm.sh/@codemirror/language@6"; + +// Languages +import { javascript } from "https://esm.sh/@codemirror/lang-javascript@6"; +import { python } from "https://esm.sh/@codemirror/lang-python@6"; +import { html } from "https://esm.sh/@codemirror/lang-html@6"; +import { json } from "https://esm.sh/@codemirror/lang-json@6"; +import { markdown } from "https://esm.sh/@codemirror/lang-markdown@6"; +import { sql } from "https://esm.sh/@codemirror/lang-sql@6"; +import { css } from "https://esm.sh/@codemirror/lang-css@6"; +import { clojure } from "https://esm.sh/@nextjournal/lang-clojure"; +import emojiData from "https://esm.sh/@emoji-mart/data@1?bundle"; + +const katex = katexPkg.default || katexPkg; +const THEME_KEY = "publish-theme"; + +document.addEventListener("click", (event) => { + const btn = event.target.closest(".block-toggle"); + if (!btn) return; + const li = btn.closest("li.block"); + if (!li) return; + const collapsed = li.classList.toggle("is-collapsed"); + btn.setAttribute("aria-expanded", String(!collapsed)); +}); + +const getEmojiNative = (id) => { + const emoji = emojiData?.emojis?.[id]; + if (!emoji) return null; + return emoji?.skins?.[0]?.native || null; +}; + +const toKebabCase = (value) => + (value || "") + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/([a-zA-Z])([0-9])/g, "$1-$2") + .replace(/([0-9])([a-zA-Z])/g, "$1-$2") + .replace(/[_\s]+/g, "-") + .replace(/-+/g, "-") + .toLowerCase(); + +const toPascalCase = (value) => + (value || "") + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + +const toTablerIconName = (id) => { + if (!id) return null; + return id.startsWith("Icon") ? id : `Icon${toPascalCase(id)}`; +}; + +const svgNamespace = "http://www.w3.org/2000/svg"; + +const isReactElement = (node) => + node && + typeof node === "object" && + node.$$typeof && + node.type && + node.props; + +const setDomAttribute = (el, key, val, isSvg) => { + if (key === "className") { + el.setAttribute("class", val); + return; + } + if (key === "style" && val && typeof val === "object") { + Object.entries(val).forEach(([styleKey, styleVal]) => { + el.style[styleKey] = styleVal; + }); + return; + } + if (key === "ref" || key === "key" || key === "children") return; + if (val === true) { + el.setAttribute(key, ""); + return; + } + if (val === false || val == null) return; + + let attr = key; + if (isSvg) { + if (key === "strokeWidth") attr = "stroke-width"; + else if (key === "strokeLinecap") attr = "stroke-linecap"; + else if (key === "strokeLinejoin") attr = "stroke-linejoin"; + else if (key !== "viewBox" && /[A-Z]/.test(key)) { + attr = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + } + } + el.setAttribute(attr, val); +}; + +const reactNodeToDom = (node, parentIsSvg = false) => { + if (node == null || node === false) return null; + if (Array.isArray(node)) { + const frag = document.createDocumentFragment(); + node.forEach((child) => { + const childNode = reactNodeToDom(child, parentIsSvg); + if (childNode) frag.appendChild(childNode); + }); + return frag; + } + if (typeof node === "string" || typeof node === "number") { + return document.createTextNode(String(node)); + } + if (node.nodeType) return node; + if (isReactElement(node)) { + if (node.type === Symbol.for("react.fragment")) { + return reactNodeToDom(node.props?.children, parentIsSvg); + } + if (typeof node.type === "function") { + return reactNodeToDom(node.type(node.props), parentIsSvg); + } + const tag = node.type; + const isSvg = parentIsSvg || tag === "svg"; + const el = isSvg + ? document.createElementNS(svgNamespace, tag) + : document.createElement(tag); + const props = node.props || {}; + Object.entries(props).forEach(([key, val]) => { + setDomAttribute(el, key, val, isSvg); + }); + const children = props.children; + if (children != null) { + const childNode = reactNodeToDom(children, isSvg); + if (childNode) el.appendChild(childNode); + } + return el; + } + return null; +}; + +const getTablerExtIcon = (id) => { + const name = toTablerIconName(id); + if (!name) return null; + return window.tablerIcons?.[name] || null; +}; + +const renderTablerExtIcon = (el, id) => { + const iconFn = getTablerExtIcon(id); + if (!iconFn) return false; + const node = iconFn({ size: 14, stroke: 2 }); + if (!node) return false; + el.textContent = ""; + const domNode = reactNodeToDom(node); + if (!domNode) return false; + if (domNode.nodeType === 11) { + el.appendChild(domNode); + return true; + } + if (domNode.nodeType) { + if (domNode.tagName === "svg") { + domNode.setAttribute("aria-hidden", "true"); + } + el.appendChild(domNode); + return true; + } + return false; +}; + +const renderPropertyIcons = () => { + const icons = Array.from( + document.querySelectorAll(".property-icon[data-icon-type][data-icon-id]") + ); + if (!icons.length) return; + + icons.forEach((el) => { + const id = el.dataset.iconId; + const type = el.dataset.iconType; + if (!id) return; + + if (type === "emoji") { + const native = getEmojiNative(id); + el.textContent = native || id; + return; + } + + el.textContent = ""; + el.setAttribute("aria-hidden", "true"); + + if (type === "tabler-ext-icon") { + if (renderTablerExtIcon(el, id)) return; + const slug = toKebabCase(id); + el.classList.add("tie", `tie-${slug}`); + return; + } + + if (type === "tabler-icon") { + if (renderTablerExtIcon(el, id)) return; + const slug = toKebabCase(id); + el.classList.add("ti", `ti-${slug}`); + return; + } + + el.textContent = id; + }); +}; + +let sequenceKey = null; +let sequenceTimer = null; +const SEQUENCE_TIMEOUT_MS = 900; + +const resetSequence = () => { + sequenceKey = null; + if (sequenceTimer) { + clearTimeout(sequenceTimer); + sequenceTimer = null; + } +}; + +const isTypingTarget = (target) => { + if (!target) return false; + const tag = target.tagName; + return ( + tag === "INPUT" || + tag === "TEXTAREA" || + target.isContentEditable + ); +}; + +document.addEventListener("keydown", (event) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (isTypingTarget(event.target)) return; + + const key = (event.key || "").toLowerCase(); + if (!key) return; + + if (sequenceKey === "t" && key === "o") { + resetSequence(); + window.toggleTopBlocks(); + event.preventDefault(); + return; + } + + if (sequenceKey === "t" && key === "t") { + resetSequence(); + window.toggleTheme(); + event.preventDefault(); + return; + } + + if (key === "t") { + sequenceKey = "t"; + if (sequenceTimer) clearTimeout(sequenceTimer); + sequenceTimer = setTimeout(resetSequence, SEQUENCE_TIMEOUT_MS); + return; + } + + resetSequence(); +}); + +document.addEventListener("click", (event) => { + const toggle = event.target.closest(".theme-toggle"); + if (!toggle) return; + event.preventDefault(); + window.toggleTheme(); +}); + +const searchStateMap = new WeakMap(); + +const getSearchContainerState = () => { + const container = + document.querySelector(".publish-search.is-expanded") || + document.querySelector(".publish-search"); + if (!container) return null; + return searchStateMap.get(container) || null; +}; + +document.addEventListener("keydown", (event) => { + const isMod = event.metaKey || event.ctrlKey; + if (!isMod) return; + + const key = (event.key || "").toLowerCase(); + if (!key) return; + + const typingTarget = isTypingTarget(event.target); + if ( + typingTarget && + !event.target.classList?.contains("publish-search-input") + ) { + return; + } + + const state = getSearchContainerState(); + if (!state) return; + + if (key === "k") { + event.preventDefault(); + state.setExpanded(true); + state.focusInput(); + return; + } +}); + +window.toggleTopBlocks = (btn) => { + const list = document.querySelector(".blocks"); + if (!list) return; + const collapsed = list.classList.toggle("collapsed-all"); + list.querySelectorAll(":scope > .block").forEach((el) => { + if (collapsed) { + el.classList.add("is-collapsed"); + } else { + el.classList.remove("is-collapsed"); + } + }); + if (btn) { + btn.textContent = collapsed ? "Expand all" : "Collapse all"; + } +}; + +const applyTheme = (theme) => { + document.documentElement.setAttribute("data-theme", theme); + document.querySelectorAll(".theme-toggle").forEach((toggle) => { + toggle.classList.toggle("is-dark", theme === "dark"); + toggle.setAttribute("aria-checked", String(theme === "dark")); + }); +}; + +const preferredTheme = () => { + const stored = window.localStorage.getItem(THEME_KEY); + if (stored) return stored; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +}; + +window.toggleTheme = () => { + const current = document.documentElement.getAttribute("data-theme") || "light"; + const next = current === "dark" ? "light" : "dark"; + applyTheme(next); + window.localStorage.setItem(THEME_KEY, next); +}; + +const initTwitterEmbeds = () => { + const tweetTargets = document.querySelectorAll(".twitter-tweet"); + if (!tweetTargets.length) return; + + const ensureTwitterScript = () => + new Promise((resolve) => { + if (window.twttr?.widgets?.createTweet) { + return resolve(window.twttr); + } + + let script = document.querySelector("script[data-twitter-widget]"); + if (!script) { + script = document.createElement("script"); + script.src = "https://platform.twitter.com/widgets.js"; + script.async = true; + script.defer = true; + script.setAttribute("data-twitter-widget", "true"); + document.body.appendChild(script); + } + + script.addEventListener("load", () => { + resolve(window.twttr); + }); + }); + + ensureTwitterScript().then((twttr) => { + if (!twttr?.widgets?.createTweet) return; + + tweetTargets.forEach((el) => { + const a = el.querySelector("a[href*='/status/']"); + if (!a) return; + const m = a.href.match(/status\/(\d+)/); + if (!m) return; + const tweetId = m[1]; + + // Clear fallback text + el.innerHTML = ""; + + // Optional: theme based on your current theme + const theme = + (document.documentElement.getAttribute("data-theme") || "light") === + "dark" + ? "dark" + : "light"; + + twttr.widgets.createTweet(tweetId, el, { theme }); + }); + }); +}; + +const buildSnippet = (text, query) => { + const haystack = text.toLowerCase(); + const needle = query.toLowerCase(); + const idx = haystack.indexOf(needle); + if (idx < 0) return text.slice(0, 160); + const start = Math.max(0, idx - 48); + const end = Math.min(text.length, idx + needle.length + 48); + return text.slice(start, end).replace(/\s+/g, " ").trim(); +}; + +const initSearch = () => { + const containers = Array.from( + document.querySelectorAll(".publish-search") + ); + if (!containers.length) return; + + containers.forEach((container) => { + const graphUuid = container.dataset.graphUuid; + const input = container.querySelector(".publish-search-input"); + const toggleBtn = container.querySelector(".publish-search-toggle"); + const toggleIcon = container.querySelector(".publish-search-toggle .ti"); + const resultsEl = container.querySelector(".publish-search-results"); + if (!input || !resultsEl || !toggleBtn) return; + + let debounceTimer = null; + let activeController = null; + let activeIndex = -1; + let activeItems = []; + + const hideResults = () => { + resultsEl.hidden = true; + resultsEl.innerHTML = ""; + activeIndex = -1; + activeItems = []; + }; + + const renderResults = (query, data) => { + const pages = data?.pages || []; + const blocks = data?.blocks || []; + + if (!pages.length && !blocks.length) { + resultsEl.innerHTML = ""; + const empty = document.createElement("div"); + empty.className = "publish-search-empty"; + empty.textContent = `No results for "${query}".`; + resultsEl.appendChild(empty); + resultsEl.hidden = false; + activeIndex = -1; + activeItems = []; + return; + } + + const list = document.createElement("div"); + list.className = "publish-search-list"; + + if (pages.length) { + pages.forEach((page) => { + const title = page.page_title || page.page_uuid; + const href = `/page/${graphUuid}/${page.page_uuid}`; + const item = document.createElement("a"); + item.className = "publish-search-result"; + item.href = href; + + const kind = document.createElement("span"); + kind.className = "publish-search-kind"; + kind.textContent = "Page"; + + const titleEl = document.createElement("span"); + titleEl.className = "publish-search-title"; + titleEl.textContent = title; + + item.appendChild(kind); + item.appendChild(titleEl); + list.appendChild(item); + }); + } + + if (blocks.length) { + blocks.forEach((block) => { + const title = block.page_title || block.page_uuid; + const href = `/page/${graphUuid}/${block.page_uuid}#block-${block.block_uuid}`; + const snippet = buildSnippet(block.block_content || "", query); + const item = document.createElement("a"); + item.className = "publish-search-result"; + item.href = href; + + const titleEl = document.createElement("span"); + titleEl.className = "publish-search-title"; + titleEl.textContent = title; + + const snippetEl = document.createElement("span"); + snippetEl.className = "publish-search-snippet"; + snippetEl.textContent = snippet; + + item.appendChild(titleEl); + item.appendChild(snippetEl); + list.appendChild(item); + }); + } + + resultsEl.innerHTML = ""; + resultsEl.appendChild(list); + resultsEl.hidden = false; + activeIndex = -1; + activeItems = Array.from( + resultsEl.querySelectorAll(".publish-search-result") + ); + activeItems.forEach((item, index) => { + item.addEventListener("mouseenter", () => { + activeIndex = index; + updateActive(); + }); + }); + }; + + const updateActive = () => { + if (!activeItems.length) return; + activeItems.forEach((item, index) => { + item.classList.toggle("is-active", index === activeIndex); + }); + const activeEl = activeItems[activeIndex]; + if (activeEl) { + activeEl.scrollIntoView({ block: "nearest" }); + } + }; + + const moveActive = (direction) => { + if (!activeItems.length) { + activeItems = Array.from( + resultsEl.querySelectorAll(".publish-search-result") + ); + } + if (!activeItems.length) return; + + if (activeIndex === -1) { + activeIndex = direction > 0 ? 0 : activeItems.length - 1; + } else { + activeIndex = + (activeIndex + direction + activeItems.length) % + activeItems.length; + } + updateActive(); + }; + + const activateSelection = () => { + if (!activeItems.length) { + activeItems = Array.from( + resultsEl.querySelectorAll(".publish-search-result") + ); + } + if (!activeItems.length) return; + const item = + activeIndex >= 0 ? activeItems[activeIndex] : activeItems[0]; + if (item?.href) { + window.location.href = item.href; + } + }; + + const setExpanded = (expanded) => { + container.classList.toggle("is-expanded", expanded); + toggleBtn.setAttribute("aria-expanded", String(expanded)); + if (toggleIcon) { + toggleIcon.classList.toggle("ti-search", !expanded); + toggleIcon.classList.toggle("ti-x", expanded); + } + if (expanded) { + input.focus(); + } else { + input.value = ""; + hideResults(); + } + }; + + const runSearch = async (query) => { + if (!query) { + hideResults(); + return; + } + + if (activeController) activeController.abort(); + activeController = new AbortController(); + + try { + const resp = await fetch( + `/search/${encodeURIComponent(graphUuid)}?q=${encodeURIComponent(query)}`, + { signal: activeController.signal } + ); + if (!resp.ok) throw new Error("search request failed"); + const data = await resp.json(); + renderResults(query, data); + } catch (error) { + if (error?.name === "AbortError") return; + hideResults(); + } + }; + + if (graphUuid) { + input.addEventListener("input", () => { + const query = input.value.trim(); + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => runSearch(query), 250); + }); + } + + input.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + setExpanded(false); + } + if (event.key === "Enter") { + if (!resultsEl.hidden && input.value.trim()) { + activateSelection(); + event.preventDefault(); + } + } + if ( + !resultsEl.hidden && + input.value.trim() && + resultsEl.querySelector(".publish-search-result") + ) { + const key = event.key; + if (key === "ArrowDown" || key === "Down") { + moveActive(1); + event.preventDefault(); + } else if (key === "ArrowUp" || key === "Up") { + moveActive(-1); + event.preventDefault(); + } else if ((event.metaKey || event.ctrlKey) && key === "n") { + moveActive(1); + event.preventDefault(); + } else if ((event.metaKey || event.ctrlKey) && key === "p") { + moveActive(-1); + event.preventDefault(); + } + } + }); + + document.addEventListener("click", (event) => { + if (!container.contains(event.target)) setExpanded(false); + }); + + toggleBtn.addEventListener("click", () => { + const expanded = container.classList.contains("is-expanded"); + setExpanded(!expanded); + }); + + searchStateMap.set(container, { + setExpanded, + focusInput: () => input.focus(), + moveActive, + activateSelection, + hasResults: () => !!resultsEl.querySelector(".publish-search-result"), + isExpanded: () => container.classList.contains("is-expanded"), + }); + }); +}; + +const initPublish = () => { + applyTheme(preferredTheme()); + renderPropertyIcons(); + if (!window.tablerIcons) { + window.addEventListener("load", renderPropertyIcons, { once: true }); + } + + initTwitterEmbeds(); + initSearch(); + + document.querySelectorAll(".math-block").forEach((el) => { + const tex = el.textContent; + try { + katex.render(tex, el, { displayMode: true, throwOnError: false }); + } catch (_) {} + }); + + document.querySelectorAll(".code-block").forEach((block) => { + const codeEl = block.querySelector("code"); + const doc = codeEl ? codeEl.textContent : ""; + block.textContent = ""; + + const lang = (block.dataset.lang || "").toLowerCase(); + const langExt = (() => { + if (!lang) return null; + if (["js", "javascript", "ts", "typescript"].includes(lang)) { + return javascript({ typescript: lang.startsWith("t") }); + } + if (["py", "python"].includes(lang)) return python(); + if (["html", "htm"].includes(lang)) return html(); + if (["json"].includes(lang)) return json(); + if (["md", "markdown"].includes(lang)) return markdown(); + if (["sql"].includes(lang)) return sql(); + if (["css", "scss"].includes(lang)) return css(); + if (["clj", "cljc", "cljs", "clojure"].includes(lang)) return clojure(); + return null; + })(); + + const extensions = [ + lineNumbers(), + syntaxHighlighting(defaultHighlightStyle), + EditorView.editable.of(false), + EditorView.lineWrapping, + ]; + + if (langExt) extensions.push(langExt); + + const state = EditorState.create({ + doc, + extensions, + }); + + new EditorView({ state, parent: block }); + }); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initPublish); +} else { + initPublish(); +} diff --git a/deps/publish/src/logseq/publish/render.cljs b/deps/publish/src/logseq/publish/render.cljs new file mode 100644 index 0000000000..93852c3820 --- /dev/null +++ b/deps/publish/src/logseq/publish/render.cljs @@ -0,0 +1,1597 @@ +(ns logseq.publish.render + (:require-macros [hiccups.core]) + (:require [clojure.string :as string] + [hiccups.runtime] + [logseq.common.util :as common-util] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.property.type :as db-property-type] + [logseq.graph-parser.mldoc :as gp-mldoc] + [logseq.publish.common :as publish-common] + [logseq.publish.model :as publish-model])) + +;; Timestamp in milliseconds used for cache busting static assets. +(defonce version 1767100530314) + +(def ref-regex + (js/RegExp. "\\[\\[([0-9a-fA-F-]{36})\\]\\]|\\(\\(([0-9a-fA-F-]{36})\\)\\)" "g")) + +(defonce inline-config + (gp-mldoc/default-config :markdown)) + +(defn- block-ast + [text] + (when-not (string/blank? text) + (->> (gp-mldoc/->edn text inline-config) + (map first)))) + +(defn inline-ast [text] + (gp-mldoc/inline->edn text inline-config)) + +(defn content->nodes [content uuid->title graph-uuid] + (let [s (or content "") + re ref-regex] + (set! (.-lastIndex re) 0) + (loop [idx 0 out []] + (let [m (.exec re s)] + (if (nil? m) + (cond-> out + (< idx (count s)) (conj (subs s idx))) + (let [start (.-index m) + end (.-lastIndex re) + uuid (or (aget m 1) (aget m 2)) + title (get uuid->title uuid uuid) + href (when graph-uuid + (str "/page/" graph-uuid "/" uuid)) + node (if href + [:a.page-ref {:href href} title] + title) + out (cond-> out + (< idx start) (conj (subs s idx start)) + true (conj node))] + (recur end out))))))) + +(defn property-title + [prop-key property-title-by-ident] + (cond + (string? prop-key) prop-key + (keyword? prop-key) (or (get property-title-by-ident prop-key) + (name prop-key)) + :else (str prop-key))) + +(defn property-value-empty? + [value] + (cond + (nil? value) true + (string? value) (string/blank? value) + (coll? value) (empty? value) + :else false)) + +(defn format-datetime + [value] + (let [date (cond + (instance? js/Date value) value + (number? value) (js/Date. value) + (string? value) (js/Date. value) + :else nil)] + (when date + (-> (.toISOString date) + (string/replace "T" " ") + (string/replace "Z" ""))))) + +(defn nodes-join + [nodes-list sep] + (reduce (fn [acc nodes] + (if (empty? nodes) + acc + (if (seq acc) + (into (conj acc sep) nodes) + (into [] nodes)))) + [] + nodes-list)) + +(defn- normalize-nodes + [nodes] + (cond + (nil? nodes) [] + (and (vector? nodes) (keyword? (first nodes))) [nodes] + :else nodes)) + +(defn- icon-span + [icon] + (when (and (map? icon) (string? (:id icon)) (not (string/blank? (:id icon)))) + [:span + (cond-> + {:class "property-icon" + :data-icon-id (:id icon) + :data-icon-type (name (:type icon))} + (:color icon) + (assoc :style (str "color: " (:color icon) ";")))])) + +(defn- with-icon + [icon nodes] + (let [icon-node (icon-span icon)] + (if icon-node + (into [:span {:class "property-value-with-icon"} icon-node] nodes) + nodes))) + +(defn- page-title-node + [title icon] + (let [icon-node (icon-span icon)] + (if icon-node + [:h1 [:span {:class "property-value-with-icon"} icon-node title]] + [:h1 title]))) + +(defn- theme-toggle-node + [] + [:button.theme-toggle + {:type "button" + :role "switch" + :aria-checked "false"} + [:span.theme-toggle__icon.theme-toggle__icon--day {:aria-hidden "true"}] + [:span.theme-toggle__thumb {:aria-hidden "true"}] + [:span.theme-toggle__icon.theme-toggle__icon--night {:aria-hidden "true"}]]) + +(defn- toolbar-node + [& nodes] + (into [:div.page-toolbar] nodes)) + +(defn- search-node + [graph-uuid] + (let [graph-id (some-> graph-uuid str)] + [:div.publish-search {:data-graph-uuid graph-id} + [:button.publish-search-toggle + {:type "button" + :aria-label "Search" + :aria-expanded "false"} + [:span.ti.ti-search {:aria-hidden "true"}]] + [:input.publish-search-input + (cond-> + {:id "publish-search-input" + :type "search" + :placeholder "Search graph (Cmd+K)" + :autocomplete "off" + :spellcheck "false" + :aria-label "Search graph"} + (string/blank? (or graph-id "")) + (assoc :disabled true :placeholder "Search unavailable"))] + [:div.publish-search-hint "Up/Down to navigate"] + [:div.publish-search-results + {:id "publish-search-results" + :hidden true}]])) + +(defn- theme-init-script + [] + [:script + "(function(){try{var k='publish-theme';var t=localStorage.getItem(k);if(!t){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();"]) + +(defn- publish-script + [] + [:script {:type "module" :src (str "/static/publish.js?v=" version)}]) + +(defn- icon-runtime-script + [] + [:script + "(function(){if(window.React&&window.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED){return;}var s='http://www.w3.org/2000/svg';var k=function(n){return n.replace(/[A-Z]/g,function(m){return'-'+m.toLowerCase();});};var a=function(el,key,val){if(key==='className'){el.setAttribute('class',val);return;}if(key==='style'&&val&&typeof val==='object'){for(var sk in val){el.style[sk]=val[sk];}return;}if(key==='ref'||key==='key'||key==='children'){return;}if(val===true){el.setAttribute(key,'');return;}if(val===false||val==null){return;}var attr=key;if(key==='strokeWidth'){attr='stroke-width';}else if(key==='strokeLinecap'){attr='stroke-linecap';}else if(key==='strokeLinejoin'){attr='stroke-linejoin';}else if(key!=='viewBox'&&/[A-Z]/.test(key)){attr=k(key);}el.setAttribute(attr,val);};var c=function(el,child){if(child==null||child===false){return;}if(Array.isArray(child)){child.forEach(function(n){c(el,n);});return;}if(typeof child==='string'||typeof child==='number'){el.appendChild(document.createTextNode(child));return;}if(child.nodeType){el.appendChild(child);} };var e=function(type,props){var children=Array.prototype.slice.call(arguments,2);if(type===Symbol.for('react.fragment')){var frag=document.createDocumentFragment();children.forEach(function(n){c(frag,n);});return frag;}if(typeof type==='function'){return type(Object.assign({},props,{children:children}));}var isSvg=type==='svg'||(props&&props.xmlns===s);var el=isSvg?document.createElementNS(s,type):document.createElement(type);if(props){for(var p in props){a(el,p,props[p]);}}children.forEach(function(n){c(el,n);});return el;};window.React={createElement:e,forwardRef:function(fn){return fn;},Fragment:Symbol.for('react.fragment'),__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED:{ReactCurrentOwner:{current:null}}};window.PropTypes=new Proxy({}, {get:function(){return function(){return null;};}});})();"]) + +(defn- head-node + [title {:keys [description keywords topics tags url custom-css-hash graph-uuid]}] + (let [description (when (string? description) + (string/trim description)) + keywords (->> [keywords topics tags] + (map #(when (string? %) (string/trim %))) + (remove string/blank?) + (string/join ", ")) + meta-tags (remove nil? + [[:meta {:name "description" :content description}] + (when (seq keywords) + [:meta {:name "keywords" :content keywords}]) + (when (string? tags) + [:meta {:name "tags" :content tags}]) + (when (string? topics) + [:meta {:name "topics" :content topics}]) + [:meta {:content "summary_large_image" :name "twitter:card"}] + (when description + [:meta {:content description :name "twitter:description"}]) + [:meta {:content "@logseq" :name "twitter:site"}] + [:meta {:content title :name "twitter:title"}] + [:meta {:content "https://asset.logseq.com/static/img/social-banner-230118.png" + :name "twitter:image:src"}] + (when description + [:meta {:content description :name "twitter:image:alt"}]) + [:meta {:content title :property "og:title"}] + [:meta {:content "article" :property "og:type"}] + (when url + [:meta {:content url :property "og:url"}]) + [:meta {:content "https://asset.logseq.com/static/img/social-banner-230118.png" + :property "og:image"}] + (when description + [:meta {:content description :property "og:description"}]) + [:meta {:content "logseq" :property "og:site_name"}]]) + custom-css (when (and (string? custom-css-hash) (string? graph-uuid)) + [:link {:rel "stylesheet" + :href (str "/asset/" graph-uuid "/publish.css?v=" custom-css-hash)}])] + [:head + [:meta {:charset "UTF-8"}] + [:meta {:name "viewport" + :content "width=device-width, initial-scale=1.0, maximum-scale=5.0, minimum-scale=1.0"}] + [:meta {:http-equiv "X-UA-Compatible" :content "ie=edge"}] + [:title title] + [:link {:href "https://asset.logseq.com/static/img/logo.png" + :rel "shortcut icon" + :type "image/png"}] + [:link {:href "https://asset.logseq.com/static/img/logo.png" + :rel "shortcut icon" + :sizes "192x192"}] + [:link {:href "https://asset.logseq.com/static/img/logo.png" + :rel "apple-touch-icon"}] + [:meta {:content "Logseq" :name "apple-mobile-web-app-title"}] + [:meta {:content "yes" :name "apple-mobile-web-app-capable"}] + [:meta {:content "yes" :name "apple-touch-fullscreen"}] + [:meta {:content "black-translucent" :name "apple-mobile-web-app-status-bar-style"}] + [:meta {:content "yes" :name "mobile-web-app-capable"}] + (theme-init-script) + (icon-runtime-script) + [:script {:defer true :src "/static/tabler.ext.js"}] + [:link {:rel "stylesheet" + :href "https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.0/dist/tabler-icons.min.css"}] + [:link {:rel "stylesheet" :href (str "/static/tabler-extension.css?v=" version)}] + [:link {:rel "stylesheet" :href (str "/static/publish.css?v=" version)}] + custom-css + meta-tags])) + +(defn- render-head + ([title] (render-head title nil)) + ([title opts] + (head-node title (or opts {})))) + +(defn- meta-value + [meta k] + (or (get meta k) + (get meta (name k)))) + +(defn property-type + [prop-key property-type-by-ident] + (or (get property-type-by-ident prop-key) + (get-in db-property/built-in-properties [prop-key :schema :type]))) + +(defn page-ref->uuid [name name->uuid] + (or (get name->uuid name) + (get name->uuid (common-util/page-name-sanity-lc name)))) + +(defn entity->link-node + [entity ctx] + (let [title (publish-model/entity->title entity) + uuid (:block/uuid entity) + graph-uuid (:graph-uuid ctx)] + (cond + (and uuid graph-uuid (publish-model/page-entity? entity)) + [[:a.page-ref {:href (str "/page/" graph-uuid "/" uuid)} title]] + (common-util/url? title) + [:a {:href title} title] + :else + [title]))) + +(defn property-value->nodes + [value prop-key ctx entities] + (let [prop-type (property-type prop-key (:property-type-by-ident ctx)) + ref-type? (contains? db-property-type/all-ref-property-types prop-type)] + (cond + (nil? value) + [] + + (string? value) + (cond + (= prop-type :datetime) + (if-let [formatted (format-datetime value)] + [formatted] + (content->nodes value (:uuid->title ctx) (:graph-uuid ctx))) + + :else + (content->nodes value (:uuid->title ctx) (:graph-uuid ctx))) + + (keyword? value) + [(name value)] + + (map? value) + (if-let [eid (:db/id value)] + (property-value->nodes eid prop-key ctx entities) + (if-let [content (db-property/property-value-content value)] + (property-value->nodes content prop-key ctx entities) + [(pr-str value)])) + + (or (set? value) (sequential? value)) + (nodes-join (map #(property-value->nodes % prop-key ctx entities) value) ", ") + + (number? value) + (cond + (= prop-type :datetime) + (if-let [formatted (format-datetime value)] + [formatted] + [(str value)]) + + (and ref-type? (get entities value)) + (let [entity (get entities value)] + (with-icon (:logseq.property/icon entity) + (entity->link-node entity ctx))) + + :else + [(str value)]) + + :else + [(str value)]))) + +(defn built-in-tag? + [entity] + (when-let [ident (:db/ident entity)] + (= "logseq.class" (namespace ident)))) + +(defn filter-tags + [values entities] + (let [values (if (sequential? values) values [values])] + (->> values + (remove (fn [value] + (cond + (keyword? value) (= "logseq.class" (namespace value)) + :else + (let [entity (cond + (map? value) value + (number? value) (get entities value) + :else nil)] + (and entity (built-in-tag? entity)))))) + vec))) + +(defn entity-properties + [entity ctx entities] + (let [props (db-property/properties entity) + inline-props (:block/properties entity) + props (if (map? inline-props) + (merge props inline-props) + props) + props (->> props + (remove (fn [[k _]] + (true? (get (:property-hidden-by-ident ctx) k)))) + (map (fn [[k v]] + (if (= k :block/tags) + [k (filter-tags v entities)] + [k v]))) + (remove (fn [[_ v]] (property-value-empty? v))) + (remove (fn [[k v]] + (and (= k :block/tags) (property-value-empty? v))))) + props (into {} props)] + props)) + +(defn render-properties + [props ctx entities] + (when (seq props) + [:dl.properties + (for [[k v] (sort-by (fn [[prop-key _]] + (string/lower-case + (property-title prop-key (:property-title-by-ident ctx)))) + props)] + [:div.property + [:dt.property-name (property-title k (:property-title-by-ident ctx))] + [:dd.property-value + (into [:span] (normalize-nodes (property-value->nodes v k ctx entities)))]])])) + +(defn- property-ui-position + [prop-key ctx] + (when-let [property (get (:property-entity-by-ident ctx) prop-key)] + (:logseq.property/ui-position property))) + +(defn- split-properties-by-position + [props ctx] + (reduce (fn [acc [k v]] + (let [position (property-ui-position k ctx) + bucket (case position + (:block-left :block-right :block-below) position + :properties)] + (update acc bucket assoc k v))) + {:properties {} + :block-left {} + :block-right {} + :block-below {}} + props)) + +(defn- sorted-properties + [props ctx] + (sort-by (fn [[prop-key _]] + (get-in ctx [:property-entity-by-ident prop-key :block/order])) + props)) + +(defn- class-has? + [class-name target] + (some #{target} (string/split (or class-name "") #"\s+"))) + +(defn- node-has-class? + [node target] + (when (and (vector? node) (keyword? (first node))) + (let [attrs (second node)] + (and (map? attrs) (class-has? (:class attrs) target))))) + +(defn- strip-positioned-value + [node] + (if (node-has-class? node "property-value-with-icon") + (let [[tag attrs & children] node + icon-children (filter #(node-has-class? % "property-icon") children)] + (if (seq icon-children) + (into [tag attrs] icon-children) + node)) + node)) + +(defn- positioned-value-nodes + [value prop-key ctx entities] + (cond + (= prop-key :logseq.property/icon) + (let [icon-node (icon-span value)] + (if icon-node [icon-node] [])) + + (= prop-key :block/tags) + (normalize-nodes (property-value->nodes value prop-key ctx entities)) + + :else + (->> (property-value->nodes value prop-key ctx entities) + normalize-nodes + (map strip-positioned-value)))) + +(defn- render-positioned-properties + [props ctx entities position] + (when (seq props) + (case position + :block-below + [:div.positioned-properties.block-below + (for [[k v] (sorted-properties props ctx)] + [:div.positioned-property + [:span.property-name (property-title k (:property-title-by-ident ctx))] + [:span.property-value + (into [:span] (positioned-value-nodes v k ctx entities))]])] + + [:div {:class (str "positioned-properties " (name position))} + (for [[k v] (sorted-properties props ctx)] + [:span.positioned-property + (into [:span] (positioned-value-nodes v k ctx entities))])]))) + +(def ^:private youtube-regex #"^((?:https?:)?//)?((?:www|m).)?((?:youtube.com|youtu.be|y2u.be|youtube-nocookie.com))(/(?:[\w-]+\?v=|embed/|v/)?)([\w-]+)([\S^\?]+)?$") +(def ^:private vimeo-regex #"^((?:https?:)?//)?((?:www).)?((?:player.vimeo.com|vimeo.com))(/(?:video/)?)([\w-]+)(\S+)?$") +(def ^:private bilibili-regex #"^((?:https?:)?//)?((?:www).)?((?:bilibili.com))(/(?:video/)?)([\w-]+)(\?p=(\d+))?(\S+)?$") +(def ^:private loom-regex #"^((?:https?:)?//)?((?:www).)?((?:loom.com))(/(?:share/|embed/))([\w-]+)(\S+)?$") + +(defn- safe-match + [re value] + (when (and (string? value) (not (string/blank? value))) + (re-find re value))) + +(defn- macro-iframe + [src {:keys [class title]}] + (when (and (string? src) (not (string/blank? src))) + (let [class-name (string/join " " (remove nil? ["macro-embed" class]))] + [:div {:class class-name} + [:iframe {:src src + :title (or title "Embedded content") + :loading "lazy" + :allow "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" + :allowfullscreen true}]]))) + +(defn- youtube-embed + [url] + (let [id (cond + (and (string? url) (= 11 (count url))) url + :else (nth (safe-match youtube-regex url) 5 nil))] + (when (and id (string? id)) + (macro-iframe (str "https://www.youtube.com/embed/" id) {:class "macro-embed--video" :title "YouTube"})))) + +(defn- vimeo-embed + [url] + (let [id (nth (safe-match vimeo-regex url) 5 nil)] + (when (and id (string? id)) + (macro-iframe (str "https://player.vimeo.com/video/" id) {:class "macro-embed--video" :title "Vimeo"})))) + +(defn- bilibili-embed + [url] + (let [id (if (<= (count (or url "")) 15) + url + (nth (safe-match bilibili-regex url) 5 nil))] + (when (and id (string? id) (not (string/blank? id))) + (macro-iframe (str "https://player.bilibili.com/player.html?bvid=" id "&high_quality=1&autoplay=0") + {:class "macro-embed--video" :title "Bilibili"})))) + +(defn- video-embed + [url] + (when (common-util/url? url) + (let [matches (or (safe-match youtube-regex url) + (safe-match loom-regex url) + (safe-match vimeo-regex url) + (safe-match bilibili-regex url)) + src (cond + (and matches (contains? #{"youtube.com" "youtu.be" "y2u.be" "youtube-nocookie.com"} (nth matches 3))) + (let [id (nth matches 5)] + (when (= 11 (count (or id ""))) + (str "https://www.youtube.com/embed/" id))) + + (and matches (string/ends-with? (nth matches 3) "loom.com")) + (str "https://www.loom.com/embed/" (nth matches 5)) + + (and matches (string/ends-with? (nth matches 3) "vimeo.com")) + (str "https://player.vimeo.com/video/" (nth matches 5)) + + (and matches (= (nth matches 3) "bilibili.com")) + (str "https://player.bilibili.com/player.html?bvid=" (nth matches 5) "&high_quality=1&autoplay=0") + + :else + url)] + (macro-iframe src {:class "macro-embed--video" :title "Video"})))) + +(defn- tweet-embed + [url] + (let [url (cond + (and (string? url) (<= (count url) 15)) (str "https://x.com/i/status/" url) + :else url)] + (when url + [:div.twitter-tweet + [:a {:href url} url]]))) + +(defn- tweet-embed-from-html + [html] + (let [id (last (safe-match #"/status/(\d+)" html))] + (when (and id (string? id)) + (tweet-embed id)))) + +(defn- macro->nodes + [ctx {:keys [name arguments]}] + (let [name (string/lower-case (or name "")) + arguments (if (sequential? arguments) arguments []) + first-arg (first arguments)] + (cond + (= name "cloze") + [[:span.cloze (string/join ", " arguments)]] + + (= name "youtube") + (when-let [node (youtube-embed first-arg)] [node]) + + (= name "vimeo") + (when-let [node (vimeo-embed first-arg)] [node]) + + (= name "bilibili") + (when-let [node (bilibili-embed first-arg)] [node]) + + (= name "video") + (when-let [node (video-embed first-arg)] [node]) + + (contains? #{"tweet" "twitter"} name) + (when-let [node (tweet-embed first-arg)] [node]) + + :else + (content->nodes (str "{{" name (when (seq arguments) + (str " " (string/join ", " arguments))) "}}") + (:uuid->title ctx) + (:graph-uuid ctx))))) + +(defn- parse-macro-text + [value] + (when-let [[_ name args] (and (string? value) + (re-find #"\{\{\s*([^\s\}]+)\s*([^}]*)\}\}" value))] + (let [args (->> (string/split (or args "") #",") + (map string/trim) + (remove string/blank?) + vec)] + {:name name + :arguments args}))) + +(defn- normalize-macro-data + [data] + (cond + (map? data) data + (string? data) (parse-macro-text data) + (and (sequential? data) (seq data)) + (let [name (first data) + args (second data)] + {:name (when (string? name) name) + :arguments (if (sequential? args) args [])}) + :else nil)) + +(defn- macro-embed-node? + [node] + (when (vector? node) + (let [tag (first node) + attrs (second node)] + (and (= tag :div) + (map? attrs) + (string? (:class attrs)) + (string/includes? (:class attrs) "macro-embed"))))) + +(defn inline->nodes [ctx item] + (let [[type data] item + {:keys [uuid->title name->uuid graph-uuid]} ctx] + (cond + (or (= "Plain" type) (= "Spaces" type)) + (let [sub-ast (inline-ast data) + simple-plain? (and (= 1 (count sub-ast)) + (= "Plain" (ffirst sub-ast)))] + (if (and (seq sub-ast) (not simple-plain?)) + (mapcat #(inline->nodes ctx %) sub-ast) + (content->nodes data uuid->title graph-uuid))) + + (= "Break_Line" type) + [[:br]] + + (= "Emphasis" type) + (let [[[kind] items] data + tag (case kind + "Bold" :strong + "Italic" :em + "Underline" :ins + "Strike_through" :del + "Highlight" :mark + :span) + children (mapcat #(inline->nodes ctx %) items)] + [(into [tag] children)]) + + (or (= "Verbatim" type) (= "Code" type)) + [[:code data]] + + (= "Link" type) + (let [url (:url data) + label (:label data) + [link-type link-value] url + label-nodes (cond + (vector? label) (mapcat #(inline->nodes ctx %) label) + (seq? label) (mapcat #(inline->nodes ctx %) label) + (string? label) (content->nodes label uuid->title graph-uuid) + :else []) + page-uuid (when (= "Page_ref" link-type) + (or (page-ref->uuid link-value name->uuid) + (when (common-util/uuid-string? link-value) link-value))) + page-title (when page-uuid + (get uuid->title page-uuid)) + label-nodes (cond + (seq label-nodes) label-nodes + page-title [page-title] + (string? link-value) [link-value] + :else [""]) + href (cond + page-uuid (str "/page/" graph-uuid "/" page-uuid) + (= "Complex" link-type) (when (and (map? link-value) + (string? (:protocol link-value)) + (string? (:link link-value))) + (str (:protocol link-value) "://" (:link link-value))) + (string? link-value) link-value + :else nil)] + (if href + [(into [:a {:class (when page-uuid "page-ref") + :href href}] label-nodes)] + label-nodes)) + + (= "Tag" type) + (let [s (or (second data) "") + page-uuid (page-ref->uuid s name->uuid)] + (if page-uuid + [[:a.page-ref {:href (str "/page/" graph-uuid "/" page-uuid)} (str "#" s)]] + (if (and graph-uuid (not (string/blank? s))) + [[:a.page-ref {:href (str "/tag/" (js/encodeURIComponent s))} (str "#" s)]] + [(str "#" s)]))) + + (= "Macro" type) + (if-let [macro-data (normalize-macro-data data)] + (or (macro->nodes ctx macro-data) []) + (content->nodes (str data) uuid->title graph-uuid)) + + (= "Email" type) + (let [email (str (:local_part data) "@" (:domain data))] + [[:a {:href (str "mailto:" email)} email]]) + + (or (= "Inline_Html" type) (= "Export_Snippet" type)) + (if-let [node (tweet-embed-from-html data)] + [node] + []) + + :else + (content->nodes (str data) uuid->title graph-uuid)))) + +(defn- inline-coll->nodes + [ctx inline-coll] + (mapcat #(inline->nodes ctx %) (or inline-coll []))) + +(declare block-ast->nodes) +(defn- block-ast-coll->nodes + [ctx content] + (mapcat #(block-ast->nodes ctx %) (or content []))) + +(defn- list-items->node + [ctx items] + (into + [:ul] + (map (fn [item] + (let [content (let [content (:content item)] + (if (and (sequential? content) + (every? #(and (vector? %) (string? (first %))) content)) + (block-ast-coll->nodes ctx content) + (inline-coll->nodes ctx content))) + nested (when (seq (:items item)) + [(list-items->node ctx (:items item))]) + children (concat content nested)] + (into [:li] children))) + items))) + +(defn- block-ast->nodes + [ctx block-ast] + (let [[type data] block-ast] + (case type + "Paragraph" + (let [children (inline-coll->nodes ctx data)] + (when (seq children) + [(into [:p] children)])) + + "Heading" + (let [children (inline-coll->nodes ctx (:title data))] + (when (seq children) + [(into [:p] children)])) + + "List" + (when (seq data) + [(list-items->node ctx data)]) + + "Quote" + (when (seq data) + [(into [:blockquote] (mapcat #(block-ast->nodes ctx %) data))]) + + "Example" + (when (seq data) + [[:pre (string/join "\n" data)]]) + + "Src" + (let [lines (:lines data) + code (if (sequential? lines) (string/join "\n" lines) (str lines))] + [[:pre [:code code]]]) + + "Paragraph_Sep" + [[:br]] + + "Horizontal_Rule" + [[:hr]] + + (let [fallback (content->nodes (str data) (:uuid->title ctx) (:graph-uuid ctx))] + (when (seq fallback) + [(into [:p] fallback)]))))) + +(defn- block-ast-complex? + [block-asts] + (let [block-asts (seq block-asts)] + (and block-asts + (or (> (count block-asts) 1) + (some (fn [[type _]] + (not (contains? #{"Paragraph" "Heading"} type))) + block-asts))))) + +(defn- heading-level + [block depth] + (let [legacy (:block/heading-level block) + prop (:logseq.property/heading block) + legacy (when (and (number? legacy) (<= 1 legacy 6)) legacy) + prop (cond + (and (number? prop) (<= 1 prop 6)) prop + (true? prop) (min (inc depth) 6) + :else nil)] + (or legacy prop))) + +(defn- strip-heading-prefix + [s] + (string/replace s #"^\s*#+\s+" "")) + +(defn- property-value->text + [value ctx entities] + (cond + (nil? value) nil + (string? value) value + (keyword? value) (name value) + (number? value) + (if-let [entity (get entities value)] + (publish-model/entity->title entity) + (str value)) + (map? value) + (if (:db/id value) + (publish-model/entity->title value) + (if-let [content (db-property/property-value-content value)] + (str content) + (str value))) + (sequential? value) + (->> value + (map #(property-value->text % ctx entities)) + (remove string/blank?) + distinct + (string/join ", ")) + :else (str value))) + +(defn block-content-nodes [block ctx depth] + (let [raw (or (:block/content block) + (:block/title block) + (:block/name block) + "") + heading (heading-level block depth) + raw (if heading + (strip-heading-prefix raw) + raw) + block-asts (when-not heading (block-ast raw)) + block-level? (and (not heading) (block-ast-complex? block-asts)) + content (if block-level? + (mapcat #(block-ast->nodes ctx %) block-asts) + (let [ast (inline-ast raw)] + (if (seq ast) + (mapcat #(inline->nodes ctx %) ast) + (content->nodes raw (:uuid->title ctx) (:graph-uuid ctx))))) + container (cond + heading (keyword (str "h" heading ".block-text.block-heading")) + block-level? :div.block-text + (some macro-embed-node? content) :div.block-text + :else :span.block-text)] + (into [container] content))) + +(defn block-raw-content [block] + (or (:block/content block) + (:block/title block) + (:block/name block) + "")) + +(defn- asset-url [block ctx] + (let [asset-type (:logseq.property.asset/type block) + asset-uuid (:block/uuid block) + external-url (:logseq.property.asset/external-url block) + graph-uuid (:graph-uuid ctx)] + (cond + (string? external-url) external-url + (and asset-uuid asset-type graph-uuid) + (str "/asset/" graph-uuid "/" asset-uuid "." asset-type) + :else nil))) + +(def ^:private publish-image-variant-sizes + [1024 1600]) + +(def ^:private publish-image-variant-types + #{"png" "jpg" "jpeg" "webp"}) + +(def ^:private publish-image-sizes-attr + "(max-width: 640px) 92vw, (max-width: 1024px) 88vw, 920px") + +(defn- asset-variant-url + [graph-uuid asset-uuid asset-type variant] + (str "/asset/" graph-uuid "/" asset-uuid "@" variant "." asset-type)) + +(defn- variant-width + [block size] + (let [asset-width (:logseq.property.asset/width block) + asset-height (:logseq.property.asset/height block)] + (if (and (number? asset-width) + (number? asset-height) + (pos? asset-width) + (pos? asset-height)) + (let [max-dim (max asset-width asset-height) + scale (min 1 (/ size max-dim))] + (js/Math.round (* asset-width scale))) + size))) + +(defn- asset-node [block ctx] + (let [asset-type (:logseq.property.asset/type block) + asset-url (asset-url block ctx) + external-url (:logseq.property.asset/external-url block) + title (or (:block/title block) (str asset-type)) + ext (string/lower-case (or asset-type "")) + graph-uuid (:graph-uuid ctx) + asset-uuid (:block/uuid block) + variant? (and (not (string? external-url)) + graph-uuid + asset-uuid + (contains? publish-image-variant-types ext)) + srcset (when variant? + (->> publish-image-variant-sizes + (map (fn [size] + (let [width (variant-width block size)] + (str (asset-variant-url graph-uuid asset-uuid asset-type size) + " " + width + "w")))) + (string/join ", ")))] + (when asset-url + (cond + (contains? #{"png" "jpg" "jpeg" "gif" "webp" "svg" "bmp" "avif"} ext) + [:img.asset-image (cond-> {:src asset-url :alt title} + srcset (assoc :srcset srcset :sizes publish-image-sizes-attr))] + + (contains? #{"mp4" "webm" "mov"} ext) + [:video.asset-video {:src asset-url :controls true}] + + (contains? #{"mp3" "wav" "ogg"} ext) + [:audio.asset-audio {:src asset-url :controls true}] + + :else + [:a.asset-link {:href asset-url :target "_blank"} title])))) + +(defn block-display-node [block ctx depth] + (let [display-type (:logseq.property.node/display-type block) + asset-node (when (:logseq.property.asset/type block) + (asset-node block ctx))] + (case display-type + :asset asset-node + :code + (let [lang (:logseq.property.code/lang block) + attrs (cond-> {:class "code-block"} + (string? lang) (assoc :data-lang lang))] + [:div attrs [:code (block-raw-content block)]]) + + :math + [:div.math-block (block-raw-content block)] + + :quote + [:blockquote.quote-block (block-content-nodes block ctx depth)] + + (or asset-node + (block-content-nodes block ctx depth))))) + +(defn block-content-from-ref [ref ctx] + (let [raw (or (get ref "source_block_content") "") + block-asts (block-ast raw) + block-level? (block-ast-complex? block-asts) + content (if block-level? + (mapcat #(block-ast->nodes ctx %) block-asts) + (let [ast (inline-ast raw)] + (if (seq ast) + (mapcat #(inline->nodes ctx %) ast) + (content->nodes raw (:uuid->title ctx) (:graph-uuid ctx)))))] + (into [(if block-level? :div.block-text :span.block-text)] content))) + +(comment + (def ^:private void-tags + #{"area" "base" "br" "col" "embed" "hr" "img" "input" "link" "meta" "param" "source" "track" "wbr"})) + +(defn render-hiccup [node] + (hiccups.core/html node)) + +(defn sort-blocks [blocks] + (sort-by (fn [block] + (or (:block/order block) (:block/uuid block) "")) + blocks)) + +(defn- linked-block-entity + [block ctx visited] + (let [link (:block/link block) + linked-id (cond + (map? link) (:db/id link) + (number? link) link + :else nil)] + (when (and linked-id (not (contains? visited linked-id))) + (get (:entities ctx) linked-id)))) + +(defn render-block-tree + ([page-children-by-parent linked-children-by-parent parent-id ctx] + (render-block-tree page-children-by-parent linked-children-by-parent parent-id ctx #{} 1)) + ([page-children-by-parent linked-children-by-parent parent-id ctx visited depth] + (let [children (get page-children-by-parent parent-id)] + (when (seq children) + [:ul.blocks + (map (fn [block] + (let [linked-block (linked-block-entity block ctx visited) + display-block (or linked-block block) + display-id (:db/id display-block) + visited (cond-> visited linked-block (conj display-id)) + nested (render-block-tree + (if linked-block linked-children-by-parent page-children-by-parent) + linked-children-by-parent + display-id + ctx + visited + (inc depth)) + has-children? (boolean nested) + raw-props (entity-properties display-block ctx (:entities ctx)) + icon-prop (get raw-props :logseq.property/icon) + tags-prop (get raw-props :block/tags) + raw-props (dissoc raw-props :logseq.property/icon :block/tags) + {:keys [properties block-left block-right block-below]} + (split-properties-by-position raw-props ctx) + block-left (cond-> block-left + (and icon-prop (not (property-value-empty? icon-prop))) + (assoc :logseq.property/icon icon-prop)) + block-right (cond-> block-right + (and tags-prop (not (property-value-empty? tags-prop))) + (assoc :block/tags tags-prop)) + positioned-left (render-positioned-properties block-left ctx (:entities ctx) :block-left) + positioned-right (render-positioned-properties block-right ctx (:entities ctx) :block-right) + positioned-below (render-positioned-properties block-below ctx (:entities ctx) :block-below) + properties (render-properties properties ctx (:entities ctx)) + block-uuid (:block/uuid display-block) + block-uuid-str (some-> block-uuid str)] + [:li.block + (cond-> {:data-block-uuid block-uuid-str} + block-uuid-str (assoc :id (str "block-" block-uuid-str))) + [:div.block-content + (when positioned-left positioned-left) + (block-display-node display-block ctx depth) + (when positioned-right positioned-right) + (when has-children? + [:button.block-toggle + {:type "button" :aria-expanded "true"} + "▾"])] + (when positioned-below positioned-below) + (when properties + [:div.block-properties properties]) + (when nested + [:div.block-children nested])])) + (sort-blocks children))])))) + +(defn linked-references + [ctx graph-uuid linked-by-page] + [:section.linked-refs + [:h2 "Linked references"] + (for [{:keys [page_uuid page_title blocks]} linked-by-page] + (let [ref-page-uuid page_uuid + ref-page-title page_title + href (when (and graph-uuid ref-page-uuid) + (str "/page/" graph-uuid "/" ref-page-uuid))] + [:div.ref-page + (if href + [:a.page-ref {:href href} ref-page-title] + [:div.ref-title ref-page-title]) + (when (seq blocks) + [:ul.ref-blocks + (for [block blocks] + [:li.ref-block [:div.block-content (block-content-from-ref block ctx)]])])]))]) + +(defn tag-item-val [item k] + (cond + (map? item) (or (get item k) + (get item (name k))) + (object? item) (or (aget item (name k)) + (aget item k)) + :else nil)) + +(defn format-timestamp + [ts] + (when (number? ts) + (.toLocaleString (js/Date. ts)))) + +(defn render-tagged-item + [graph-uuid item] + (let [item-graph-uuid (tag-item-val item :graph_uuid) + graph-uuid (or item-graph-uuid graph-uuid) + source-page-uuid (tag-item-val item :source_page_uuid) + source-page-title (tag-item-val item :source_page_title) + source-block-uuid (tag-item-val item :source_block_uuid) + source-block-content (tag-item-val item :source_block_content) + updated-at (tag-item-val item :updated_at) + page? (and source-page-uuid (= source-page-uuid source-block-uuid)) + href (when (and graph-uuid source-page-uuid) + (str "/page/" graph-uuid "/" source-page-uuid))] + [:li.tagged-item + [:div.tagged-main + (if href + [:a.page-ref {:href href} (or source-page-title source-page-uuid)] + [:span (or source-page-title source-page-uuid)]) + (when (and source-block-content (not page?)) + [:div.tagged-block source-block-content])] + [:span.tagged-meta (or (format-timestamp updated-at) "—")]])) + +(defn- author-usernames + [entities page-eid page-entity] + (let [author-ids (->> entities + (keep (fn [[_e entity]] + (when (= (:block/page entity) page-eid) + (publish-model/ref-eid (:logseq.property/created-by-ref entity))))) + (concat [(publish-model/ref-eid (:logseq.property/created-by-ref page-entity))]) + (remove nil?) + distinct)] + (->> author-ids + (map #(get entities %)) + (keep publish-model/entity->title) + (remove string/blank?) + distinct + sort))) + +(defn render-page-html + [transit page-uuid-str refs-data tagged-nodes] + (let [payload (publish-common/read-transit-safe transit) + meta (publish-common/get-publish-meta payload) + graph-uuid (when meta + (or (:graph meta) + (:publish/graph meta) + (get meta "graph") + (get meta "publish/graph"))) + datoms (:datoms payload) + entities (publish-model/datoms->entities datoms) + page-uuid (uuid page-uuid-str) + page-entity (some (fn [[_e entity]] + (when (= (:block/uuid entity) page-uuid) + entity)) + entities) + page-title (publish-model/entity->title page-entity) + page-updated-at (:block/updated-at page-entity) + page-eid (some (fn [[e entity]] + (when (= (:block/uuid entity) page-uuid) + e)) + entities) + authors (author-usernames entities page-eid page-entity) + uuid->title (reduce (fn [acc [_e entity]] + (if-let [uuid-value (:block/uuid entity)] + (assoc acc (str uuid-value) (publish-model/entity->title entity)) + acc)) + {} + entities) + name->uuid (reduce (fn [acc [_e entity]] + (if-let [uuid-value (:block/uuid entity)] + (let [uuid-str (str uuid-value) + title (:block/title entity)] + (assoc acc title uuid-str)) + acc)) + {} + entities) + property-title-by-ident (reduce (fn [acc [_e entity]] + (if-let [ident (:db/ident entity)] + (assoc acc ident (publish-model/entity->title entity)) + acc)) + {} + entities) + property-type-by-ident (reduce (fn [acc [_e entity]] + (if-let [ident (:db/ident entity)] + (assoc acc ident (:logseq.property/type entity)) + acc)) + {} + entities) + property-hidden-by-ident (reduce (fn [acc [_e entity]] + (if-let [ident (:db/ident entity)] + (assoc acc ident (true? (:logseq.property/hide? entity))) + acc)) + {} + entities) + property-entity-by-ident (reduce (fn [acc [_e entity]] + (if-let [ident (:db/ident entity)] + (assoc acc ident entity) + acc)) + {} + entities) + children-by-parent (->> entities + (reduce (fn [acc [e entity]] + (if (and (= (:block/page entity) page-eid) + (not= e page-eid) + (not (:logseq.property/created-from-property entity))) + (let [parent (or (:block/parent entity) page-eid)] + (update acc parent (fnil conj []) entity)) + acc)) + {}) + (reduce-kv (fn [acc k v] + (assoc acc k (sort-blocks v))) + {})) + linked-children-by-parent (->> entities + (reduce (fn [acc [_e entity]] + (if (and (:block/parent entity) + (not (:logseq.property/created-from-property entity))) + (update acc (:block/parent entity) (fnil conj []) entity) + acc)) + {}) + (reduce-kv (fn [acc k v] + (assoc acc k (sort-blocks v))) + {})) + ctx {:uuid->title uuid->title + :name->uuid name->uuid + :graph-uuid graph-uuid + :property-title-by-ident property-title-by-ident + :property-type-by-ident property-type-by-ident + :property-hidden-by-ident property-hidden-by-ident + :property-entity-by-ident property-entity-by-ident + :entities entities} + page-props (entity-properties page-entity ctx entities) + page-properties (render-properties (dissoc page-props + :logseq.property/icon + :logseq.property.publish/published-url) + ctx + entities) + blocks (render-block-tree children-by-parent linked-children-by-parent page-eid ctx) + linked-by-page (when refs-data + (->> (get refs-data "refs") + (group-by #(get % "source_page_uuid")) + (map (fn [[_ items]] + {:page_title (get (first items) "source_page_title") + :page_uuid (get (first items) "source_page_uuid") + :blocks items})) + (sort-by (fn [{:keys [page_title]}] + (string/lower-case (or page_title "")))))) + linked-refs (when (seq linked-by-page) + (linked-references ctx graph-uuid linked-by-page)) + tagged-section (when (seq tagged-nodes) + [:section.tagged-pages + [:h2 "Tagged nodes"] + [:ul.tagged-list + (for [item tagged-nodes] + (render-tagged-item graph-uuid item))]]) + description (property-value->text (get page-props :logseq.property/description) ctx entities) + tags (property-value->text (or (get page-props :block/tags) + (get page-props :logseq.property/page-tags)) + ctx + entities) + keywords (property-value->text (get page-props :logseq.property/keywords) ctx entities) + topics (property-value->text (get page-props :logseq.property/topics) ctx entities) + page-url (when (and graph-uuid page-uuid-str) + (str "/page/" graph-uuid "/" page-uuid-str)) + custom-css-hash (meta-value meta :custom_publish_css_hash) + custom-js-hash (meta-value meta :custom_publish_js_hash) + doc [:html + (render-head page-title {:description description + :keywords keywords + :topics topics + :tags tags + :url page-url + :custom-css-hash custom-css-hash + :graph-uuid graph-uuid}) + [:body + [:main.wrap + (toolbar-node + (when graph-uuid + [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"]) + (search-node graph-uuid) + (theme-toggle-node)) + + (page-title-node page-title (:logseq.property/icon page-entity)) + [:div.page-meta + (when true + [:div.page-authors (str "By: " (string/join ", " authors))]) + + (let [updated-at (format-timestamp page-updated-at)] + [:div.page-updated-at updated-at])] + + (when page-properties + [:section.page-properties + page-properties]) + + (when blocks blocks) + (when tagged-section tagged-section) + (when linked-refs linked-refs)] + (publish-script) + (when (and (string? custom-js-hash) (string? graph-uuid)) + [:script {:defer true + :src (str "/asset/" graph-uuid "/publish.js?v=" custom-js-hash)}])]]] + (str "" (render-hiccup doc)))) + +(defn render-graph-html + [graph-uuid pages] + (let [rows (->> pages + (map (fn [page] + (let [page-uuid (aget page "page_uuid") + page-title (aget page "page_title") + updated-at (aget page "updated_at") + href (str "/page/" graph-uuid "/" page-uuid) + short-id (aget page "short_id")] + {:page-uuid page-uuid + :page-title page-title + :href href + :short-id short-id + :updated-at updated-at}))) + (sort-by (fn [row] + (or (:updated-at row) 0))) + reverse) + doc [:html + (render-head "Published pages") + [:body + [:main.wrap + (toolbar-node + (search-node graph-uuid) + (theme-toggle-node)) + [:h1 "Published pages"] + (if (seq rows) + [:ul.page-list + (for [{:keys [page-uuid page-title href updated-at]} rows] + [:li.page-item + [:div.page-links + [:a.page-link {:href href} (or page-title page-uuid)]] + [:span.page-meta (or (format-timestamp updated-at) "—")]])] + [:p "No pages have been published yet."]) + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-user-html + [username user pages] + (let [username (or (aget user "username") username) + rows (->> pages + (map (fn [page] + (let [page-uuid (aget page "page_uuid") + page-title (aget page "page_title") + updated-at (aget page "updated_at") + graph-uuid (aget page "graph_uuid") + href (str "/page/" graph-uuid "/" page-uuid) + short-id (aget page "short_id")] + {:page-uuid page-uuid + :page-title page-title + :href href + :short-id short-id + :updated-at updated-at + :graph-uuid graph-uuid}))) + (sort-by (fn [row] + (or (:updated-at row) 0))) + reverse) + title (str "Published by " username) + doc [:html + (render-head title) + [:body + [:main.wrap + (toolbar-node + (search-node nil) + (theme-toggle-node)) + [:h1 title] + (if (seq rows) + [:ul.page-list + (for [{:keys [page-uuid page-title href updated-at]} rows] + [:li.page-item + [:div.page-links + [:a.page-link {:href href} (or page-title page-uuid)]] + [:span.page-meta + (or (format-timestamp updated-at) "—")]])] + [:p "No pages have been published yet."]) + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-tag-html + [graph-uuid tag-uuid tag-title tag-items] + (let [rows tag-items + title (or tag-title tag-uuid) + doc [:html + (render-head (str "#" title)) + [:body + [:main.wrap + (toolbar-node + (when graph-uuid + [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"]) + (search-node graph-uuid) + (theme-toggle-node)) + [:h1 (str "#" title)] + (if (seq rows) + [:ul.page-list + (for [item rows] + (render-tagged-item graph-uuid item))] + [:p "No published nodes use this tag yet."]) + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-tag-name-html + [tag-name tag-title tag-items] + (let [rows tag-items + title (or tag-title tag-name) + doc [:html + (render-head (str "Tag - " title)) + [:body + [:main.wrap + (toolbar-node + [:a.toolbar-btn {:href "/"} "Home"] + (theme-toggle-node)) + [:h1 (str "#" title)] + (if (seq rows) + [:ul.page-list + (for [row rows] + (render-tagged-item (tag-item-val row :graph_uuid) row))] + [:p "No published pages use this tag yet."]) + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-home-html + [] + (let [doc [:html + (render-head "Logseq Publish") + [:body.publish-home + [:svg#publish-home-bg.publish-home-bg + {:aria-hidden "true"}] + [:main.publish-home-card + [:div.publish-home-logo "Logseq Publish"] + [:h1.publish-home-title + "Small notes," + [:br] + [:strong "big "] + "connections!"] + [:p.publish-home-subtitle + "Publish your Logseq notes to the web. Each note links through " + [:code "#tag"] + " or " + [:code "[[page]] references"] + ", connecting your dots with others."]] + [:script + "(function(){\n" + " const svg = document.getElementById('publish-home-bg');\n" + " if (!svg) return;\n" + " let width = window.innerWidth;\n" + " let height = window.innerHeight;\n" + " const POINTS_COUNT = 40;\n" + " const MAX_DIST = 160;\n" + " const pts = [];\n" + " let circlesGroup;\n" + " let linesGroup;\n" + "\n" + " const cssVar = (name) =>\n" + " getComputedStyle(document.documentElement)\n" + " .getPropertyValue(name)\n" + " .trim();\n" + "\n" + " function resize() {\n" + " width = window.innerWidth;\n" + " height = window.innerHeight;\n" + " svg.setAttribute('width', width);\n" + " svg.setAttribute('height', height);\n" + " svg.setAttribute('viewBox', `0 0 ${width} ${height}`);\n" + " }\n" + "\n" + " function createSvgElement(tag, attrs) {\n" + " const el = document.createElementNS('http://www.w3.org/2000/svg', tag);\n" + " for (const k in attrs) el.setAttribute(k, attrs[k]);\n" + " return el;\n" + " }\n" + "\n" + " function init() {\n" + " resize();\n" + " svg.innerHTML = '';\n" + "\n" + " const lineColor = cssVar('--muted') || '#6f6e69';\n" + " const dotColor = cssVar('--ink') || '#282726';\n" + "\n" + " linesGroup = createSvgElement('g', {\n" + " stroke: lineColor,\n" + " 'stroke-width': 0.6,\n" + " 'stroke-linecap': 'round',\n" + " opacity: 0.35\n" + " });\n" + " circlesGroup = createSvgElement('g', { fill: dotColor, opacity: 0.65 });\n" + "\n" + " svg.appendChild(linesGroup);\n" + " svg.appendChild(circlesGroup);\n" + " pts.length = 0;\n" + "\n" + " for (let i = 0; i < POINTS_COUNT; i++) {\n" + " const x = Math.random() * width;\n" + " const y = Math.random() * height;\n" + " const speed = 0.15 + Math.random() * 0.25;\n" + " const angle = Math.random() * Math.PI * 2;\n" + " const vx = Math.cos(angle) * speed;\n" + " const vy = Math.sin(angle) * speed;\n" + "\n" + " const circle = createSvgElement('circle', {\n" + " cx: x,\n" + " cy: y,\n" + " r: 2 + Math.random() * 1.2\n" + " });\n" + "\n" + " circlesGroup.appendChild(circle);\n" + " pts.push({ x, y, vx, vy, circle });\n" + " }\n" + " }\n" + "\n" + " function step() {\n" + " linesGroup.innerHTML = '';\n" + "\n" + " for (let i = 0; i < pts.length; i++) {\n" + " const p = pts[i];\n" + " p.x += p.vx;\n" + " p.y += p.vy;\n" + "\n" + " if (p.x < 0 || p.x > width) p.vx *= -1;\n" + " if (p.y < 0 || p.y > height) p.vy *= -1;\n" + "\n" + " p.circle.setAttribute('cx', p.x);\n" + " p.circle.setAttribute('cy', p.y);\n" + " }\n" + "\n" + " for (let i = 0; i < pts.length; i++) {\n" + " for (let j = i + 1; j < pts.length; j++) {\n" + " const p1 = pts[i];\n" + " const p2 = pts[j];\n" + " const dx = p1.x - p2.x;\n" + " const dy = p1.y - p2.y;\n" + " const dist = Math.sqrt(dx * dx + dy * dy);\n" + " if (dist < MAX_DIST) {\n" + " const opacity = 0.35 * (1 - dist / MAX_DIST);\n" + " const line = createSvgElement('line', {\n" + " x1: p1.x,\n" + " y1: p1.y,\n" + " x2: p2.x,\n" + " y2: p2.y,\n" + " opacity: opacity.toString()\n" + " });\n" + " linesGroup.appendChild(line);\n" + " }\n" + " }\n" + " }\n" + "\n" + " requestAnimationFrame(step);\n" + " }\n" + "\n" + " window.addEventListener('resize', () => {\n" + " init();\n" + " });\n" + "\n" + " init();\n" + " requestAnimationFrame(step);\n" + "})();\n"]]]] + (str "" (render-hiccup doc)))) + +(defn render-ref-html + [graph-uuid ref-name ref-title ref-items] + (let [rows ref-items + title (or ref-title ref-name) + doc [:html + (render-head (str "Ref - " title)) + [:body + [:main.wrap + (toolbar-node + [:a.toolbar-btn {:href "/"} "Home"] + (theme-toggle-node)) + [:h1 title] + [:p.tag-sub (str "Reference: " ref-name)] + (if (seq rows) + [:ul.page-list + (for [row rows + :let [graph-id (or (tag-item-val row :graph_uuid) graph-uuid) + page-uuid (tag-item-val row :source_page_uuid) + page-title (tag-item-val row :source_page_title) + href (when (and graph-id page-uuid) + (str "/page/" graph-id "/" page-uuid))]] + [:li.page-item + [:div.page-links + (if href + [:a.page-ref {:href href} (or page-title page-uuid)] + [:span (or page-title page-uuid)])] + [:span.page-meta (or (format-timestamp (tag-item-val row :updated_at)) "—")]])] + [:p "No published pages reference this yet."]) + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-not-published-html + [graph-uuid] + (let [title "Page not published" + doc [:html + (render-head title) + [:body + [:main.wrap + (toolbar-node + (when graph-uuid + [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"]) + (search-node graph-uuid) + (theme-toggle-node)) + [:h1 title] + [:p.tag-sub "This page hasn't been published yet."] + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-password-html + [graph-uuid page-uuid wrong?] + (let [title "Protected page" + doc [:html + (render-head title) + [:body + [:main.wrap + (toolbar-node + (when graph-uuid + [:a.toolbar-btn {:href (str "/graph/" graph-uuid)} "Home"]) + (search-node graph-uuid) + (theme-toggle-node)) + [:div.password-card + [:h1 title] + [:p.tag-sub "This page is password protected."] + (when wrong? + [:p.password-error "Incorrect password."]) + [:form.password-form {:method "GET"} + (when page-uuid + [:input {:type "hidden" :name "page" :value page-uuid}]) + [:label.password-label {:for "publish-password"} "Enter password"] + [:input.password-input {:id "publish-password" + :name "password" + :type "password" + :placeholder "Password" + :required true}] + [:button.toolbar-btn {:type "submit"} "Unlock"]]] + (publish-script)]]]] + (str "" (render-hiccup doc)))) + +(defn render-404-html + [] + (let [title "Page not found" + doc [:html + (render-head title) + [:body + [:main.wrap + (toolbar-node + [:a.toolbar-btn {:href "/"} "Home"] + (theme-toggle-node)) + [:div.not-found + [:p.not-found-eyebrow "404"] + [:h1 title] + [:p.tag-sub "We couldn't find that page. It may have been removed or never published."]] + (publish-script)]]]] + (str "" (render-hiccup doc)))) diff --git a/deps/publish/src/logseq/publish/routes.cljs b/deps/publish/src/logseq/publish/routes.cljs new file mode 100644 index 0000000000..3af0a7e1cb --- /dev/null +++ b/deps/publish/src/logseq/publish/routes.cljs @@ -0,0 +1,773 @@ +(ns logseq.publish.routes + (:require [cljs-bean.core :as bean] + [clojure.string :as string] + [logseq.publish.assets :as publish-assets] + [logseq.publish.common :as publish-common] + [logseq.publish.index :as publish-index] + [logseq.publish.model :as publish-model] + [logseq.publish.render :as publish-render] + [shadow.resource :as resource]) + (:require-macros [logseq.publish.async :refer [js-await]])) + +(def publish-css (resource/inline "logseq/publish/publish.css")) +(def publish-js (resource/inline "logseq/publish/publish.js")) +(def tabler-ext-js (resource/inline "js/tabler.ext.js")) +(def tabler-extension-css (resource/inline "css/tabler-extension.css")) + +(defn- request-password + [request] + (let [url (js/URL. (.-url request)) + query (.get (.-searchParams url) "password") + header (.get (.-headers request) "x-publish-password")] + (or header query))) + +(defn- fetch-page-password-hash + [graph-uuid page-uuid env] + (js-await [^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) + (js-await [data (.json resp)] + (aget data "password_hash"))))) + +(defn- check-page-password + [request graph-uuid page-uuid env] + (js-await [stored-hash (fetch-page-password-hash graph-uuid page-uuid env)] + (if (string/blank? stored-hash) + {:allowed? true :provided? false} + (let [provided (request-password request)] + (if (string? provided) + (js-await [valid? (publish-common/verify-password provided stored-hash)] + {:allowed? valid? :provided? true}) + {:allowed? false :provided? false}))))) + +(defn- auth-claims + [request env] + (js-await [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 (publish-common/verify-jwt token env))] + {:claims claims})) + +(defn handle-post-pages [request env] + (js-await [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 (publish-common/verify-jwt token env))] + (if (nil? claims) + (publish-common/unauthorized) + (js-await [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 + (js-await [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) + (js-await [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)})))))))))) + +(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") + (js-await [^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"})] + (if-not (.-ok tags-resp) + (publish-common/not-found) + (js-await [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) + tag-uuid)] + (js/Response. + (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))})))))) + +(defn handle-get-page [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (js-await [^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) + (js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (publish-common/json-response {:error "password required"} 401) + (js-await [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)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (js-await [^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))}) + (js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (publish-common/json-response {:error "password required"} 401) + (js-await [meta (.json meta-resp) + r2-key (aget meta "r2_key")] + (if-not r2-key + (publish-common/json-response {:error "missing transit"} 404) + (js-await [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)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (publish-common/json-response {:error "password required"} 401) + (js-await [^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))}) + (js-await [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)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (js-await [{:keys [allowed?]} (check-page-password request graph-uuid page-uuid env)] + (if-not allowed? + (publish-common/json-response {:error "password required"} 401) + (js-await [^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) + (js-await [tags (.json tags-resp)] + (publish-common/json-response (js->clj tags :keywordize-keys true) 200))))))))) + +(defn handle-list-pages [env] + (js-await [^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))}) + (js-await [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") + (js-await [^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))}) + (js-await [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) #"/") + graph-uuid (nth parts 2 nil) + query (.get (.-searchParams url) "q")] + (if (or (string/blank? graph-uuid) (string/blank? query)) + (publish-common/bad-request "missing graph uuid or query") + (js-await [^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)) + #js {:method "GET"})] + (if-not (.-ok resp) + (publish-common/not-found) + (js-await [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") + (js-await [^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))}) + (js-await [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") + (js-await [^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))}) + (js-await [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") + (js-await [^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))}) + (js-await [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") + (js-await [^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) + (js-await [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") + (js-await [^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) + (js-await [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)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil)] + (handle-list-graph-pages-by-uuid graph-uuid env))) + +(defn handle-delete-page [request env] + (let [url (js/URL. (.-url request)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (js-await [{:keys [claims]} (auth-claims request env)] + (if (nil? claims) + (publish-common/unauthorized) + (js-await [^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) + (js-await [meta (.json meta-resp) + owner-sub (aget meta "owner_sub") + subject (aget claims "sub")] + (if (and (or (string/blank? owner-sub) + (not= owner-sub subject))) + (publish-common/forbidden) + (js-await [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)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil)] + (if-not graph-uuid + (publish-common/bad-request "missing graph uuid") + (js-await [{:keys [claims]} (auth-claims request env)] + (if (nil? claims) + (publish-common/unauthorized) + (js-await [^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) + (js-await [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) + (js-await [_ (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)) + parts (string/split (.-pathname url) #"/") + graph-uuid (nth parts 2 nil) + page-uuid (nth parts 3 nil)] + (if (or (nil? graph-uuid) (nil? page-uuid)) + (publish-common/bad-request "missing graph uuid or page uuid") + (js-await [^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-await [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)) + (js-await [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))}))) + (js-await [{: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))}) + (js-await [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)) + (js-await [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)) + (js-await [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) + (js-await [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 handle-fetch [request env] + (let [url (js/URL. (.-url request)) + path (.-pathname url) + method (.-method request)] + (cond + (= method "OPTIONS") + (js/Response. nil #js {:status 204 :headers (publish-common/cors-headers)}) + + (and (= path "/static/publish.css") (= method "GET")) + (js/Response. + publish-css + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/css; charset=utf-8" + "cache-control" "public, max-age=31536000, immutable"} + (publish-common/cors-headers))}) + + (and (= path "/static/publish.js") (= method "GET")) + (js/Response. + publish-js + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/javascript; charset=utf-8" + "cache-control" "public, max-age=31536000, immutable"} + (publish-common/cors-headers))}) + + (and (= path "/static/tabler.ext.js") (= method "GET")) + (js/Response. + tabler-ext-js + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/javascript; charset=utf-8" + "cache-control" "public, max-age=31536000, immutable"} + (publish-common/cors-headers))}) + + (and (= path "/") (= method "GET")) + (js/Response. + (publish-render/render-home-html) + #js {:headers (publish-common/merge-headers + #js {"content-type" "text/html; charset=utf-8" + "cache-control" "public, max-age=31536000, immutable"} + (publish-common/cors-headers))}) + + (and (string/starts-with? path "/page/") (= method "GET")) + (handle-page-html request env) + + (and (= path "/assets") (= method "POST")) + (publish-assets/handle-post-asset request env) + + (and (= path "/pages") (= method "POST")) + (handle-post-pages request env) + + (and (= path "/pages") (= method "GET")) + (handle-list-pages env) + + (and (string/starts-with? path "/search/") (= method "GET")) + (handle-graph-search request env) + + (and (string/starts-with? path "/graph/") (= method "GET")) + (let [parts (string/split path #"/") + graph-uuid (nth parts 2 nil)] + (if (= (nth parts 3 nil) "json") + (handle-list-graph-pages-by-uuid graph-uuid env) + (handle-graph-html graph-uuid env))) + + (and (string/starts-with? path "/tag/") (= method "GET")) + (let [parts (string/split path #"/") + raw-name (nth parts 2 nil) + tag-name (when raw-name + (js/decodeURIComponent raw-name))] + (if (= (nth parts 3 nil) "json") + (handle-tag-name-json tag-name env) + (handle-tag-name-html tag-name env))) + + (and (string/starts-with? path "/ref/") (= method "GET")) + (let [parts (string/split path #"/") + raw-name (nth parts 2 nil) + ref-name (when raw-name + (js/decodeURIComponent raw-name))] + (if (= (nth parts 3 nil) "json") + (handle-ref-name-json ref-name env) + (handle-ref-name-html ref-name env))) + + (and (string/starts-with? path "/asset/") (= method "GET")) + (let [parts (string/split path #"/") + graph-uuid (nth parts 2 nil) + file-name (nth parts 3 nil)] + (if (or (string/blank? graph-uuid) (string/blank? file-name)) + (publish-common/bad-request "missing asset id") + (let [ext-idx (string/last-index-of file-name ".") + asset-uuid (when (and ext-idx (pos? ext-idx)) + (subs file-name 0 ext-idx)) + asset-type (when (and ext-idx (pos? ext-idx)) + (subs file-name (inc ext-idx)))] + (if (or (string/blank? asset-uuid) (string/blank? asset-type)) + (publish-common/bad-request "invalid asset id") + (js-await [r2 (aget env "PUBLISH_R2") + r2-key (str "publish/assets/" graph-uuid "/" asset-uuid "." asset-type) + ^js object (.get r2 r2-key)] + (if-not object + (publish-common/not-found) + (let [headers (publish-common/merge-headers + #js {"content-type" (or (some-> object .-httpMetadata .-contentType) + (publish-assets/asset-content-type asset-type)) + "cache-control" "public, max-age=31536000, immutable"} + (publish-common/cors-headers))] + (js/Response. (.-body object) + #js {:headers headers})))))))) + + (and (string/starts-with? path "/p/") (= method "GET")) + (let [parts (string/split path #"/") + short-id (nth parts 2 nil)] + (if (string/blank? short-id) + (publish-common/bad-request "missing short id") + (js-await [^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) + (js-await [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") + (js-await [^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) + (js-await [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 #"/")] + (cond + (= (count parts) 3) (handle-list-graph-pages request env) + (= (nth parts 4 nil) "transit") (handle-get-page-transit request env) + (= (nth parts 4 nil) "refs") (handle-get-page-refs request env) + (= (nth parts 4 nil) "tagged_nodes") (handle-get-page-tagged-nodes request env) + :else (handle-get-page request env))) + + (and (string/starts-with? path "/pages/") (= method "DELETE")) + (let [parts (string/split path #"/")] + (if (= (count parts) 3) + (handle-delete-graph request env) + (handle-delete-page request env))) + + :else + (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))})))) diff --git a/deps/publish/src/logseq/publish/worker.cljs b/deps/publish/src/logseq/publish/worker.cljs new file mode 100644 index 0000000000..a8592d9a23 --- /dev/null +++ b/deps/publish/src/logseq/publish/worker.cljs @@ -0,0 +1,22 @@ +(ns logseq.publish.worker + (:require ["cloudflare:workers" :refer [DurableObject]] + [logseq.publish.meta-store :as meta-store] + [logseq.publish.routes :as publish-routes] + [shadow.cljs.modern :refer (defclass)])) + +(def worker + #js {:fetch (fn [request env _ctx] + (publish-routes/handle-fetch request env))}) + +(defclass PublishMetaDO + (extends DurableObject) + + (constructor [this ^js state env] + (super state env) + (set! (.-state this) state) + (set! (.-env this) env) + (set! (.-sql this) (.-sql ^js (.-storage state)))) + + Object + (fetch [this request] + (meta-store/do-fetch this request))) diff --git a/deps/publish/worker/README.md b/deps/publish/worker/README.md new file mode 100644 index 0000000000..6baba9aee1 --- /dev/null +++ b/deps/publish/worker/README.md @@ -0,0 +1,46 @@ +## Cloudflare Publish Worker (Skeleton) + +This worker accepts publish payloads and stores transit blobs in R2 while keeping +metadata in a Durable Object backed by SQLite. + +### Bindings + +- `PUBLISH_META_DO`: Durable Object namespace +- `PUBLISH_R2`: R2 bucket +- `R2_ACCOUNT_ID`: Cloudflare account id for signing +- `R2_BUCKET`: R2 bucket name for signing +- `R2_ACCESS_KEY_ID`: R2 access key for signing +- `R2_SECRET_ACCESS_KEY`: R2 secret key for signing +- `COGNITO_JWKS_URL`: JWKS URL for Cognito user pool +- `COGNITO_ISSUER`: Cognito issuer URL +- `COGNITO_CLIENT_ID`: Cognito client ID +- `DEV_SKIP_AUTH`: set to `true` to bypass JWT verification in local dev + +### Routes + +- `GET /p/:graph-uuid/:page-uuid` + - Returns server-rendered HTML for the page +- `POST /pages` + - Requires `Authorization: Bearer ` + - Requires `x-publish-meta` header (JSON) + - Body is transit payload (stored in R2 as-is) +- `GET /pages/:graph-uuid/:page-uuid` + - Returns metadata for the page +- `GET /pages/:graph-uuid/:page-uuid/transit` + - Returns JSON with a signed R2 URL and `etag` +- `DELETE /pages/:graph-uuid/:page-uuid` + - Deletes a published page +- `DELETE /pages/:graph-uuid` + - Deletes all pages for a graph +- `GET /pages` + - Lists metadata entries (from the index DO) + +### Notes + +- This is a starter implementation. Integrate with your deployment tooling + (wrangler, etc.) as needed. +- For local testing, run `wrangler dev` and use `deps/publish/worker/scripts/dev_test.sh`. +- If you switch schema versions, clear local DO state with + `deps/publish/worker/scripts/clear_dev_state.sh`. +- Build the worker bundle with `clojure -M:cljs release publish-worker` before running the worker. +- For dev, you can run `clojure -M:cljs watch publish-worker` in one terminal. diff --git a/deps/publish/worker/scripts/clear_dev_state.sh b/deps/publish/worker/scripts/clear_dev_state.sh new file mode 100755 index 0000000000..26006a1544 --- /dev/null +++ b/deps/publish/worker/scripts/clear_dev_state.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +GRAPH_UUID=${GRAPH_UUID:-"00000000-0000-0000-0000-000000000000"} + +cat <> (shell {:out :string} "git diff --name-only") :out diff --git a/shadow-cljs.edn b/shadow-cljs.edn index 20e3718548..9dcb23828b 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -1,6 +1,7 @@ ;; shadow-cljs configuration {:deps true :nrepl {:port 8701} + :source-paths ["src/main" "src/electron" "src/resources"] ;; :ssl {:password "logseq"} diff --git a/src/main/frontend/common/missionary.cljs b/src/main/frontend/common/missionary.cljs index d334fa9651..ad9c068de0 100644 --- a/src/main/frontend/common/missionary.cljs +++ b/src/main/frontend/common/missionary.cljs @@ -4,6 +4,7 @@ (:require [cljs.core.async.impl.channels] [clojure.core.async :as a] [lambdaisland.glogi :as log] + [logseq.db.common.entity-plus :as entity-plus] [missionary.core :as m] [promesa.protocols :as pt]) (:import [missionary Cancelled])) @@ -158,6 +159,8 @@ [key'] (contains? @*background-task-cancelers key')) +(reset! entity-plus/*reset-cache-background-task-running-f background-task-running?) + (comment (defn >! "Return a task that diff --git a/src/main/frontend/components/header.cljs b/src/main/frontend/components/header.cljs index 64ec1fbe9b..a5b6c4c779 100644 --- a/src/main/frontend/components/header.cljs +++ b/src/main/frontend/components/header.cljs @@ -133,7 +133,10 @@ (fn [] (if favorited? (page-handler/ (publish-handler/publish-page! page {:password password}) + (p/finally (fn [] + (set-publishing! false) + (shui/dialog-close!))))))] + [:div.flex.flex-col.gap-4.p-2 + [:div.text-lg.font-medium "Publish page"] + [:div.text-sm.opacity-70 + "Optionally protect this page with a password. Leave empty for public access."] + (shui/toggle-password + {:placeholder "Optional password" + :value password + :on-change (fn [e] + (set-password! (util/evalue e)))}) + [:div.flex.justify-end.gap-2 + (shui/button + {:variant "ghost" + :on-click #(shui/dialog-close!)} + "Cancel") + (shui/button + {:on-click submit! + :disabled publishing?} + (if publishing? + "Publishing..." + "Publish"))]])) (defn- delete-page! [page] @@ -97,6 +132,11 @@ :export-type :page})) {:class "w-auto md:max-w-4xl max-h-[80vh] overflow-y-auto"})}}) + (when (and page (not config/publishing?)) + {:title "Publish page" + :options {:on-click #(shui/dialog-open! (fn [] (publish-page-dialog page)) + {:class "w-auto max-w-md"})}}) + (when (util/electron?) {:title (t (if public? :page/make-private :page/make-public)) :options {:on-click diff --git a/src/main/frontend/components/property/value.cljs b/src/main/frontend/components/property/value.cljs index 4d9329998c..78104711e8 100644 --- a/src/main/frontend/components/property/value.cljs +++ b/src/main/frontend/components/property/value.cljs @@ -20,6 +20,7 @@ [frontend.handler.page :as page-handler] [frontend.handler.property :as property-handler] [frontend.handler.property.util :as pu] + [frontend.handler.publish :as publish-handler] [frontend.handler.route :as route-handler] [frontend.modules.outliner.ui :as ui-outliner-tx] [frontend.search :as search] @@ -1237,17 +1238,36 @@ {:on-click #(next-phase block-entity phase)))}) diff --git a/src/main/frontend/handler/publish.cljs b/src/main/frontend/handler/publish.cljs new file mode 100644 index 0000000000..e5e4be3323 --- /dev/null +++ b/src/main/frontend/handler/publish.cljs @@ -0,0 +1,431 @@ +(ns frontend.handler.publish + "Prepare publish payloads for pages." + (:require [cljs-bean.core :as bean] + [clojure.string :as string] + [frontend.config :as config] + [frontend.db :as db] + [frontend.db.model :as db-model] + [frontend.fs :as fs] + [frontend.handler.notification :as notification] + [frontend.handler.property :as property-handler] + [frontend.handler.user :as user-handler] + [frontend.image :as image] + [frontend.state :as state] + [frontend.util :as util] + [logseq.common.path :as path] + [logseq.db :as ldb] + [promesa.core :as p])) + +(defn- > data + (map (fn [b] + (.padStart (.toString b 16) 2 "0"))) + (apply str)))) + +(defn- publish-endpoint + [] + (str config/PUBLISH-API-BASE "/pages")) + +(defn- publish-page-endpoint + [graph-uuid page-uuid] + (str config/PUBLISH-API-BASE "/pages/" graph-uuid "/" page-uuid)) + +(defn- asset-upload-endpoint + [] + (str config/PUBLISH-API-BASE "/assets")) + +(defn- asset-content-type + [ext] + (case (string/lower-case (or ext "")) + ("png") "image/png" + ("jpg" "jpeg") "image/jpeg" + ("gif") "image/gif" + ("webp") "image/webp" + ("svg") "image/svg+xml" + ("bmp") "image/bmp" + ("avif") "image/avif" + ("mp4") "video/mp4" + ("webm") "video/webm" + ("mov") "video/quicktime" + ("mp3") "audio/mpeg" + ("wav") "audio/wav" + ("ogg") "audio/ogg" + ("pdf") "application/pdf" + "application/octet-stream")) + +(def ^:private publish-image-variant-sizes + [1024 1600]) + +(def ^:private publish-image-quality + 0.9) + +(def ^:private publish-image-types + #{"png" "jpg" "jpeg" "webp"}) + +(def ^:private custom-publish-assets + [{:path (path/path-join "logseq" "publish.css") + :type "css" + :content-type "text/css; charset=utf-8" + :meta-key :custom_publish_css_hash + :asset-name "publish.css"} + {:path (path/path-join "logseq" "publish.js") + :type "js" + :content-type "text/javascript; charset=utf-8" + :meta-key :custom_publish_js_hash + :asset-name "publish.js"}]) + +(defn- image-asset? + [asset-type] + (contains? publish-image-types (string/lower-case (or asset-type "")))) + +(defn- asset-uuid-with-variant + [asset-uuid variant] + (if variant + (str asset-uuid "@" variant) + asset-uuid)) + +(defn- > data + (map (fn [b] + (.padStart (.toString b 16) 2 "0"))) + (apply str)))) + +(defn- blob + [canvas content-type quality] + (p/create + (fn [resolve _reject] + (.toBlob canvas + (fn [blob] + (resolve blob)) + content-type + quality)))) + +(defn- blob canvas content-type publish-image-quality)] + (when blob' + {:variant size + :blob blob'}))) + publish-image-variant-sizes) + variants (p/then (p/all variant-promises) + (fn [entries] + (->> entries (remove nil?) vec)))] + (when (seq variants) + (let [sorted (sort-by :variant variants) + largest (last sorted) + uploads (vec (concat [(assoc largest :variant nil)] sorted))] + (p/all + (map (fn [{:keys [variant blob]}] + (p/let [checksum ( {"content-type" content_type + "x-asset-meta" (js/JSON.stringify (clj->js meta))} + asset-token (assoc "authorization" (str "Bearer " asset-token)))] + (js/fetch (asset-upload-endpoint) + (clj->js {:method "POST" + :headers headers + :body blob})))) + +(defn- {"content-type" content-type + "x-asset-meta" (js/JSON.stringify (clj->js asset-meta))} + asset-token (assoc "authorization" (str "Bearer " asset-token)))] + (js/fetch (asset-upload-endpoint) + (clj->js {:method "POST" + :headers headers + :body content})))) + +(defn- merge-attr + [entity attr value] + (let [existing (get entity attr ::none)] + (cond + (= existing ::none) (assoc entity attr value) + (vector? existing) (assoc entity attr (conj existing value)) + (set? existing) (assoc entity attr (conj existing value)) + :else (assoc entity attr [existing value])))) + +(defn- datoms->entities + [datoms] + (reduce + (fn [acc datom] + (let [[e a v _tx added?] datom] + (if added? + (update acc e (fn [entity] + (merge-attr (or entity {:db/id e}) a v))) + acc))) + {} + datoms)) + +(defn- asset-entities-from-payload + [payload] + (let [entities (datoms->entities (:datoms payload))] + (->> entities + vals + (filter (fn [entity] + (and (:logseq.property.asset/type entity) + (:block/uuid entity))))))) + +(defn- (:block/uuid asset) str) + external-url (:logseq.property.asset/external-url asset) + token (state/get-auth-id-token)] + (if (or (not (string? asset-type)) (string/blank? asset-type) external-url (nil? asset-uuid)) + (p/resolved nil) + (p/let [repo-dir (config/get-repo-dir repo) + asset-path (path/path-join "assets" (str asset-uuid "." asset-type)) + content (fs/read-file-raw repo-dir asset-path {}) + content-type (asset-content-type asset-type)] + (if (image-asset? asset-type) + (p/let [blob (js/Blob. (array content) (clj->js {:type content-type})) + uploads ( {"content-type" "application/transit+json"} + token (assoc "authorization" (str "Bearer " token)))] + (p/let [page-password (some-> password string/trim) + page-password (when (and (string? page-password) + (not (string/blank? page-password))) + page-password) + payload (cond-> payload + page-password (assoc :page-password page-password)) + body (ldb/write-transit-str payload) + content-hash ( (ldb/get-graph-rtc-uuid (db/get-db)) str)) + _ (when-not graph-uuid + (throw (ex-info "Missing graph UUID" {:repo (state/get-current-repo)}))) + publish-meta {:graph graph-uuid + :page_uuid (str (:page-uuid payload)) + :block_count (:block-count payload) + :schema_version (:schema-version payload) + :format :transit + :compression :none + :content_hash content-hash + :content_length (count body) + :owner_sub (user-handler/user-uuid) + :owner_username (user-handler/username) + :created_at (util/time-ms)} + publish-meta (cond-> publish-meta + (get custom-assets :custom_publish_css_hash) + (assoc :custom_publish_css_hash (:custom_publish_css_hash custom-assets)) + (get custom-assets :custom_publish_js_hash) + (assoc :custom_publish_js_hash (:custom_publish_js_hash custom-assets))) + publish-body (assoc payload :meta publish-meta) + headers (assoc headers "x-publish-meta" (js/JSON.stringify (clj->js publish-meta))) + resp (js/fetch (publish-endpoint) + (clj->js {:method "POST" + :headers headers + :body (ldb/write-transit-str publish-body)}))] + (if (.-ok resp) + resp + (p/let [body (.text resp)] + (throw (ex-info "Publish failed" + {:status (.-status resp) + :body body}))))))) + +(defn publish-page! + "Prepares and uploads the publish payload for a page." + [page & [{:keys [password]}]] + (let [repo (state/get-current-repo)] + (when-let [db* (and repo (db/get-db repo))] + (if (and page (:db/id page)) + (p/let [graph-uuid (some-> (ldb/get-graph-rtc-uuid db*) str) + payload (state/ (p/let [_ (clj json)] + (let [short-url (:short_url data) + graph-uuid (or (:graph-uuid payload) + (some-> (ldb/get-graph-rtc-uuid db*) str)) + page-uuid (str (:block/uuid page)) + fallback-url (when (and graph-uuid page-uuid) + (str config/PUBLISH-API-BASE "/page/" graph-uuid "/" page-uuid)) + url (or (when short-url + (str config/PUBLISH-API-BASE short-url)) + fallback-url)] + (when (and url (:db/id page)) + (property-handler/set-block-property! (:db/id page) + :logseq.property.publish/published-url + url)) + (when url + (notification/show! + [:div.inline + [:span "Published to: "] + [:a {:target "_blank" + :href url} + url]] + :success + false)))))) + (p/catch (fn [error] + (js/console.error error) + (notification/show! "Publish failed." :error)))) + (notification/show! "Publish failed." :error))) + (notification/show! "Publish failed: invalid page." :error))))) + +(defn unpublish-page! + [page] + (let [token (state/get-auth-id-token) + headers (cond-> {} + token (assoc "authorization" (str "Bearer " token)))] + (p/let [graph-uuid (some-> (ldb/get-graph-rtc-uuid (db/get-db)) str) + page-uuid (some-> (:block/uuid page) str)] + (if (and graph-uuid page-uuid) + (-> (p/let [resp (js/fetch (publish-page-endpoint graph-uuid page-uuid) + (clj->js {:method "DELETE" + :headers headers}))] + (if (.-ok resp) + (do + (property-handler/remove-block-property! (:db/id page) + :logseq.property.publish/published-url) + (notification/show! "Unpublished." :success false)) + (p/let [body (.text resp)] + (throw (ex-info "Unpublish failed" + {:status (.-status resp) + :body body}))))) + (p/catch (fn [error] + (js/console.error error) + (notification/show! "Unpublish failed." :error)))) + (notification/show! "Unpublish failed: missing page id." :error))))) diff --git a/src/main/frontend/worker/db/migrate.cljs b/src/main/frontend/worker/db/migrate.cljs index 7b5e8088c0..d91580d988 100644 --- a/src/main/frontend/worker/db/migrate.cljs +++ b/src/main/frontend/worker/db/migrate.cljs @@ -8,6 +8,7 @@ [frontend.worker.db.rename-db-ident :as rename-db-ident] [logseq.common.config :as common-config] [logseq.common.util :as common-util] + [logseq.common.uuid :as common-uuid] [logseq.db :as ldb] [logseq.db.frontend.class :as db-class] [logseq.db.frontend.property :as db-property] @@ -163,6 +164,12 @@ (when (:logseq.property/ui-position e) [:db/retract (:e d) :logseq.property/ui-position])))))) +(defn- ensure-graph-uuid + [db] + (let [graph-uuid (:kv/value (d/entity db :logseq.kv/graph-uuid))] + (when-not graph-uuid + [(sqlite-util/kv :logseq.kv/graph-uuid (common-uuid/gen-uuid))]))) + (def schema-version->updates "A vec of tuples defining datascript migrations. Each tuple consists of the schema version integer and a migration map. A migration map can have keys of :properties, :classes @@ -179,7 +186,9 @@ ["65.15" (rename-properties {:logseq.property.asset/external-src :logseq.property.asset/external-url} {})] - ["65.16" {:properties [:logseq.property.asset/external-file-name]}]]) + ["65.16" {:properties [:logseq.property.asset/external-file-name]}] + ["65.17" {:properties [:logseq.property.publish/published-url]}] + ["65.18" {:fix ensure-graph-uuid}]]) (let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first) schema-version->updates)))] diff --git a/src/main/frontend/worker/db_worker.cljs b/src/main/frontend/worker/db_worker.cljs index ae43ca2f85..ae696c5db3 100644 --- a/src/main/frontend/worker/db_worker.cljs +++ b/src/main/frontend/worker/db_worker.cljs @@ -23,6 +23,7 @@ [frontend.worker.export :as worker-export] [frontend.worker.handler.page :as worker-page] [frontend.worker.pipeline :as worker-pipeline] + [frontend.worker.publish] [frontend.worker.rtc.asset-db-listener] [frontend.worker.rtc.client-op :as client-op] [frontend.worker.rtc.core :as rtc.core] diff --git a/src/main/frontend/worker/publish.cljs b/src/main/frontend/worker/publish.cljs new file mode 100644 index 0000000000..757c28000f --- /dev/null +++ b/src/main/frontend/worker/publish.cljs @@ -0,0 +1,231 @@ +(ns frontend.worker.publish + "Publish" + (:require [clojure.string :as string] + [datascript.core :as d] + [frontend.common.thread-api :refer [def-thread-api]] + [frontend.worker.state :as worker-state] + [logseq.common.util :as common-util] + [logseq.db :as ldb] + [logseq.db.common.entity-util :as common-entity-util] + [logseq.db.frontend.content :as db-content] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.schema :as db-schema])) + +(defn- publish-entity-title + [entity] + (or (:block/title entity) + "Untitled")) + +(defn- page-tags + [page-entity] + (let [tags (:block/tags page-entity)] + (->> tags + (remove (fn [tag] + (contains? #{:logseq.class/Page} (:db/ident tag)))) + (map (fn [tag] + {:tag_uuid (:block/uuid tag) + :tag_title (:block/title tag)}))))) + +(defn- publish-ref-eid [value] + (cond + (number? value) (when (pos? value) value) + (map? value) (let [eid (:db/id value)] + (when (and (number? eid) (pos? eid)) + eid)) + :else nil)) + +(defn- publish-refs-from-blocks + [db blocks page-entity graph-uuid] + (let [page-uuid (:block/uuid page-entity) + page-title (publish-entity-title page-entity) + page? (common-entity-util/page? page-entity) + graph-uuid (str graph-uuid)] + (mapcat (fn [block] + (let [block-uuid (:block/uuid block) + block-uuid-str (some-> block-uuid str)] + (when (and block-uuid-str + (or (not page?) + (not= block-uuid page-uuid))) + (let [block-content (or (:block/content block) + (:block/title block) + (:block/name block) + "") + block-format (name (or (:block/format block) :markdown)) + refs (:block/refs block) + refs (if (sequential? refs) refs (when refs [refs])) + targets (->> refs + (map publish-ref-eid) + (keep #(when % (d/entity db %))) + (keep :block/uuid) + (map str) + distinct)] + (when (seq targets) + (map (fn [target] + {:graph_uuid graph-uuid + :target_page_uuid target + :source_page_uuid (str page-uuid) + :source_page_title page-title + :source_block_uuid block-uuid-str + :source_block_content block-content + :source_block_format block-format + :updated_at (common-util/time-ms)}) + targets)))))) + blocks))) + +(defn- collect-publish-blocks + [db entity] + (if (common-entity-util/page? entity) + (:block/_page entity) + (ldb/get-block-and-children db (:block/uuid entity)))) + +(def ^:private publish-search-max-length 4096) + +(defn- block-page-eid + [block] + (let [page (:block/page block)] + (cond + (map? page) (:db/id page) + (number? page) page + :else nil))) + +(defn- block-search-content + [block] + (let [raw-content (or (:block/content block) + (:block/title block) + (:block/name block) + "") + raw-content (string/trim raw-content)] + (when-not (string/blank? raw-content) + (let [content (db-content/recur-replace-uuid-in-block-title + (assoc block :block/title raw-content)) + content (if (> (count content) publish-search-max-length) + (subs content 0 publish-search-max-length) + content)] + (string/trim content))))) + +(defn- collect-search-blocks + [blocks page-eid page-uuid] + (->> blocks + (keep (fn [block] + (when (and (= (block-page-eid block) page-eid) + (not= (:db/id block) page-eid) + (not (:logseq.property/created-from-property block))) + (when-let [block-uuid (some-> (:block/uuid block) str)] + (when-let [content (block-search-content block)] + {:page_uuid (str page-uuid) + :block_uuid block-uuid + :block_content content}))))))) + +(defn- collect-embedded-blocks + [db blocks] + (let [linked-eids (->> blocks + (map :block/link) + (map publish-ref-eid) + (remove nil?) + distinct)] + (loop [queue (vec linked-eids) + visited #{} + acc []] + (if (empty? queue) + acc + (let [eid (first queue) + queue (subvec queue 1)] + (if (contains? visited eid) + (recur queue visited acc) + (let [entity (d/entity db eid) + uuid (:block/uuid entity) + children (when uuid + (ldb/get-block-and-children db uuid)) + child-links (->> children + (map :block/link) + (map publish-ref-eid) + (remove nil?))] + (recur (into queue child-links) + (conj visited eid) + (into acc children))))))))) + +(defn- publish-collect-page-eids + [db entity] + (let [page-id (:db/id entity) + blocks (collect-publish-blocks db entity) + embedded-blocks (collect-embedded-blocks db blocks) + blocks (concat blocks embedded-blocks) + block-eids (map :db/id blocks) + ref-eids (->> blocks + (mapcat :block/refs) + (map publish-ref-eid) + (remove nil?)) + tag-eids (->> blocks + (mapcat :block/tags) + (map publish-ref-eid) + (remove nil?)) + page-tag-eids (->> (if-let [tags (:block/tags entity)] + (if (sequential? tags) tags [tags]) + []) + (map publish-ref-eid) + (remove nil?)) + page-eids (->> blocks (map :block/page) (keep :db/id)) + property-eids (->> (cons entity blocks) + (map db-property/properties) + (mapcat (fn [props] + (mapcat (fn [[k v]] + (let [property (d/entity db k) + pid (:db/id property) + ref-type? (= :db.type/ref (:db/valueType property)) + many? (= :db.cardinality/many (:db/cardinality property))] + (cons pid + (when ref-type? + (if many? + (map :db/id v) + (list (:db/id v))))))) + props))) + (remove nil?))] + {:blocks blocks + :eids (->> (concat [page-id] block-eids ref-eids tag-eids page-tag-eids page-eids property-eids) + (remove nil?) + distinct)})) + +(defn- normalize-block-publish-datoms + [datoms block-eids root-eid] + (map (fn [[e a v tx added]] + (if (and (contains? block-eids e) (= a :block/page)) + [e a root-eid tx added] + [e a v tx added])) + datoms)) + +(defn- build-publish-page-payload + [db entity graph-uuid] + (let [{:keys [blocks eids]} (publish-collect-page-eids db entity) + graph-uuid (or graph-uuid (ldb/get-graph-rtc-uuid db)) + refs (when graph-uuid + (publish-refs-from-blocks db blocks entity graph-uuid)) + tags (page-tags entity) + search-blocks (collect-search-blocks blocks (:db/id entity) (:block/uuid entity)) + raw-datoms (->> + (mapcat (fn [eid] + (map (fn [d] [(:e d) (:a d) (:v d) (:tx d) (:added d)]) + (d/datoms db :eavt eid))) + eids) + (remove (fn [[_e a _v _tx _added]] + (contains? #{:block/tx-id :logseq.property.user/email :logseq.property.embedding/hnsw-label-updated-at} a)))) + datoms (if (common-entity-util/page? entity) + raw-datoms + (normalize-block-publish-datoms raw-datoms (set (map :db/id blocks)) (:db/id entity)))] + {:page (common-entity-util/entity->map entity) + :page-uuid (:block/uuid entity) + :page-title (publish-entity-title entity) + :graph-uuid (some-> graph-uuid str) + :block-count (count blocks) + :schema-version (db-schema/schema-version->string db-schema/version) + :refs refs + :page-tags tags + :blocks search-blocks + :datoms datoms})) + +(def-thread-api :thread-api/build-publish-page-payload + [repo eid graph-uuid] + (when-let [conn (worker-state/get-datascript-conn repo)] + (let [db @conn + page-entity (d/entity db eid)] + (when (and page-entity (:db/id page-entity)) + (build-publish-page-payload db page-entity graph-uuid))))) diff --git a/src/main/frontend/worker/rtc/full_upload_download_graph.cljs b/src/main/frontend/worker/rtc/full_upload_download_graph.cljs index 5ef7ff4803..24aaf4a0cf 100644 --- a/src/main/frontend/worker/rtc/full_upload_download_graph.cljs +++ b/src/main/frontend/worker/rtc/full_upload_download_graph.cljs @@ -191,6 +191,7 @@ :graph-name remote-graph-name :encrypted-aes-key (ldb/write-transit-str encrypted-aes-key)}))] + ;; FIXME: use local graph uuid instead of creating new one (if-let [graph-uuid (:graph-uuid upload-resp)] (let [schema-version (ldb/get-graph-schema-version @conn)] (ldb/transact! conn diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index 0007dfa5f8..e7ade6a325 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -247,6 +247,10 @@ :flashcards/modal-welcome-title "Time to create a card!" :flashcards/modal-welcome-desc-1 "You can add \"{1}\" to any block to turn it into a card or trigger \"/cloze\" to add some clozes." :flashcards/modal-finished "Congrats, you've reviewed all the cards for this query, see you next time! 💯" + :flashcards/modal-btn-show-answers "Show answers" + :flashcards/modal-btn-hide-answers "Hide answers" + :flashcards/modal-btn-show-clozes "Show clozes" + :home "Home" :new-page "New page:" :new-tag "New tag:" diff --git a/src/test/frontend/worker/publish_test.cljs b/src/test/frontend/worker/publish_test.cljs new file mode 100644 index 0000000000..081614beec --- /dev/null +++ b/src/test/frontend/worker/publish_test.cljs @@ -0,0 +1,70 @@ +(ns frontend.worker.publish-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [frontend.worker.publish :as worker-publish] + [logseq.db.test.helper :as db-test])) + +(deftest publish-payload-includes-embedded-blocks + (testing "embedded blocks and their children are included in publish payload" + (let [target-uuid (random-uuid) + child-uuid (random-uuid) + embed-uuid (random-uuid) + conn (db-test/create-conn-with-blocks + [{:page {:block/title "Page A"} + :blocks [{:block/title "Embed" + :block/uuid embed-uuid + :build/keep-uuid? true}]} + {:page {:block/title "Page B"} + :blocks [{:block/title "Target" + :block/uuid target-uuid + :build/keep-uuid? true + :build/children [{:block/title "Child" + :block/uuid child-uuid + :build/keep-uuid? true}]}]}]) + db @conn + embed-eid (:db/id (d/entity db [:block/uuid embed-uuid])) + target-eid (:db/id (d/entity db [:block/uuid target-uuid])) + _ (d/transact! conn [{:db/id embed-eid :block/link target-eid}]) + db @conn + page-a (db-test/find-page-by-title db "Page A") + payload (#'worker-publish/build-publish-page-payload db page-a nil) + datom-eids (->> (:datoms payload) (map first) set) + child-eid (:db/id (d/entity db [:block/uuid child-uuid]))] + (is (contains? datom-eids target-eid)) + (is (contains? datom-eids child-eid))))) + +(deftest publish-payload-traverses-nested-embeds + (testing "embedded blocks can include linked blocks that also embed others" + (let [first-uuid (random-uuid) + second-uuid (random-uuid) + embed-uuid (random-uuid) + conn (db-test/create-conn-with-blocks + [{:page {:block/title "Root Page"} + :blocks [{:block/title "Embed" + :block/uuid embed-uuid + :build/keep-uuid? true}]} + {:page {:block/title "First Page"} + :blocks [{:block/title "First" + :block/uuid first-uuid + :build/keep-uuid? true + :build/children [{:block/title "First child" + :build/keep-uuid? true}]}]} + {:page {:block/title "Second Page"} + :blocks [{:block/title "Second" + :block/uuid second-uuid + :build/keep-uuid? true}]}]) + db @conn + embed-eid (:db/id (d/entity db [:block/uuid embed-uuid])) + first-eid (:db/id (d/entity db [:block/uuid first-uuid])) + second-eid (:db/id (d/entity db [:block/uuid second-uuid])) + first-child (db-test/find-block-by-content db "First child") + _ (d/transact! conn [{:db/id embed-eid :block/link first-eid} + {:db/id (:db/id first-child) :block/link second-eid}]) + db @conn + root-page (db-test/find-page-by-title db "Root Page") + payload (#'worker-publish/build-publish-page-payload db root-page nil) + datom-eids (->> (:datoms payload) (map first) set) + first-eid (:db/id (d/entity db [:block/uuid first-uuid])) + second-eid (:db/id (d/entity db [:block/uuid second-uuid]))] + (is (contains? datom-eids first-eid)) + (is (contains? datom-eids second-eid)))))