diff --git a/deps/common/.carve/config.edn b/deps/common/.carve/config.edn index bdd5a7cab0..9e9674b333 100644 --- a/deps/common/.carve/config.edn +++ b/deps/common/.carve/config.edn @@ -11,5 +11,6 @@ logseq.common.cognito-config logseq.common.config logseq.common.defkeywords - logseq.common.graph-dir] + logseq.common.graph-dir + logseq.common.graph-registry] :report {:format :ignore}} diff --git a/deps/common/src/logseq/common/graph_registry.cljc b/deps/common/src/logseq/common/graph_registry.cljc new file mode 100644 index 0000000000..882e00f5e4 --- /dev/null +++ b/deps/common/src/logseq/common/graph_registry.cljc @@ -0,0 +1,72 @@ +(ns logseq.common.graph-registry + "Graph registry normalization and lookup helpers." + (:require [clojure.string :as string])) + +(def ^:private db-version-prefix + "logseq_db_") + +(defn- present-string? + [s] + (and (string? s) (not (string/blank? s)))) + +(defn normalize-entry + [entry] + (let [local-graph-id (:local-graph-id entry) + graph-id (or (when (present-string? (:graph-id entry)) (:graph-id entry)) + (when (present-string? local-graph-id) local-graph-id))] + (when-not graph-id + (throw (ex-info "Missing graph identity" + {:entry entry}))) + (-> entry + (assoc :graph-id graph-id) + (dissoc :rtc-graph-id)))) + +(defn ^:api upsert-entry + [registry entry] + (let [entry' (normalize-entry entry)] + (->> (or registry []) + (remove #(or (= (:graph-id entry') (:graph-id %)) + (and (present-string? (:repo entry')) + (= (:repo entry') (:repo %))) + (and (present-string? (:local-graph-id entry')) + (= (:local-graph-id entry') (:local-graph-id %))))) + (cons entry') + vec))) + +(defn- normalize-comparable + [s] + (some-> s str string/lower-case)) + +(defn- canonical-repo + [s] + (when (seq s) + (let [s (str s) + stripped (loop [name' s] + (if (string/starts-with? name' db-version-prefix) + (recur (subs name' (count db-version-prefix))) + name'))] + (str db-version-prefix stripped)))) + +(defn- identifier-match? + [entry graph-identifier] + (let [identifier (normalize-comparable graph-identifier) + repo (normalize-comparable (:repo entry)) + graph-name (normalize-comparable (:graph-name entry)) + graph-id (normalize-comparable (:graph-id entry)) + canonical-repo-name (normalize-comparable (canonical-repo graph-identifier))] + (or (= identifier repo) + (= identifier graph-name) + (= identifier graph-id) + (= canonical-repo-name repo)))) + +(defn ^:api resolve-target + [registry {:keys [graph-id graph-identifier]}] + (cond + (present-string? graph-id) + (some #(when (= graph-id (:graph-id %)) %) registry) + + (present-string? graph-identifier) + (some #(when (identifier-match? % graph-identifier) %) registry) + + :else + nil)) diff --git a/docs/adr/0017-url-target-graph-resolution.md b/docs/adr/0017-url-target-graph-resolution.md new file mode 100644 index 0000000000..bbf9531e66 --- /dev/null +++ b/docs/adr/0017-url-target-graph-resolution.md @@ -0,0 +1,195 @@ +# ADR 0017: URL Target Graph Resolution + +Date: 2026-05-20 +Status: Accepted + +## Context +Logseq URLs can point at a graph, page, block, or file. Today the graph target is +not carried through one coherent flow. + +The custom protocol path is handled in Electron main: +- `logseq://graph/` and `logseq://new-window/` are parsed in + `electron.url/local-url-handler`. +- Electron resolves `` through `electron.handler/get-graph-name`. +- If no window is already associated with that graph, Electron asks the current + renderer to run `openNewWindowOfGraph`. +- The renderer then opens a new Electron window with `#/?graph=`. + +This depends on renderer IPC timing and renderer-local storage. On first app +launch, the initial deeplink is handled soon after the main window is created, +while the renderer listener can still be unavailable. When the message is lost, +the newly opened app falls back to the stored `:git/current-repo`, so the URL +appears to always open the current graph. + +The web app has a related weakness. `frontend.state` only reads `:graph` from +`frontend.util/parse-params` during initial state creation, and that parser only +understands hash fragments shaped like `#/?graph=...`. Page/block web URLs and +later hash changes can route inside the currently opened graph instead of first +switching to the graph named by the URL. + +Graph names are not stable identifiers. Users can rename graphs, and two linked +graphs can share the same short basename. URLs that are meant to survive +renames, browser sessions, and device handoffs need to identify the graph by id. + +## Decision +Make URL graph selection an explicit target object and process it before route +navigation in the renderer, with a small persisted graph registry used to turn +stable graph ids into local repos. + +1. Introduce a shared URL target model for Electron protocol URLs, web URLs, and + mobile URLs: + - `:graph-id` - canonical graph id from the URL. + - `:graph-identifier` - compatibility alias, such as a short graph name or + full DB repo name. + - `:repo` - resolved full local repo name, for example `logseq_db_work`. + - `:route` - one of graph home, page, block, or file. + - `:open-mode` - current window or new window. + - `:source` - protocol URL, web URL, startup argv, or second instance. +2. Use `:graph-id` as the canonical URL identity. For remote graphs, + `:graph-id` is the remote graph UUID. For local-only graphs, `:graph-id` is + the local graph UUID. Generated URLs should include `graph-id` whenever the + graph id is known. +3. Resolve URL graph targets through one graph resolver that accepts, in order: + - graph ids from the local graph registry; + - full DB repo names, such as `logseq_db_work`; + - short local graph names, such as `work`; + - existing canonicalized DB repo names. +4. Treat an unresolved graph id as an unresolved URL target, not as a successful + match for the current graph. Mobile reports this to the user. Web startup + currently logs the unresolved target and continues with the tab-local or + stored graph so the app can still boot. +5. For Electron custom protocol URLs, the main process owns URL intake and + graph-name/graph-id resolution. This branch keeps the existing + renderer-mediated window creation path for custom protocol URLs and teaches + it to resolve graph ids through the registry. +6. For web and mobile URLs, the renderer owns URL intake and graph resolution. + It must switch to the target graph before applying the route target. +7. Once the target graph is loaded, apply the route target exactly once. +8. Do not store URL graph targets in `:git/current-repo` until the graph switch + succeeds. +9. Use `sessionStorage` for tab-local graph identity on web reloads that do not + carry a URL graph target. This preserves each browser tab's graph without + appending `graph-id` to every internal route. + +## Supported URL Shapes +Canonical generated web URLs use graph ids: +- `https://logseq.com/?graph-id=` +- `https://logseq.com/page/?graph-id=` +- `https://logseq.com/block/?graph-id=` + +Electron protocol URLs accept graph ids through the same graph identifier slot: +- `logseq://graph/` +- `logseq://graph/?page=` +- `logseq://graph/?block-id=` +- `logseq://new-window/?page=` + +Compatibility URLs remain accepted: +- `logseq://graph/` +- `logseq://graph/?page=` +- `logseq://graph/?block-id=` +- `logseq://graph/?file=` +- `https://logseq.com/#/?graph=` +- `https://logseq.com/#/page/?graph=` + +Generated web URLs should use path routes instead of hash routes. The app may +continue accepting hash-routed URLs during migration, but new share/copy-link +helpers should emit path URLs with `graph-id`. + +## Graph Registry +URL graph-id resolution must not open every graph database during startup. Keep a +small graph registry outside each graph DB. + +1. Web and mobile store the graph registry in IndexedDB. +2. Electron stores the graph registry in + `(.getPath app "home")/.logseq/graphs.edn`. +3. Registry entries include at least: + - `:graph-id` + - `:repo` + - `:graph-name` + - `:local-graph-id` + - `:updated-at` +4. Do not persist a separate `:rtc-graph-id` in the registry. For remote graphs, + `:graph-id` is the remote graph UUID. For local-only graphs, `:graph-id` is + the local graph UUID. +5. Update the registry whenever a graph is created, opened, imported, + downloaded, uploaded to RTC, renamed, unlinked, or deleted. +6. If a registry lookup fails for a graph id, the app may run an explicit repair + path that enumerates local graph storage and opens graph DBs to rebuild the + registry. This repair path must not be the normal URL-open path. + +## Electron Runtime Design +1. Store the registry at `(.getPath app "home")/.logseq/graphs.edn`. +2. Resolve Electron protocol graph identifiers through that registry before + falling back to existing repo/name lookup. +3. For shift-click in the graph list, open a new Electron window through the + existing `openNewWindow` IPC using the resolved repo. Electron windows still + use repo identity for window creation. +4. Keep a per-window pending target in Electron state only for work that cannot + be represented safely in the URL, such as a file redirect that requires graph + readiness. Avoid a single global `:window/once-graph-ready` callback because + concurrent windows can overwrite each other. +5. When the renderer reports `graphReady`, Electron should look up and consume + the pending target for that specific window id. +6. Existing error notifications for missing or unknown graphs remain, but they + should be sent to the selected/fallback window after graph resolution fails. +7. Direct main-process creation of route-aware graph windows remains follow-up + work; it should replace the renderer-mediated `openNewWindowOfGraph` path. + +## Web Runtime Design +1. Add a parser that can read graph targets from path URLs and compatibility + hash URLs. +2. During frontend startup: + - parse the URL target; + - resolve `:graph-id` through the IndexedDB graph registry; + - resolve compatibility `:graph-identifier` against the known graph list when + no `:graph-id` is present; + - initialize from the resolved URL target when it resolves; + - otherwise use the tab-local `sessionStorage` graph target, then the stored + `:git/current-repo`, then the first linked repo. +3. During startup, apply resolved page/block URL targets after the target graph + is restored. Later in-app route changes with graph targets are follow-up + work. +4. During route generation: + - generated page routes include `graph-id` when the current graph id is + available, but startup/render-phase redirects tolerate a temporarily + unavailable graph id instead of crashing. +5. Generated web links from `frontend.util.url` should include `graph-id` on the + actual route. +6. Web "open in another tab" and shift-click from All graphs open `#/?graph-id=` + URLs for existing local graphs. + +## Mobile Runtime Design +1. Use the same URL target parser and resolver as the web runtime. +2. Store and resolve the graph registry from IndexedDB. +3. Treat mobile app intents and universal links as URL sources that produce the + same target model. + +## Consequences +- Resolvable graph-id URLs open the requested graph instead of silently using the + last current graph. +- Electron protocol URLs can resolve graph ids via the registry, but route-aware + direct window creation remains follow-up work. +- Web graph URLs become route-aware, so page and block links can switch graph + before navigating during startup and mobile deeplink handling. +- Web and mobile graph-id resolution is fast because it reads the graph registry + instead of scanning OPFS graph directories and opening graph databases. +- Electron graph-id resolution is fast because it reads + `(.getPath app "home")/.logseq/graphs.edn` instead of opening each graph + database. +- Bare web reloads preserve the tab's graph through `sessionStorage`, so two + tabs can remain on different graphs even when one tab changes the global + current graph. +- The implementation needs focused tests around URL parsing, graph resolution, + first-launch Electron deeplinks, second-instance deeplinks, and web/mobile + route changes. + +## Follow-up Work +1. Add Electron main tests for first-launch and second-instance deeplinks. +2. Add renderer tests proving that web URLs switch graph before page/block + navigation after graph switches triggered by later route changes, not only + startup URLs. +3. Add graph registry read/write tests for IndexedDB and the Electron + `(.getPath app "home")/.logseq/graphs.edn` registry. +4. Replace `:window/once-graph-ready` with per-window pending targets. +5. Replace Electron custom protocol's renderer-mediated `openNewWindowOfGraph` + path with direct main-process route-aware window creation. diff --git a/src/electron/electron/configs.cljs b/src/electron/electron/configs.cljs index 06ce06f1a4..6170d5dba6 100644 --- a/src/electron/electron/configs.cljs +++ b/src/electron/electron/configs.cljs @@ -3,13 +3,18 @@ ["fs-extra" :as ^js fs] ["path" :as ^js node-path] [cljs.reader :as reader] - [electron.logger :as logger])) + [electron.logger :as logger] + [logseq.common.graph-registry :as graph-registry])) ;; FIXME: move configs.edn to where it should be (defonce dot-root (.join node-path (.getPath app "home") ".logseq")) (defonce cfg-root (.getPath app "userData")) (defonce cfg-path (.join node-path cfg-root "configs.edn")) +(defn graph-registry-path + [] + (.join node-path dot-root "graphs.edn")) + (defn- ensure-cfg [] (try @@ -40,3 +45,33 @@ (defn get-config [] (ensure-cfg)) + +(defn- read-edn-file + [path] + (try + (.ensureFileSync fs path) + (let [body (.toString (.readFileSync fs path))] + (if (seq body) (reader/read-string body) [])) + (catch :default e + (logger/error :graph-registry-read-error e) + []))) + +(defn read-graph-registry + [] + (read-edn-file (graph-registry-path))) + +(defn write-graph-registry! + [registry] + (try + (.ensureDirSync fs dot-root) + (.writeFileSync fs (graph-registry-path) (pr-str (vec registry))) + (vec registry) + (catch :default e + (logger/error :graph-registry-write-error e) + nil))) + +(defn upsert-graph-registry-entry! + [entry] + (let [registry (read-graph-registry) + registry' (graph-registry/upsert-entry registry entry)] + (write-graph-registry! registry'))) diff --git a/src/electron/electron/handler.cljs b/src/electron/electron/handler.cljs index 34957b9d94..5061e60c28 100644 --- a/src/electron/electron/handler.cljs +++ b/src/electron/electron/handler.cljs @@ -34,6 +34,7 @@ [logseq.cli.common :as cli-common] [logseq.common.config :as common-config] [logseq.common.graph :as common-graph] + [logseq.common.graph-registry :as graph-registry] [logseq.db.sqlite.util :as sqlite-util] [promesa.core :as p])) @@ -206,18 +207,24 @@ "Given a graph's name of string, returns the graph's fullname. For example, given `cat`, returns `logseq_db_cat`. Returns `nil` if no such graph exists." [graph-identifier] - (when-let [repo (canonical-repo graph-identifier)] - (let [graph-name (common-config/strip-leading-db-version-prefix repo)] - (->> (get-graphs) - (some #(when (or - (= (utils/normalize-lc %) (utils/normalize-lc repo)) - (string/ends-with? (utils/normalize-lc %) - (str "/" (utils/normalize-lc graph-name)))) - %)))))) + (or (:repo (graph-registry/resolve-target + (cfgs/read-graph-registry) + {:graph-identifier graph-identifier})) + (when-let [repo (canonical-repo graph-identifier)] + (let [graph-name (common-config/strip-leading-db-version-prefix repo)] + (->> (get-graphs) + (some #(when (or + (= (utils/normalize-lc %) (utils/normalize-lc repo)) + (string/ends-with? (utils/normalize-lc %) + (str "/" (utils/normalize-lc graph-name)))) + %))))))) (defmethod handle :getGraphs [_window [_]] (get-graphs)) +(defmethod handle :upsertGraphRegistryEntry [_window [_ entry]] + (cfgs/upsert-graph-registry-entry! entry)) + (defmethod handle :deleteGraph [_window [_ graph]] (when-let [repo (canonical-repo graph)] (p/let [_ (db-worker/release-repo! repo)] diff --git a/src/main/frontend/components/content.cljs b/src/main/frontend/components/content.cljs index 9726c7d3c7..ef1d9bc138 100644 --- a/src/main/frontend/components/content.cljs +++ b/src/main/frontend/components/content.cljs @@ -16,6 +16,7 @@ [frontend.handler.common.developer :as dev-common-handler] [frontend.handler.comments :as comments-handler] [frontend.handler.editor :as editor-handler] + [frontend.handler.graph :as graph-handler] [frontend.handler.notification :as notification] [frontend.handler.property :as property-handler] [frontend.handler.property.util :as pu] @@ -271,9 +272,10 @@ (shui/dropdown-menu-item {:key "Copy block URL" :on-click (fn [_e] - (let [current-repo (state/get-current-repo) - tap-f (fn [block-id] - (url-util/get-logseq-graph-uuid-url nil current-repo block-id))] + (let [tap-f (fn [block-id] + (url-util/get-logseq-web-block-url config/app-website + (graph-handler/current-graph-id) + block-id))] (editor-handler/copy-block-ref! block-id tap-f)))} (t :block/copy-url))) diff --git a/src/main/frontend/components/repo.cljs b/src/main/frontend/components/repo.cljs index c2434b6d5f..1a79101da0 100644 --- a/src/main/frontend/components/repo.cljs +++ b/src/main/frontend/components/repo.cljs @@ -125,6 +125,34 @@ (p/then (fn [] (repo-handler/remove-repo! repo)))))) +(defn graph-open-new-window-target + [{:keys [url] :as repo}] + (when-let [graph-id (:graph-id (graph/repo-summary->registry-entry repo))] + {:repo url + :graph-id graph-id})) + +(defn- (p/let [_ (db-restore/restore-graph! repo)] + (-> (p/let [_ (db-restore/restore-graph! repo) + _ (graph-handler/clj info))))) +(defn- current-url-target + [] + (try + (url-util/parse-web-url-target (.-href js/window.location)) + (catch js/Error e + (log/warn :url-target/parse-failed e) + nil))) + +(defn- apply-url-target-route! + [url-target] + (let [{:keys [to page-id block-id]} (:route url-target)] + (case to + :page + (route-handler/redirect-to-page! page-id) + + :block + (route-handler/redirect-to-page! block-id) + + nil))) + (defn start! [render] (let [t1 (util/time-ms)] @@ -154,10 +179,26 @@ repos (repo-handler/get-repos) _ (state/set-repos! repos) _ (mobile-util/hide-splash) ;; hide splash as early as ui is stable - repo (or (state/get-current-repo) (:url (first repos))) + url-target (current-url-target) + registry (graph-handler/clj + [registry] + (let [registry' (bean/->clj registry)] + (if (sequential? registry') + (vec registry') + []))) + +(defn clj registry))) + +(defn entry + normalize-registry-entry + (assoc :updated-at (js/Date.now)))] + (if (util/electron?) + (ipc/ipc "upsertGraphRegistryEntry" entry') + (p/let [registry (js registry')))))) + +(defn repo-summary->registry-entry + [{:keys [url GraphName GraphUUID metadata sync-meta] :as repo}] + (when url + (let [graph-id (some-> (or GraphUUID + (:kv/value metadata) + (second sync-meta)) + str) + local-graph-id (some-> (:local-graph-id repo) str)] + (when (or graph-id local-graph-id) + (normalize-registry-entry + {:repo url + :graph-name (or GraphName + (common-config/strip-leading-db-version-prefix url)) + :graph-id graph-id + :local-graph-id local-graph-id}))))) + +(defn registry-from-repo-summaries + [repos] + (keep repo-summary->registry-entry repos)) + +(defn resolve-startup-repo + [registry repos url-target tab-graph current-repo] + (let [registry' (concat registry (registry-from-repo-summaries repos)) + tab-repo (:repo tab-graph) + tab-graph-id (:graph-id tab-graph) + repo-exists? (fn [repo] + (some #(= repo (:url %)) repos)) + url-target-repo (:repo (resolve-registry-target registry' url-target)) + tab-target-repo (when (and (nil? url-target-repo) + (string/blank? (:graph-id url-target))) + (or (when (and (not (string/blank? tab-repo)) + (repo-exists? tab-repo)) + tab-repo) + (:repo (resolve-registry-target + registry' + {:graph-id tab-graph-id}))))] + (or url-target-repo + tab-target-repo + current-repo + (:url (first repos))))) + +(defn current-graph-id + [] + (when-let [repo (state/get-current-repo)] + (when-let [db* (db/get-db repo)] + (some-> (or (ldb/get-graph-rtc-uuid db*) + (ldb/get-graph-local-uuid db*)) + str)))) + +(defn remember-current-graph-id-in-tab! + [] + (when-let [repo (state/get-current-repo)] + (when-let [graph-id (current-graph-id)] + (set-tab-graph! repo graph-id)))) + +(defn (ldb/get-graph-local-uuid db*) str) + :graph-id (some-> (or (ldb/get-graph-rtc-uuid db*) + (ldb/get-graph-local-uuid db*)) + str)})))) (defn settle-metadata-to-local! [m] diff --git a/src/main/frontend/handler/page.cljs b/src/main/frontend/handler/page.cljs index 108b473e57..01ae8b7484 100644 --- a/src/main/frontend/handler/page.cljs +++ b/src/main/frontend/handler/page.cljs @@ -17,6 +17,7 @@ [frontend.handler.notification :as notification] [frontend.handler.plugin :as plugin-handler] [frontend.handler.property :as property-handler] + [frontend.handler.graph :as graph-handler] [frontend.handler.route :as route-handler] [frontend.modules.outliner.op :as outliner-op] [frontend.modules.outliner.ui :as ui-outliner-tx] @@ -313,5 +314,7 @@ ([page-uuid] (if page-uuid (util/copy-to-clipboard! - (url-util/get-logseq-graph-page-url nil (state/get-current-repo) (str page-uuid))) + (url-util/get-logseq-web-page-url config/app-website + (graph-handler/current-graph-id) + (str page-uuid))) (notification/show! (t :page/no-page-found-to-copy) :warning)))) diff --git a/src/main/frontend/handler/route.cljs b/src/main/frontend/handler/route.cljs index 40bff8804b..e7e47aa2ef 100644 --- a/src/main/frontend/handler/route.cljs +++ b/src/main/frontend/handler/route.cljs @@ -6,6 +6,7 @@ [frontend.db :as db] [frontend.db.model :as model] [frontend.extensions.pdf.utils :as pdf-utils] + [frontend.handler.graph :as graph-handler] [frontend.handler.notification :as notification] [frontend.handler.property.util :as pu] [frontend.handler.recent :as recent-handler] @@ -69,6 +70,15 @@ {:to :page :path-params {:name (str page-name)}})) +(defn- current-graph-query-params + [] + (when-let [graph-id (graph-handler/current-graph-id)] + {:graph-id graph-id})) + +(defn- merge-query-params + [route params] + (update route :query-params merge params)) + (defn redirect-to-page! "`page-name` can be a block uuid or name, prefer to use uuid than name when possible" ([page-name] @@ -90,17 +100,19 @@ (when-let [db-id (:db/id page)] (recent-handler/add-page-to-recent! db-id click-from-recent?)) (let [m (cond-> - (default-page-route (str page-name)) + (merge-query-params + (default-page-route (str page-name)) + (current-graph-query-params)) block-id - (assoc :query-params {:anchor (str "ls-block-" block-id)}) + (merge-query-params {:anchor (str "ls-block-" block-id)}) anchor - (assoc :query-params {:anchor anchor}) + (merge-query-params {:anchor anchor}) (boolean? push) (assoc :push push))] - (redirect! m))))))))) + (redirect! m))))))))) (defn built-in-page-title [page-name] diff --git a/src/main/frontend/handler/ui.cljs b/src/main/frontend/handler/ui.cljs index 11e0f7d554..c6c13bab98 100644 --- a/src/main/frontend/handler/ui.cljs +++ b/src/main/frontend/handler/ui.cljs @@ -240,12 +240,21 @@ (state/pub-event! [:modal/show-cards]))) (defn open-new-window-or-tab! - "Open a new Electron window." - [target-repo] - (when target-repo - (if (util/electron?) - (ipc/ipc "openNewWindow" target-repo) - (js/window.open (str config/app-website "#/?graph=" target-repo) "_blank")))) + "Open a new Electron window or web tab." + [target] + (when target + (let [{:keys [repo graph-id]} (if (map? target) + target + {:repo target})] + (if (util/electron?) + (ipc/ipc "openNewWindow" repo) + (do + (when-not (seq graph-id) + (throw (js/Error. "Missing graph id"))) + (js/window.open (str config/app-website + "#/?graph-id=" + (js/encodeURIComponent graph-id)) + "_blank")))))) (defn toggle-show-empty-hidden-properties! [] diff --git a/src/main/frontend/state.cljs b/src/main/frontend/state.cljs index c823534760..7e2345c0d2 100644 --- a/src/main/frontend/state.cljs +++ b/src/main/frontend/state.cljs @@ -12,6 +12,7 @@ [frontend.db.conn-state :as db-conn-state] [frontend.dicts :as dicts] [frontend.flows :as flows] + [frontend.graph-tab :as graph-tab] [frontend.mobile.util :as mobile-util] [frontend.spec.storage :as storage-spec] [frontend.storage :as storage] @@ -77,7 +78,9 @@ (defonce ^:large-vars/data-var state (let [document-mode? (or (storage/get :document/mode?) false) current-graph (let [url-graph (:graph (util/parse-params)) - graph (or url-graph (storage/get :git/current-repo))] + graph (or url-graph + (graph-tab/get-tab-repo) + (storage/get :git/current-repo))] (when graph (ipc/ipc "setCurrentGraph" graph)) graph)] (atom diff --git a/src/main/frontend/util/url.cljs b/src/main/frontend/util/url.cljs index a6c455e4cc..829505affa 100644 --- a/src/main/frontend/util/url.cljs +++ b/src/main/frontend/util/url.cljs @@ -1,6 +1,7 @@ (ns frontend.util.url "Util fns related to protocol url" - (:require [frontend.db.conn :as db-conn])) + (:require [clojure.string :as string] + [frontend.db.conn :as db-conn])) ;; Keep same as electron/electron.core (def LSP_SCHEME "logseq") @@ -52,14 +53,64 @@ (str (get-logseq-graph-url host repo protocol?) "?block-id=" uuid))) -(defn get-logseq-graph-page-url - "The URL represents an page in graph with pagename, for example: - logseq://graph/abc?page= - Ensure repo and page-name are valid before hand. - host: set to `nil` for local graph - protocol?: if true, returns URL with protocol prefix" - ([host repo page-name] - (get-logseq-graph-page-url host repo page-name true)) - ([host repo page-name protocol?] - (str (get-logseq-graph-url host repo protocol?) - "?page=" (encode-param page-name)))) +(defn- strip-trailing-slash + [s] + (string/replace s #"/+$" "")) + +(defn- required-url-part! + [k v] + (when-not (and (string? v) (not (string/blank? v))) + (throw (js/Error. (str "Missing " (name k))))) + v) + +(defn get-logseq-web-page-url + "Canonical web URL for a page. Page routes must always carry `graph-id`." + [app-base-url graph-id page-id] + (str (strip-trailing-slash (required-url-part! :app-base-url app-base-url)) + "/page/" (encode-param (required-url-part! :page-id page-id)) + "?graph-id=" (encode-param (required-url-part! :graph-id graph-id)))) + +(defn get-logseq-web-block-url + "Canonical web URL for a block. Block routes must always carry `graph-id`." + [app-base-url graph-id block-id] + (str (strip-trailing-slash (required-url-part! :app-base-url app-base-url)) + "/block/" (encode-param (required-url-part! :block-id block-id)) + "?graph-id=" (encode-param (required-url-part! :graph-id graph-id)))) + +(defn- route-from-path-parts + [path-parts] + (case (first path-parts) + "page" (when-let [page-id (second path-parts)] + {:to :page + :page-id (js/decodeURIComponent page-id)}) + "block" (when-let [block-id (second path-parts)] + {:to :block + :block-id (js/decodeURIComponent block-id)}) + nil)) + +(defn- path-parts + [path] + (->> (string/split path #"/") + (remove string/blank?) + vec)) + +(defn- hash-route-url + [parsed-url] + (let [hash (.-hash parsed-url)] + (when (string/starts-with? hash "#/") + (js/URL. (subs hash 1) "https://logseq.com")))) + +(defn parse-web-url-target + [url] + (let [parsed-url (js/URL. url "https://logseq.com") + hash-url (hash-route-url parsed-url) + graph-id (or (some-> hash-url .-searchParams (.get "graph-id")) + (.get (.-searchParams parsed-url) "graph-id")) + route (or (some-> hash-url .-pathname path-parts route-from-path-parts) + (route-from-path-parts (path-parts (.-pathname parsed-url))))] + (cond-> {} + (not (string/blank? graph-id)) + (assoc :graph-id graph-id) + + route + (assoc :route route)))) diff --git a/src/main/mobile/deeplink.cljs b/src/main/mobile/deeplink.cljs index 3965a46401..172f4a4ed8 100644 --- a/src/main/mobile/deeplink.cljs +++ b/src/main/mobile/deeplink.cljs @@ -4,90 +4,134 @@ [frontend.config :as config] [frontend.context.i18n :refer [t]] [frontend.db.async :as db-async] + [frontend.handler.graph :as graph-handler] [frontend.handler.notification :as notification] [frontend.handler.route :as route-handler] [frontend.mobile.intent :as intent] [frontend.state :as state] [frontend.util.text :as text-util] + [frontend.util.url :as url-util] [goog :refer [Uri]] [logseq.common.util :as common-util] [promesa.core :as p])) (def *link-to-another-graph (atom false)) +(defn- redirect-url-target-route! + [url-target link-to-another-graph?] + (when-let [{:keys [to page-id block-id]} (:route url-target)] + (js/setTimeout + (fn [] + (case to + :page + (route-handler/redirect-to-page! page-id) + + :block + (route-handler/redirect-to-page! block-id) + + nil) + (reset! *link-to-another-graph false)) + (if link-to-another-graph? + 1000 + 0)))) + +(defn- handle-web-url-target! + [url-target] + (when (seq url-target) + (p/let [registry (graph-handler/> (state/sub [:me :repos]) + (remove #(= (:url %) config/demo-repo))) + target-repo (:repo (graph-handler/resolve-registry-target + (concat registry + (graph-handler/registry-from-repo-summaries repos)) + url-target))] + (if target-repo + (let [link-to-another-graph? (not= target-repo (state/get-current-repo))] + (when link-to-another-graph? + (state/pub-event! [:graph/switch target-repo]) + (reset! *link-to-another-graph true)) + (redirect-url-target-route! url-target link-to-another-graph?)) + (when-let [graph-id (:graph-id url-target)] + (notification/show! (t :deeplink/open-graph-error graph-id) :error false)))))) + (defn deeplink [url] - (let [url (string/replace url "logseq.com/" "") - ^js/Uri parsed-url (.parse Uri url) - hostname (.getDomain parsed-url) - pathname (.getPath parsed-url) - search-params (.getQueryData parsed-url) - current-repo-url (state/get-current-repo) - get-graph-name-fn #(-> (text-util/get-graph-name-from-path %) - (string/split "/") - last - string/lower-case) - current-graph-name (get-graph-name-fn current-repo-url) - repos (->> (state/sub [:me :repos]) - (remove #(= (:url %) config/demo-repo)) - (map :url)) - repo-names (map #(get-graph-name-fn %) repos)] - (cond - (and (= hostname "mobile") (= pathname "/go/audio")) - (state/pub-event! [:mobile/start-audio-record]) - (and (= hostname "mobile") (= pathname "/go/quick-add")) - (state/pub-event! [:mobile/set-tab "capture"]) - (= hostname "graph") - (let [graph-name (some-> pathname - (string/replace "/" "") - string/lower-case) - [page-name block-uuid] (map #(.get search-params %) - ["page" "block-id"])] + (let [url-target (url-util/parse-web-url-target url)] + (if (seq url-target) + (handle-web-url-target! url-target) + (let [url (string/replace url "logseq.com/" "") + ^js/Uri parsed-url (.parse Uri url) + hostname (.getDomain parsed-url) + pathname (.getPath parsed-url) + search-params (.getQueryData parsed-url) + current-repo-url (state/get-current-repo) + get-graph-name-fn #(-> (text-util/get-graph-name-from-path %) + (string/split "/") + last + string/lower-case) + current-graph-name (get-graph-name-fn current-repo-url) + repos (->> (state/sub [:me :repos]) + (remove #(= (:url %) config/demo-repo)) + (map :url)) + repo-names (map #(get-graph-name-fn %) repos)] + (cond + (and (= hostname "mobile") (= pathname "/go/audio")) + (state/pub-event! [:mobile/start-audio-record]) - (when-not (string/blank? graph-name) - (when-not (= graph-name current-graph-name) - (let [graph-idx (.indexOf repo-names graph-name) - graph-url (when (not= graph-idx -1) - (nth repos graph-idx))] - (if graph-url - (do (state/pub-event! [:graph/switch graph-url]) - (reset! *link-to-another-graph true)) - (notification/show! (t :deeplink/open-graph-error graph-name) :error false)))) + (and (= hostname "mobile") (= pathname "/go/quick-add")) + (state/pub-event! [:mobile/set-tab "capture"]) - (when (or (= graph-name current-graph-name) - @*link-to-another-graph) - (js/setTimeout - (fn [] - (cond - page-name - (p/let [block (db-async/ pathname + (string/replace "/" "") + string/lower-case) + [page-name block-uuid] (map #(.get search-params %) + ["page" "block-id"])] - block-uuid - (p/let [block (db-async/ raw - js/JSON.parse - (js->clj :keywordize-keys true))] - (intent/handle-payload payload)) - (intent/handle-result result))) + block-uuid + (p/let [block (db-async/ raw + js/JSON.parse + (js->clj :keywordize-keys true))] + (intent/handle-payload payload)) + (intent/handle-result result))) + + :else + nil))))) diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index 0e45901d0d..05a0a94b5c 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -728,6 +728,7 @@ :graph/no-selected-node "No graph node selected." :graph/node-count (fn [n] (str n " " (if (= 1 n) "node" "nodes"))) :graph/open-folder-action "Open graph folder" + :graph/open-in-another-tab-action "Open in another tab" :graph/open-selected-node "Open {1}" :graph/preparing "Preparing" :graph/refresh-remote-graphs "Refresh remote graphs" diff --git a/src/resources/dicts/zh-cn.edn b/src/resources/dicts/zh-cn.edn index d804320734..58248cf742 100644 --- a/src/resources/dicts/zh-cn.edn +++ b/src/resources/dicts/zh-cn.edn @@ -724,6 +724,7 @@ :graph/name-placeholder "你的知识库名称" :graph/no-selected-node "未选中图谱节点。" :graph/open-folder-action "打开图谱所在文件夹" + :graph/open-in-another-tab-action "在另一个标签页中打开" :graph/open-selected-node "打开 {1}" :graph/preparing "加载中" :graph/refresh-remote-graphs "刷新远程知识库" diff --git a/src/test/frontend/components/repo_test.cljs b/src/test/frontend/components/repo_test.cljs index c48a70b8eb..499aa4f72e 100644 --- a/src/test/frontend/components/repo_test.cljs +++ b/src/test/frontend/components/repo_test.cljs @@ -4,6 +4,7 @@ [frontend.components.rtc.indicator :as rtc-indicator] [frontend.db :as db] [frontend.handler.db-based.sync :as rtc-handler] + [frontend.handler.graph :as graph-handler] [frontend.handler.repo :as repo-handler] [frontend.handler.user :as user-handler] [frontend.state :as state] @@ -23,6 +24,60 @@ user-handler/rtc-group? (constantly true)] (is (true? (repo/local-uploadable-graph? {:url "logseq_db_mobile"}))))) +(deftest open-in-another-tab-action-is-web-only-for-existing-graphs-test + (let [actionable-f (some-> (resolve 'frontend.components.repo/open-in-another-tab-action?) deref)] + (is (fn? actionable-f) "All graphs should expose a web open-in-another-tab action predicate") + (when actionable-f + (with-redefs [util/web-platform? true] + (is (true? (actionable-f {:url "logseq_db_demo" + :root "/graphs/demo" + :GraphUUID "graph-uuid"}))) + (is (true? (actionable-f {:url "logseq_db_demo" + :root "/graphs/demo"}))) + (is (false? (actionable-f {:url "logseq_db_remote"})))) + (with-redefs [util/web-platform? false] + (is (false? (actionable-f {:url "logseq_db_demo" + :root "/graphs/demo" + :GraphUUID "graph-uuid"}))))))) + +(deftest open-graph-in-another-tab-publishes-graph-open-window-event-test + (let [open-f (some-> (resolve 'frontend.components.repo/open-graph-in-another-tab!) deref) + events (atom [])] + (is (fn? open-f) "All graphs should expose a web open-in-another-tab action") + (when open-f + (with-redefs [state/pub-event! (fn [event] + (swap! events conj event))] + (open-f {:url "logseq_db_demo" + :GraphUUID "graph-uuid"}) + (is (= [[:graph/open-new-window {:repo "logseq_db_demo" + :graph-id "graph-uuid"}]] + @events)))))) + +(deftest open-local-graph-in-another-tab-resolves-graph-id-from-registry-test + (async done + (let [open-f (some-> (resolve 'frontend.components.repo/open-graph-in-another-tab!) deref) + events (atom [])] + (is (fn? open-f) "All graphs should expose a web open-in-another-tab action") + (if-not open-f + (done) + (-> (p/with-redefs [graph-handler/ (resolve 'frontend.components.repo/upload-local-graph-with-confirm!) deref) @@ -337,3 +392,73 @@ (swap! events conj event))] (on-click #js {}) (is (empty? @events))))) + +(deftest shift-click-opens-downloaded-remote-graph-in-new-tab-with-graph-id-test + (let [events (atom []) + links (#'repo/repos-dropdown-links + [{:url "logseq_db_demo" + :root "/graphs/demo" + :remote? true + :rtc-graph? true + :GraphName "demo" + :GraphUUID "graph-uuid" + :GraphSchemaVersion "65" + :graph-ready-for-use? true}] + nil + nil) + on-click (get-in (first links) [:options :on-click])] + (with-redefs [state/pub-event! (fn [event] + (swap! events conj event))] + (on-click #js {:shiftKey true}) + (is (= [[:graph/open-new-window {:repo "logseq_db_demo" + :graph-id "graph-uuid"}]] + @events))))) + +(deftest shift-click-opens-local-graph-in-new-tab-with-registry-graph-id-test + (async done + (let [events (atom []) + links (#'repo/repos-dropdown-links + [{:url "logseq_db_demo" + :root "/graphs/demo" + :graph-ready-for-use? true}] + nil + nil) + on-click (get-in (first links) [:options :on-click])] + (-> (p/with-redefs [graph-handler/ (resolve 'frontend.handler.graph/graph-registry-key) deref)] + (is (= "ls-graph-registry" registry-key)))) + +(deftest get-graph-registry-normalizes-indexeddb-js-values-test + (async done + (let [get-registry-f (some-> (resolve 'frontend.handler.graph/ (get-registry-f) + (.then (fn [registry] + (is (= [{:repo "logseq_db_work" + :graph-name "work" + :graph-id "remote-uuid"}] + registry)) + (is (= "logseq_db_work" + (:repo (graph-registry/resolve-target + registry + {:graph-id "remote-uuid"})))) + (done))) + (.catch (fn [e] + (is false (str e)) + (done)))))))) + +(deftest remember-current-graph-id-in-tab-test + (let [remember-f (some-> (resolve 'frontend.handler.graph/remember-current-graph-id-in-tab!) deref) + stored-graph (atom nil)] + (is (fn? remember-f) "Current graph id should be remembered for same-tab reloads") + (when remember-f + (p/with-redefs [frontend.handler.graph/current-graph-id (constantly "remote-uuid") + state/get-current-repo (constantly "logseq_db_work") + frontend.handler.graph/set-tab-graph! (fn [repo graph-id] + (reset! stored-graph {:repo repo + :graph-id graph-id}))] + (remember-f) + (is (= {:repo "logseq_db_work" + :graph-id "remote-uuid"} + @stored-graph)))))) + +(deftest resolve-startup-repo-prefers-tab-repo-before-global-current-test + (let [resolve-f (some-> (resolve 'frontend.handler.graph/resolve-startup-repo) deref)] + (is (fn? resolve-f) "Startup repo resolver should exist") + (when resolve-f + (is (= "logseq_db_tab" + (resolve-f [] + [{:url "logseq_db_tab"} + {:url "logseq_db_current"}] + {} + {:repo "logseq_db_tab" + :graph-id "tab-uuid"} + "logseq_db_current")))))) + +(deftest resolve-startup-repo-prefers-url-graph-id-test + (let [resolve-f (some-> (resolve 'frontend.handler.graph/resolve-startup-repo) deref)] + (is (fn? resolve-f) "Startup repo resolver should exist") + (when resolve-f + (is (= "logseq_db_url" + (resolve-f [{:repo "logseq_db_url" + :graph-name "url" + :graph-id "url-uuid"} + {:repo "logseq_db_tab" + :graph-name "tab" + :graph-id "tab-uuid"}] + [{:url "logseq_db_current"}] + {:graph-id "url-uuid"} + {:repo "logseq_db_tab" + :graph-id "tab-uuid"} + "logseq_db_current")))))) + +(deftest resolve-startup-repo-uses-tab-graph-id-before-global-current-test + (let [resolve-f (some-> (resolve 'frontend.handler.graph/resolve-startup-repo) deref)] + (is (fn? resolve-f) "Startup repo resolver should exist") + (when resolve-f + (testing "refreshing a bare root URL keeps the tab's graph context" + (is (= "logseq_db_tab" + (resolve-f [{:repo "logseq_db_tab" + :graph-name "tab" + :graph-id "tab-uuid"}] + [{:url "logseq_db_current"}] + {} + {:repo "logseq_db_tab" + :graph-id "tab-uuid"} + "logseq_db_current")))) + (testing "global current graph remains the last fallback" + (is (= "logseq_db_current" + (resolve-f [] + [{:url "logseq_db_first"}] + {} + nil + "logseq_db_current"))))))) + +(deftest normalize-registry-entry-prefers-remote-graph-id-test + (let [normalize-f (some-> (resolve 'frontend.handler.graph/normalize-registry-entry) deref)] + (is (fn? normalize-f) "Graph registry entry normalizer should exist") + (when normalize-f + (testing "remote graphs store graph-id without duplicating rtc-graph-id" + (is (= {:repo "logseq_db_work" + :graph-name "work" + :local-graph-id "local-uuid" + :graph-id "remote-uuid"} + (select-keys + (normalize-f {:repo "logseq_db_work" + :graph-name "work" + :local-graph-id "local-uuid" + :graph-id "remote-uuid"}) + [:repo :graph-name :local-graph-id :rtc-graph-id :graph-id])))) + (testing "local-only graphs use local graph uuid as canonical graph-id" + (is (= "local-uuid" + (:graph-id (normalize-f {:repo "logseq_db_local" + :graph-name "local" + :local-graph-id "local-uuid"}))))) + (testing "missing graph identity fails fast" + (is (thrown? js/Error + (normalize-f {:repo "logseq_db_broken" + :graph-name "broken"}))))))) + +(deftest resolve-registry-target-prefers-graph-id-test + (let [resolve-f (some-> (resolve 'frontend.handler.graph/resolve-registry-target) deref)] + (is (fn? resolve-f) "Graph registry target resolver should exist") + (when resolve-f + (let [registry [{:repo "logseq_db_work" + :graph-name "work" + :graph-id "remote-uuid"} + {:repo "logseq_db_other" + :graph-name "work" + :graph-id "other-uuid"}]] + (is (= "logseq_db_work" + (:repo (resolve-f registry {:graph-id "remote-uuid"})))) + (is (= "logseq_db_other" + (:repo (resolve-f registry {:graph-identifier "logseq_db_other"})))) + (is (= "logseq_db_work" + (:repo (resolve-f registry {:graph-identifier "remote-uuid"}))) + "Protocol URL graph identifiers can be canonical graph ids") + (is (nil? (resolve-f registry {:graph-id "missing-uuid"}))))))) + +(deftest upsert-registry-entry-replaces-local-id-after-remote-id-exists-test + (let [registry [{:repo "logseq_db_work" + :graph-name "work" + :local-graph-id "local-uuid" + :graph-id "local-uuid"}] + registry' (graph-registry/upsert-entry + registry + {:repo "logseq_db_work" + :graph-name "work" + :local-graph-id "local-uuid" + :graph-id "remote-uuid"})] + (is (= 1 (count registry'))) + (is (= "remote-uuid" (:graph-id (first registry')))) + (is (not (contains? (first registry') :rtc-graph-id))) + (is (nil? (graph-registry/resolve-target registry' {:graph-id "local-uuid"}))) + (is (= "logseq_db_work" + (:repo (graph-registry/resolve-target registry' {:graph-id "remote-uuid"})))))) diff --git a/src/test/frontend/handler/route_test.cljs b/src/test/frontend/handler/route_test.cljs index 0c79256a8b..1406afe001 100644 --- a/src/test/frontend/handler/route_test.cljs +++ b/src/test/frontend/handler/route_test.cljs @@ -1,8 +1,11 @@ (ns frontend.handler.route-test (:require [clojure.test :refer [deftest is use-fixtures testing]] + [frontend.db :as db] [frontend.db.utils :as db-utils] + [frontend.handler.graph :as graph-handler] [frontend.handler.route :as route-handler] - [frontend.test.helper :as test-helper :refer [load-test-files]])) + [frontend.test.helper :as test-helper :refer [load-test-files]] + [reitit.frontend.easy :as rfe])) (use-fixtures :each {:before test-helper/start-test-db! :after test-helper/destroy-test-db!}) @@ -63,3 +66,37 @@ (is (= {:to :page :path-params {:name "page name"}} (#'route-handler/default-page-route "Page name")) "Generates a case insensitive page link"))) + +(deftest redirect-to-page-includes-current-graph-id + (testing "page routes always carry graph identity" + (let [calls (atom [])] + (with-redefs [db/get-page (constantly nil) + db/get-alias-source-page (constantly nil) + graph-handler/current-graph-id (constantly "graph-uuid") + rfe/push-state (fn [& args] (swap! calls conj args))] + (route-handler/redirect-to-page! "Page name") + (is (= [[:page {:name "page name"} {:graph-id "graph-uuid"}]] + @calls))))) + + (testing "existing page route query params are preserved" + (let [calls (atom [])] + (with-redefs [db/get-page (constantly nil) + db/get-alias-source-page (constantly nil) + graph-handler/current-graph-id (constantly "graph-uuid") + rfe/push-state (fn [& args] (swap! calls conj args))] + (route-handler/redirect-to-page! "Page name" {:anchor "ls-block-block-uuid"}) + (is (= [[:page {:name "page name"} + {:anchor "ls-block-block-uuid" + :graph-id "graph-uuid"}]] + @calls)))))) + +(deftest redirect-to-page-tolerates-missing-current-graph-id + (testing "startup redirects do not crash before graph restoration finishes" + (let [calls (atom [])] + (with-redefs [db/get-page (constantly nil) + db/get-alias-source-page (constantly nil) + graph-handler/current-graph-id (constantly nil) + rfe/push-state (fn [& args] (swap! calls conj args))] + (route-handler/redirect-to-page! "Page name") + (is (= [[:page {:name "page name"} nil]] + @calls)))))) diff --git a/src/test/frontend/handler/ui_test.cljs b/src/test/frontend/handler/ui_test.cljs new file mode 100644 index 0000000000..ff923024b2 --- /dev/null +++ b/src/test/frontend/handler/ui_test.cljs @@ -0,0 +1,21 @@ +(ns frontend.handler.ui-test + (:require [cljs.test :refer [deftest is]] + [frontend.config :as config] + [frontend.handler.ui :as ui-handler] + [frontend.util :as util])) + +(deftest open-new-window-or-tab-uses-graph-id-on-web-test + (let [opened (atom []) + original-open (.-open js/window)] + (try + (set! (.-open js/window) + (fn [url target] + (swap! opened conj [url target]))) + (with-redefs [util/electron? (constantly false) + config/app-website "http://localhost:3001"] + (ui-handler/open-new-window-or-tab! {:repo "logseq_db_demo" + :graph-id "graph uuid"})) + (is (= [["http://localhost:3001#/?graph-id=graph%20uuid" "_blank"]] + @opened)) + (finally + (set! (.-open js/window) original-open))))) diff --git a/src/test/frontend/util/url_test.cljs b/src/test/frontend/util/url_test.cljs new file mode 100644 index 0000000000..26eb910bc7 --- /dev/null +++ b/src/test/frontend/util/url_test.cljs @@ -0,0 +1,38 @@ +(ns frontend.util.url-test + (:require [cljs.test :refer [deftest is testing]] + [frontend.util.url])) + +(deftest web-page-url-requires-graph-id-test + (let [page-url-f (some-> (resolve 'frontend.util.url/get-logseq-web-page-url) deref)] + (is (fn? page-url-f) "Canonical page URL helper should exist") + (when page-url-f + (testing "page routes always include graph-id" + (is (= "https://logseq.com/page/page-uuid?graph-id=remote-graph-uuid" + (page-url-f "https://logseq.com" "remote-graph-uuid" "page-uuid")))) + (testing "missing graph-id fails instead of generating ambiguous page URL" + (is (thrown? js/Error + (page-url-f "https://logseq.com" nil "page-uuid"))))))) + +(deftest web-block-url-requires-graph-id-test + (let [block-url-f (some-> (resolve 'frontend.util.url/get-logseq-web-block-url) deref)] + (is (fn? block-url-f) "Canonical block URL helper should exist") + (when block-url-f + (is (= "https://logseq.com/block/block-uuid?graph-id=remote-graph-uuid" + (block-url-f "https://logseq.com" "remote-graph-uuid" "block-uuid")))))) + +(deftest parse-web-url-target-reads-path-route-and-graph-id-test + (let [parse-f (some-> (resolve 'frontend.util.url/parse-web-url-target) deref)] + (is (fn? parse-f) "Web URL target parser should exist") + (when parse-f + (is (= {:graph-id "remote-graph-uuid" + :route {:to :page + :page-id "page-uuid"}} + (parse-f "https://logseq.com/page/page-uuid?graph-id=remote-graph-uuid"))) + (is (= {:graph-id "remote-graph-uuid" + :route {:to :block + :block-id "block-uuid"}} + (parse-f "https://logseq.com/block/block-uuid?graph-id=remote-graph-uuid"))) + (is (= {:graph-id "dc4b7cbd-65f7-4e76-9591-dcb3d14f11cf" + :route {:to :page + :page-id "00000001-2026-0520-0000-000000000000"}} + (parse-f "http://localhost:3001/#/page/00000001-2026-0520-0000-000000000000?graph-id=dc4b7cbd-65f7-4e76-9591-dcb3d14f11cf")))))) diff --git a/src/test/mobile/deeplink_test.cljs b/src/test/mobile/deeplink_test.cljs new file mode 100644 index 0000000000..2aa7f1c335 --- /dev/null +++ b/src/test/mobile/deeplink_test.cljs @@ -0,0 +1,61 @@ +(ns mobile.deeplink-test + (:require [cljs.test :refer [async deftest is testing]] + [frontend.handler.graph :as graph-handler] + [frontend.handler.route :as route-handler] + [frontend.state :as state] + [mobile.deeplink :as deeplink] + [promesa.core :as p])) + +(deftest web-page-url-switches-to-graph-id-target + (async done + (testing "mobile universal web links resolve graph-id before routing" + (let [events (atom []) + redirects (atom [])] + (p/with-redefs [graph-handler/ (deeplink/deeplink "https://logseq.com/page/page-uuid?graph-id=remote-uuid") + (p/then (fn [] (p/delay 1010))) + (p/then (fn [] + (is (= [[:graph/switch "logseq_db_target"]] + @events)) + (is (= ["page-uuid"] @redirects)) + (done))) + (p/catch (fn [e] + (is false (str e)) + (done))))))))) + +(deftest web-page-url-routes-within-current-graph + (async done + (testing "mobile universal web links route when graph-id is already current" + (let [redirects (atom [])] + (p/with-redefs [graph-handler/ (deeplink/deeplink "https://logseq.com/page/page-uuid?graph-id=remote-uuid") + (p/then (fn [] (p/delay 5))) + (p/then (fn [] + (is (= ["page-uuid"] @redirects)) + (done))) + (p/catch (fn [e] + (is false (str e)) + (done)))))))))