mirror of
https://github.com/logseq/logseq.git
synced 2026-02-01 22:47:36 +00:00
Merge pull request #12279 from logseq/feat/page-publish
feat: page publish
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
5
deps.edn
5
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"}
|
||||
|
||||
24
deps/db/src/logseq/db/common/entity_plus.cljc
vendored
24
deps/db/src/logseq/db/common/entity_plus.cljc
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
8
deps/db/src/logseq/db/frontend/property.cljs
vendored
8
deps/db/src/logseq/db/frontend/property.cljs
vendored
@@ -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"
|
||||
|
||||
2
deps/db/src/logseq/db/frontend/schema.cljs
vendored
2
deps/db/src/logseq/db/frontend/schema.cljs
vendored
@@ -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.
|
||||
|
||||
14
deps/db/src/logseq/db/sqlite/create_graph.cljs
vendored
14
deps/db/src/logseq/db/sqlite/create_graph.cljs
vendored
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
22
deps/publish/README.md
vendored
Normal file
22
deps/publish/README.md
vendored
Normal file
@@ -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.
|
||||
27
deps/publish/deps.edn
vendored
Normal file
27
deps/publish/deps.edn
vendored
Normal file
@@ -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"]}}}
|
||||
16
deps/publish/package.json
vendored
Normal file
16
deps/publish/package.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
17
deps/publish/scripts/bump-publish-version.js
vendored
Normal file
17
deps/publish/scripts/bump-publish-version.js
vendored
Normal file
@@ -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);
|
||||
12
deps/publish/shadow-cljs.edn
vendored
Normal file
12
deps/publish/shadow-cljs.edn
vendored
Normal file
@@ -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}}}}
|
||||
66
deps/publish/src/logseq/publish/assets.cljs
vendored
Normal file
66
deps/publish/src/logseq/publish/assets.cljs
vendored
Normal file
@@ -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)})))))))
|
||||
11
deps/publish/src/logseq/publish/async.clj
vendored
Normal file
11
deps/publish/src/logseq/publish/async.clj
vendored
Normal file
@@ -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))))
|
||||
332
deps/publish/src/logseq/publish/common.cljs
vendored
Normal file
332
deps/publish/src/logseq/publish/common.cljs
vendored
Normal file
@@ -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))))
|
||||
94
deps/publish/src/logseq/publish/index.cljs
vendored
Normal file
94
deps/publish/src/logseq/publish/index.cljs
vendored
Normal file
@@ -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)))))
|
||||
457
deps/publish/src/logseq/publish/meta_store.cljs
vendored
Normal file
457
deps/publish/src/logseq/publish/meta_store.cljs
vendored
Normal file
@@ -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))))
|
||||
41
deps/publish/src/logseq/publish/model.cljs
vendored
Normal file
41
deps/publish/src/logseq/publish/model.cljs
vendored
Normal file
@@ -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))
|
||||
945
deps/publish/src/logseq/publish/publish.css
vendored
Normal file
945
deps/publish/src/logseq/publish/publish.css
vendored
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
712
deps/publish/src/logseq/publish/publish.js
vendored
Normal file
712
deps/publish/src/logseq/publish/publish.js
vendored
Normal file
@@ -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();
|
||||
}
|
||||
1597
deps/publish/src/logseq/publish/render.cljs
vendored
Normal file
1597
deps/publish/src/logseq/publish/render.cljs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
773
deps/publish/src/logseq/publish/routes.cljs
vendored
Normal file
773
deps/publish/src/logseq/publish/routes.cljs
vendored
Normal file
@@ -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))}))))
|
||||
22
deps/publish/src/logseq/publish/worker.cljs
vendored
Normal file
22
deps/publish/src/logseq/publish/worker.cljs
vendored
Normal file
@@ -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)))
|
||||
46
deps/publish/worker/README.md
vendored
Normal file
46
deps/publish/worker/README.md
vendored
Normal file
@@ -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 <JWT>`
|
||||
- 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.
|
||||
12
deps/publish/worker/scripts/clear_dev_state.sh
vendored
Executable file
12
deps/publish/worker/scripts/clear_dev_state.sh
vendored
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
GRAPH_UUID=${GRAPH_UUID:-"00000000-0000-0000-0000-000000000000"}
|
||||
|
||||
cat <<MSG
|
||||
To clear local Durable Object state, remove the miniflare state directory:
|
||||
rm -rf .wrangler/state/v3/durable-objects/${GRAPH_UUID}
|
||||
|
||||
If your dev environment uses a different state path, locate it under:
|
||||
.wrangler/state/v3/
|
||||
MSG
|
||||
36
deps/publish/worker/scripts/dev_test.sh
vendored
Executable file
36
deps/publish/worker/scripts/dev_test.sh
vendored
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL=${BASE_URL:-"http://127.0.0.1:8787"}
|
||||
GRAPH_UUID=${GRAPH_UUID:-"00000000-0000-0000-0000-000000000000"}
|
||||
PAGE_UUID=${PAGE_UUID:-"00000000-0000-0000-0000-000000000001"}
|
||||
|
||||
META=$(cat <<JSON
|
||||
{"page-uuid":"${PAGE_UUID}","block-count":1,"schema-version":"0","publish/format":"transit","publish/compression":"none","publish/content-hash":"dev","publish/content-length":1,"publish/graph":"${GRAPH_UUID}","publish/created-at":0}
|
||||
JSON
|
||||
)
|
||||
|
||||
PAYLOAD="{}"
|
||||
|
||||
curl -sS -X POST "${BASE_URL}/pages" \
|
||||
-H "content-type: application/transit+json" \
|
||||
-H "x-publish-meta: ${META}" \
|
||||
--data-binary "${PAYLOAD}"
|
||||
|
||||
echo
|
||||
|
||||
curl -sS "${BASE_URL}/pages/${GRAPH_UUID}/${PAGE_UUID}"
|
||||
|
||||
echo
|
||||
|
||||
curl -sS "${BASE_URL}/pages/${GRAPH_UUID}/${PAGE_UUID}/transit"
|
||||
|
||||
echo
|
||||
|
||||
curl -sS "${BASE_URL}/pages"
|
||||
|
||||
echo
|
||||
|
||||
curl -sS "${BASE_URL}/p/${GRAPH_UUID}/${PAGE_UUID}"
|
||||
|
||||
echo
|
||||
71
deps/publish/worker/wrangler.toml
vendored
Normal file
71
deps/publish/worker/wrangler.toml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name = "logseq-publish"
|
||||
main = "dist/worker/main.js"
|
||||
compatibility_date = "2025-02-04"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
# Workers Logs
|
||||
# Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/
|
||||
# Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs
|
||||
[observability]
|
||||
enabled = true
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "PUBLISH_META_DO"
|
||||
class_name = "PublishMetaDO"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v2"
|
||||
new_sqlite_classes = ["PublishMetaDO"]
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "PUBLISH_R2"
|
||||
bucket_name = "logseq-publish-dev"
|
||||
|
||||
[vars]
|
||||
COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json"
|
||||
COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
|
||||
COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
|
||||
|
||||
[env.staging]
|
||||
name = "logseq-publish-staging"
|
||||
|
||||
[env.staging.vars]
|
||||
COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json"
|
||||
COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
|
||||
COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
|
||||
|
||||
[[env.staging.durable_objects.bindings]]
|
||||
name = "PUBLISH_META_DO"
|
||||
class_name = "PublishMetaDO"
|
||||
|
||||
[[env.staging.migrations]]
|
||||
tag = "v2"
|
||||
new_sqlite_classes = ["PublishMetaDO"]
|
||||
|
||||
[[env.staging.r2_buckets]]
|
||||
binding = "PUBLISH_R2"
|
||||
bucket_name = "logseq-publish-dev"
|
||||
|
||||
[env.prod]
|
||||
name = "logseq-publish-prod"
|
||||
|
||||
[env.prod.vars]
|
||||
COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json"
|
||||
COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
|
||||
COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
|
||||
|
||||
[[env.prod.durable_objects.bindings]]
|
||||
name = "PUBLISH_META_DO"
|
||||
class_name = "PublishMetaDO"
|
||||
|
||||
[[env.prod.migrations]]
|
||||
tag = "v2"
|
||||
new_sqlite_classes = ["PublishMetaDO"]
|
||||
|
||||
[[env.prod.r2_buckets]]
|
||||
binding = "PUBLISH_R2"
|
||||
bucket_name = "logseq-publish-prod"
|
||||
|
||||
[[env.prod.routes]]
|
||||
pattern = "logseq.io"
|
||||
custom_domain = true
|
||||
84
deps/publish/yarn.lock
vendored
Normal file
84
deps/publish/yarn.lock
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
base64-js@^1.3.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
buffer-from@^1.0.0:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
|
||||
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
|
||||
|
||||
buffer@^6.0.3:
|
||||
version "6.0.3"
|
||||
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
|
||||
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
|
||||
dependencies:
|
||||
base64-js "^1.3.1"
|
||||
ieee754 "^1.2.1"
|
||||
|
||||
ieee754@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
|
||||
|
||||
isexe@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d"
|
||||
integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==
|
||||
|
||||
process@^0.11.10:
|
||||
version "0.11.10"
|
||||
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
||||
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
|
||||
|
||||
readline-sync@^1.4.10:
|
||||
version "1.4.10"
|
||||
resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.10.tgz#41df7fbb4b6312d673011594145705bf56d8873b"
|
||||
integrity sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==
|
||||
|
||||
shadow-cljs-jar@1.3.4:
|
||||
version "1.3.4"
|
||||
resolved "https://registry.yarnpkg.com/shadow-cljs-jar/-/shadow-cljs-jar-1.3.4.tgz#0939d91c468b4bc5eab5a958f79e7ef5696fdf62"
|
||||
integrity sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA==
|
||||
|
||||
shadow-cljs@^3.3.4:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/shadow-cljs/-/shadow-cljs-3.3.4.tgz#d1593c1ad4eee1ed34f57aa68cdfc5caaf5696d9"
|
||||
integrity sha512-xZV+Ek5TeQtqcY++Otpto5DW+gXu/znIJjtTZjhfQl1yYxnfQNSyC2pS9/XoI3kmmQza3oY5WA0b45gS7W7W5g==
|
||||
dependencies:
|
||||
buffer "^6.0.3"
|
||||
process "^0.11.10"
|
||||
readline-sync "^1.4.10"
|
||||
shadow-cljs-jar "1.3.4"
|
||||
source-map-support "^0.5.21"
|
||||
which "^5.0.0"
|
||||
ws "^8.18.1"
|
||||
|
||||
source-map-support@^0.5.21:
|
||||
version "0.5.21"
|
||||
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
|
||||
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
|
||||
dependencies:
|
||||
buffer-from "^1.0.0"
|
||||
source-map "^0.6.0"
|
||||
|
||||
source-map@^0.6.0:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
||||
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
||||
|
||||
which@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/which/-/which-5.0.0.tgz#d93f2d93f79834d4363c7d0c23e00d07c466c8d6"
|
||||
integrity sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==
|
||||
dependencies:
|
||||
isexe "^3.1.1"
|
||||
|
||||
ws@^8.18.1:
|
||||
version "8.18.3"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
|
||||
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
|
||||
@@ -7,5 +7,7 @@
|
||||
;; for config.edn
|
||||
logseq/common
|
||||
{:local/root "../deps/common"}
|
||||
logseq/publish
|
||||
{:local/root "../deps/publish"}
|
||||
logseq/publishing
|
||||
{:local/root "../deps/publishing"}}}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
(defn kondo-git-changes
|
||||
"Run clj-kondo across dirs and only for files that git diff detects as unstaged changes"
|
||||
[]
|
||||
(let [kondo-dirs ["src" "deps/common" "deps/db" "deps/graph-parser" "deps/outliner" "deps/publishing" "deps/cli"]
|
||||
(let [kondo-dirs ["src" "deps/common" "deps/db" "deps/graph-parser" "deps/outliner" "deps/publish" "deps/publishing" "deps/cli"]
|
||||
dir-regex (re-pattern (str "^(" (string/join "|" kondo-dirs) ")"))
|
||||
dir-to-files (->> (shell {:out :string} "git diff --name-only")
|
||||
:out
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
;; shadow-cljs configuration
|
||||
{:deps true
|
||||
:nrepl {:port 8701}
|
||||
:source-paths ["src/main" "src/electron" "src/resources"]
|
||||
|
||||
;; :ssl {:password "logseq"}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -133,7 +133,10 @@
|
||||
(fn []
|
||||
(if favorited?
|
||||
(page-handler/<unfavorite-page! block-id-str)
|
||||
(page-handler/<favorite-page! block-id-str)))}}])))
|
||||
(page-handler/<favorite-page! block-id-str)))}}
|
||||
{:title "Publish page"
|
||||
:options {:on-click #(shui/dialog-open! (fn [] (page-menu/publish-page-dialog page))
|
||||
{:class "w-auto max-w-md"})}}])))
|
||||
page-menu-and-hr (concat page-menu [{:hr true}])
|
||||
login? (and (state/sub :auth/id-token) (user-handler/logged-in?))
|
||||
items (fn []
|
||||
|
||||
@@ -9,14 +9,49 @@
|
||||
[frontend.handler.db-based.page :as db-page-handler]
|
||||
[frontend.handler.notification :as notification]
|
||||
[frontend.handler.page :as page-handler]
|
||||
[frontend.handler.publish :as publish-handler]
|
||||
[frontend.mobile.util :as mobile-util]
|
||||
[frontend.state :as state]
|
||||
[frontend.util :as util]
|
||||
[frontend.util.page :as page-util]
|
||||
[logseq.common.path :as path]
|
||||
[logseq.db :as ldb]
|
||||
[logseq.shui.hooks :as hooks]
|
||||
[logseq.shui.ui :as shui]
|
||||
[promesa.core :as p]))
|
||||
[promesa.core :as p]
|
||||
[rum.core :as rum]))
|
||||
|
||||
(rum/defc publish-page-dialog
|
||||
[page]
|
||||
(let [[password set-password!] (hooks/use-state "")
|
||||
[publishing? set-publishing!] (hooks/use-state false)
|
||||
submit! (fn []
|
||||
(when-not publishing?
|
||||
(set-publishing! true)
|
||||
(-> (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
|
||||
|
||||
@@ -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 #(<create-new-block! block property "")}
|
||||
"Set default value"]
|
||||
|
||||
(= (:db/ident property) :logseq.property.publish/published-url)
|
||||
[:div.flex.items-center.gap-2.w-full
|
||||
[:a {:href (:block/title value)
|
||||
:target "_blank"}
|
||||
(:block/title value)]
|
||||
|
||||
(when-not config/publishing?
|
||||
(shui/button
|
||||
{:variant :text
|
||||
:size :sm
|
||||
:class "text-xs"
|
||||
:on-click (fn [e]
|
||||
(util/stop e)
|
||||
(publish-handler/unpublish-page! block))}
|
||||
"Unpublish"))]
|
||||
|
||||
text-ref-type?
|
||||
(property-block-value value block property page-cp opts)
|
||||
|
||||
:else
|
||||
(let [content (inline-text {} :markdown (macro-util/expand-value-if-macro (str value) (state/get-macros)))]
|
||||
(if (contains? (set (keys string-value-on-click))
|
||||
(:db/ident property))
|
||||
(cond
|
||||
(contains? (set (keys string-value-on-click))
|
||||
(:db/ident property))
|
||||
[:div.w-full {:on-click (fn []
|
||||
(let [f (get string-value-on-click (:db/ident property))]
|
||||
(f block property)))}
|
||||
content]
|
||||
|
||||
:else
|
||||
content)))]))
|
||||
|
||||
(rum/defc single-number-input
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
;; when it launches (when pro plan launches) it should be removed
|
||||
(def ENABLE-SETTINGS-ACCOUNT-TAB false)
|
||||
|
||||
;; (def PUBLISH-API-BASE "http://localhost:8787")
|
||||
|
||||
(if ENABLE-FILE-SYNC-PRODUCTION
|
||||
(do (def LOGIN-URL
|
||||
"https://logseq-prod.auth.us-east-1.amazoncognito.com/login?client_id=3c7np6bjtb4r1k1bi9i049ops5&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
|
||||
@@ -34,7 +36,8 @@
|
||||
(def REGION "us-east-1")
|
||||
(def USER-POOL-ID "us-east-1_dtagLnju8")
|
||||
(def IDENTITY-POOL-ID "us-east-1:d6d3b034-1631-402b-b838-b44513e93ee0")
|
||||
(def OAUTH-DOMAIN "logseq-prod.auth.us-east-1.amazoncognito.com"))
|
||||
(def OAUTH-DOMAIN "logseq-prod.auth.us-east-1.amazoncognito.com")
|
||||
(def PUBLISH-API-BASE "https://logseq.io"))
|
||||
|
||||
(do (def LOGIN-URL
|
||||
"https://logseq-test2.auth.us-east-2.amazoncognito.com/login?client_id=3ji1a0059hspovjq5fhed3uil8&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
|
||||
@@ -44,7 +47,8 @@
|
||||
(def REGION "us-east-2")
|
||||
(def USER-POOL-ID "us-east-2_kAqZcxIeM")
|
||||
(def IDENTITY-POOL-ID "us-east-2:cc7d2ad3-84d0-4faf-98fe-628f6b52c0a5")
|
||||
(def OAUTH-DOMAIN "logseq-test2.auth.us-east-2.amazoncognito.com")))
|
||||
(def OAUTH-DOMAIN "logseq-test2.auth.us-east-2.amazoncognito.com")
|
||||
(def PUBLISH-API-BASE "https://logseq-publish-staging.logseq.workers.dev")))
|
||||
|
||||
(goog-define ENABLE-RTC-SYNC-PRODUCTION false)
|
||||
(if ENABLE-RTC-SYNC-PRODUCTION
|
||||
|
||||
@@ -215,16 +215,15 @@
|
||||
(component-block/blocks-container option [block-entity]))
|
||||
[:div.mt-8.pb-2
|
||||
(if (contains? #{:show-cloze :show-answer} next-phase)
|
||||
(btn-with-shortcut {:btn-text (t
|
||||
(case next-phase
|
||||
:show-answer
|
||||
:flashcards/modal-btn-show-answers
|
||||
:show-cloze
|
||||
:flashcards/modal-btn-show-clozes
|
||||
:init
|
||||
:flashcards/modal-btn-hide-answers))
|
||||
(btn-with-shortcut {:btn-text (case next-phase
|
||||
:show-answer
|
||||
(t :flashcards/modal-btn-show-answers)
|
||||
:show-cloze
|
||||
(t :flashcards/modal-btn-show-clozes)
|
||||
:init
|
||||
(t :flashcards/modal-btn-hide-answers))
|
||||
:shortcut "s"
|
||||
:id (str "card-answers")
|
||||
:id "card-answers"
|
||||
:on-click #(swap! *phase
|
||||
(fn [phase]
|
||||
(phase->next-phase block-entity phase)))})
|
||||
|
||||
431
src/main/frontend/handler/publish.cljs
Normal file
431
src/main/frontend/handler/publish.cljs
Normal file
@@ -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- <sha256-hex
|
||||
[text]
|
||||
(p/let [encoder (js/TextEncoder.)
|
||||
data (.encode encoder text)
|
||||
digest (.digest (.-subtle js/crypto) "SHA-256" data)
|
||||
data (js/Uint8Array. digest)]
|
||||
(->> 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- <sha256-hex-buffer
|
||||
[array-buffer]
|
||||
(p/let [digest (.digest (.-subtle js/crypto) "SHA-256" array-buffer)
|
||||
data (js/Uint8Array. digest)]
|
||||
(->> data
|
||||
(map (fn [b]
|
||||
(.padStart (.toString b 16) 2 "0")))
|
||||
(apply str))))
|
||||
|
||||
(defn- <blob-checksum
|
||||
[blob]
|
||||
(p/let [buffer (.arrayBuffer blob)]
|
||||
(<sha256-hex-buffer buffer)))
|
||||
|
||||
(defn- <canvas->blob
|
||||
[canvas content-type quality]
|
||||
(p/create
|
||||
(fn [resolve _reject]
|
||||
(.toBlob canvas
|
||||
(fn [blob]
|
||||
(resolve blob))
|
||||
content-type
|
||||
quality))))
|
||||
|
||||
(defn- <canvas-from-blob
|
||||
[blob max-dim]
|
||||
(if (exists? js/createImageBitmap)
|
||||
(p/let [bitmap (js/createImageBitmap blob #js {:imageOrientation "from-image"})
|
||||
width (.-width bitmap)
|
||||
height (.-height bitmap)
|
||||
scale (min 1 (/ max-dim (max width height)))
|
||||
target-width (js/Math.round (* width scale))
|
||||
target-height (js/Math.round (* height scale))
|
||||
canvas (js/document.createElement "canvas")
|
||||
ctx ^js (.getContext canvas "2d")]
|
||||
(set! (.-width canvas) target-width)
|
||||
(set! (.-height canvas) target-height)
|
||||
(set! (.-imageSmoothingEnabled ctx) true)
|
||||
(set! (.-imageSmoothingQuality ctx) "high")
|
||||
(.drawImage ctx bitmap 0 0 target-width target-height)
|
||||
(when (.-close bitmap)
|
||||
(.close bitmap))
|
||||
canvas)
|
||||
(p/create
|
||||
(fn [resolve reject]
|
||||
(let [img (js/Image.)
|
||||
url (js/URL.createObjectURL blob)]
|
||||
(set! (.-onload img)
|
||||
(fn []
|
||||
(image/get-orientation img
|
||||
(fn [canvas]
|
||||
(js/URL.revokeObjectURL url)
|
||||
(resolve canvas))
|
||||
max-dim
|
||||
max-dim)))
|
||||
(set! (.-onerror img)
|
||||
(fn [error]
|
||||
(js/URL.revokeObjectURL url)
|
||||
(reject error)))
|
||||
(set! (.-src img) url))))))
|
||||
|
||||
(defn- <build-image-uploads
|
||||
[asset-uuid asset-type title blob content-type]
|
||||
(p/let [variant-promises (map (fn [size]
|
||||
(p/let [canvas (<canvas-from-blob blob size)
|
||||
blob' (<canvas->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 (<blob-checksum blob)]
|
||||
{:asset_uuid (asset-uuid-with-variant asset-uuid variant)
|
||||
:asset_type asset-type
|
||||
:content_type content-type
|
||||
:checksum checksum
|
||||
:size (.-size blob)
|
||||
:title title
|
||||
:blob blob}))
|
||||
uploads))))))
|
||||
|
||||
(defn- <upload-blob-asset!
|
||||
[graph-uuid asset-token {:keys [asset_uuid asset_type checksum size title content_type blob]}]
|
||||
(let [meta {:graph graph-uuid
|
||||
:asset_uuid asset_uuid
|
||||
:asset_type asset_type
|
||||
:checksum checksum
|
||||
:size size
|
||||
:title title
|
||||
:content_type content_type}
|
||||
headers (cond-> {"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- <upload-raw-asset!
|
||||
[asset-token asset-meta content-type content]
|
||||
(let [headers (cond-> {"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- <upload-asset!
|
||||
[repo graph-uuid asset]
|
||||
(let [asset-type (:logseq.property.asset/type asset)
|
||||
asset-uuid (some-> (: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 (<build-image-uploads asset-uuid asset-type (:block/title asset) blob content-type)]
|
||||
(if (seq uploads)
|
||||
(p/let [responses (p/all (map (fn [upload]
|
||||
(<upload-blob-asset! graph-uuid token upload))
|
||||
uploads))]
|
||||
(doseq [resp responses]
|
||||
(when-not (.-ok resp)
|
||||
(js/console.warn "Asset publish failed" {:asset asset-uuid :status (.-status resp)})))
|
||||
(last responses))
|
||||
(p/let [meta {:graph graph-uuid
|
||||
:asset_uuid asset-uuid
|
||||
:asset_type asset-type
|
||||
:checksum (:logseq.property.asset/checksum asset)
|
||||
:size (:logseq.property.asset/size asset)
|
||||
:title (:block/title asset)}
|
||||
resp (<upload-raw-asset! token meta content-type content)]
|
||||
(when-not (.-ok resp)
|
||||
(js/console.warn "Asset publish failed" {:asset asset-uuid :status (.-status resp)}))
|
||||
resp)))
|
||||
(p/let [meta {:graph graph-uuid
|
||||
:asset_uuid asset-uuid
|
||||
:asset_type asset-type
|
||||
:checksum (:logseq.property.asset/checksum asset)
|
||||
:size (:logseq.property.asset/size asset)
|
||||
:title (:block/title asset)}
|
||||
resp (<upload-raw-asset! token meta content-type content)]
|
||||
(when-not (.-ok resp)
|
||||
(js/console.warn "Asset publish failed" {:asset asset-uuid :status (.-status resp)}))
|
||||
resp))))))
|
||||
|
||||
(defn- <upload-assets!
|
||||
[repo graph-uuid payload]
|
||||
(let [assets (asset-entities-from-payload payload)]
|
||||
(when (seq assets)
|
||||
(p/all (map (fn [asset]
|
||||
(p/catch (<upload-asset! repo graph-uuid asset)
|
||||
(fn [error]
|
||||
(js/console.warn "Asset publish error" error))))
|
||||
assets)))))
|
||||
|
||||
(defn- <upload-custom-publish-assets!
|
||||
[repo graph-uuid]
|
||||
(let [token (state/get-auth-id-token)
|
||||
asset-uuid "publish"]
|
||||
(p/let [results (p/all
|
||||
(map (fn [{:keys [path type content-type meta-key asset-name]}]
|
||||
(p/let [content (db-model/get-file repo path)]
|
||||
(when (and (string? content) (not (string/blank? content)))
|
||||
(p/let [checksum (<sha256-hex content)
|
||||
meta {:graph graph-uuid
|
||||
:asset_uuid asset-uuid
|
||||
:asset_type type
|
||||
:content_type content-type
|
||||
:checksum checksum
|
||||
:title asset-name}
|
||||
resp (<upload-raw-asset! token meta content-type content)]
|
||||
(when-not (.-ok resp)
|
||||
(js/console.warn "Custom publish asset upload failed"
|
||||
{:path path :status (.-status resp)}))
|
||||
{meta-key checksum}))))
|
||||
custom-publish-assets))]
|
||||
(apply merge (remove nil? results)))))
|
||||
|
||||
(defn- <post-publish!
|
||||
[payload {:keys [password custom-assets]}]
|
||||
(let [token (state/get-auth-id-token)
|
||||
headers (cond-> {"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 (<sha256-hex body)
|
||||
graph-uuid (or (:graph-uuid payload)
|
||||
(some-> (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/<invoke-db-worker :thread-api/build-publish-page-payload
|
||||
repo
|
||||
(:db/id page)
|
||||
graph-uuid)]
|
||||
(if payload
|
||||
(-> (p/let [_ (<upload-assets! repo graph-uuid payload)
|
||||
custom-assets (<upload-custom-publish-assets! repo graph-uuid)]
|
||||
(<post-publish! payload {:password password
|
||||
:custom-assets custom-assets}))
|
||||
(p/then (fn [resp]
|
||||
(p/let [json (.json resp)
|
||||
data (bean/->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)))))
|
||||
@@ -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)))]
|
||||
|
||||
@@ -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]
|
||||
|
||||
231
src/main/frontend/worker/publish.cljs
Normal file
231
src/main/frontend/worker/publish.cljs
Normal file
@@ -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)))))
|
||||
@@ -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
|
||||
|
||||
@@ -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:"
|
||||
|
||||
70
src/test/frontend/worker/publish_test.cljs
Normal file
70
src/test/frontend/worker/publish_test.cljs
Normal file
@@ -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)))))
|
||||
Reference in New Issue
Block a user