enhance: use graph identity in URLs (#12693)

* enhance: use graph identity in URLs

* fix: resolve graph identity URLs

* fix: satisfy graph url lint

* fix: resolve graph id from hash urls

* fix: preserve tab graph on reload

* fix: initialize tab graph before render

* enhance: open graph in another tab

* fix: open graph tabs by graph id

* fix: open local graph tabs by registry id

* fix: avoid duplicate rtc graph id

* fix: open electron graph window on shift click

* fix: address graph identity review
This commit is contained in:
Tienson Qin
2026-05-21 14:52:28 +08:00
committed by GitHub
parent c5c24d2b6b
commit ec0c01fd97
25 changed files with 1236 additions and 117 deletions

View File

@@ -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}}

View File

@@ -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))

View File

@@ -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/<graph>` and `logseq://new-window/<graph>` are parsed in
`electron.url/local-url-handler`.
- Electron resolves `<graph>` 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=<repo>`.
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=<graph-uuid>`
- `https://logseq.com/page/<page-uuid>?graph-id=<graph-uuid>`
- `https://logseq.com/block/<block-uuid>?graph-id=<graph-uuid>`
Electron protocol URLs accept graph ids through the same graph identifier slot:
- `logseq://graph/<graph-uuid>`
- `logseq://graph/<graph-uuid>?page=<page-name-or-uuid>`
- `logseq://graph/<graph-uuid>?block-id=<block-uuid>`
- `logseq://new-window/<graph-uuid>?page=<page-name-or-uuid>`
Compatibility URLs remain accepted:
- `logseq://graph/<graph-name>`
- `logseq://graph/<graph-name>?page=<page-name>`
- `logseq://graph/<graph-name>?block-id=<uuid>`
- `logseq://graph/<graph-name>?file=<file>`
- `https://logseq.com/#/?graph=<graph-name>`
- `https://logseq.com/#/page/<page-name>?graph=<graph-name>`
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.

View File

@@ -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')))

View File

@@ -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)]

View File

@@ -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)))

View File

@@ -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- <graph-open-new-window-target
[{:keys [url] :as repo}]
(if-let [target (graph-open-new-window-target repo)]
(p/resolved target)
(p/let [registry (graph/<get-graph-registry)
entry (graph/resolve-registry-target registry {:graph-identifier url})]
(when entry
{:repo (:repo entry)
:graph-id (:graph-id entry)}))))
(defn open-in-another-tab-action?
[{:keys [root]}]
(boolean (and util/web-platform? root)))
(defn open-graph-in-another-tab!
[repo]
(if-let [target (graph-open-new-window-target repo)]
(state/pub-event! [:graph/open-new-window target])
(p/let [target (<graph-open-new-window-target repo)]
(when target
(state/pub-event! [:graph/open-new-window target])))))
(rum/defc ^:large-vars/cleanup-todo repos-inner
"Graph list in `All graphs` page"
[repos]
@@ -171,6 +199,13 @@
(ui/icon "dots" {:size 15})))
(shui/dropdown-menu-content
{:align "end"}
(when (open-in-another-tab-action? repo)
(shui/dropdown-menu-item
{:key "open-in-another-tab"
:class "open-in-another-tab-menu-item"
:on-click #(open-graph-in-another-tab! repo)}
(t :graph/open-in-another-tab-action)))
(when root
(shui/dropdown-menu-item
{:key "delete-locally"
@@ -330,9 +365,14 @@
(when (and ready-for-use? (not downloading?))
(when-let [on-click (:on-click opts)]
(on-click e))
(if (and (gobj/get e "shiftKey")
(not (and rtc-graph? remote?)))
(state/pub-event! [:graph/open-new-window url])
(if (and (gobj/get e "shiftKey") (:root graph))
(if (util/electron?)
(state/pub-event! [:graph/open-new-window url])
(if-let [target (graph-open-new-window-target graph)]
(state/pub-event! [:graph/open-new-window target])
(p/let [target (<graph-open-new-window-target graph)]
(when target
(state/pub-event! [:graph/open-new-window target])))))
(cond
;; exists locally?
(or (:root graph) (not rtc-graph?))

View File

@@ -0,0 +1,44 @@
(ns frontend.graph-tab
"Tab-local graph context helpers."
(:require [clojure.string :as string]
[goog.object :as gobj]))
(def graph-id-key
"ls-tab-graph-id")
(def repo-key
"ls-tab-repo")
(defn- session-storage
[]
(gobj/get js/globalThis "sessionStorage"))
(defn get-tab-graph-id
[]
(when-let [storage (session-storage)]
(let [graph-id (.getItem storage graph-id-key)]
(when-not (string/blank? graph-id)
graph-id))))
(defn get-tab-repo
[]
(when-let [storage (session-storage)]
(let [repo (.getItem storage repo-key)]
(when-not (string/blank? repo)
repo))))
(defn get-tab-graph
[]
(let [repo (get-tab-repo)
graph-id (get-tab-graph-id)]
(when (or repo graph-id)
{:repo repo
:graph-id graph-id})))
(defn set-tab-graph!
[repo graph-id]
(when-let [storage (session-storage)]
(when-not (string/blank? repo)
(.setItem storage repo-key repo))
(when-not (string/blank? graph-id)
(.setItem storage graph-id-key graph-id))))

View File

@@ -21,12 +21,14 @@
[frontend.handler.events.export]
[frontend.handler.events.rtc]
[frontend.handler.events.ui]
[frontend.handler.graph :as graph-handler]
[frontend.handler.global-config :as global-config-handler]
[frontend.handler.page :as page-handler]
[frontend.handler.plugin :as plugin-handler]
[frontend.handler.plugin-config :as plugin-config-handler]
[frontend.handler.repo :as repo-handler]
[frontend.handler.repo-config :as repo-config-handler]
[frontend.handler.route :as route-handler]
[frontend.handler.ui :as ui-handler]
[frontend.handler.user :as user-handler]
[frontend.common.idb :as idb]
@@ -36,6 +38,7 @@
[frontend.persist-db :as persist-db]
[frontend.state :as state]
[frontend.util :as util]
[frontend.util.url :as url-util]
[goog.object :as gobj]
[lambdaisland.glogi :as log]
[promesa.core :as p]))
@@ -57,7 +60,9 @@
(defn restore-and-setup!
[repo]
(when repo
(-> (p/let [_ (db-restore/restore-graph! repo)]
(-> (p/let [_ (db-restore/restore-graph! repo)
_ (graph-handler/<upsert-current-graph-registry!)]
(graph-handler/remember-current-graph-id-in-tab!)
(repo-config-handler/start {:repo repo}))
(p/then
(fn []
@@ -120,6 +125,26 @@
(p/let [info (ipc/ipc :system/info)]
(state/set-state! :system/info (bean/->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/<get-graph-registry)
target-repo (when (seq url-target)
(:repo (graph-handler/resolve-registry-target
(concat registry
(graph-handler/registry-from-repo-summaries repos))
url-target)))
_ (when (and (seq (:graph-id url-target)) (nil? target-repo))
(log/warn :url-target/unresolved-graph-id url-target))
repo (graph-handler/resolve-startup-repo
registry
repos
url-target
(graph-handler/get-tab-graph)
(state/get-current-repo))
_ (if (empty? repos)
(repo-handler/new-db! config/demo-repo)
(restore-and-setup! repo))]
(restore-and-setup! repo))
_ (when target-repo
(apply-url-target-route! url-target))]
(set-network-watcher!)
(when (mobile-util/native-platform?)
(state/restore-mobile-theme!)))

View File

@@ -105,6 +105,8 @@
(p/do!
(repo-handler/restore-and-setup-repo! graph)
(graph-switch graph)
(graph-handler/<upsert-current-graph-registry!)
(graph-handler/remember-current-graph-id-in-tab!)
(state/set-state! :sync-graph/init? false)
(when (:rtc-download? opts)
(repo-handler/refresh-repos!)
@@ -128,8 +130,8 @@
(let [t2 (t/now)]
(log/info ::graph-switch-spent (- t2 t1))))))
(defmethod handle :graph/open-new-window [[_ev target-repo]]
(ui-handler/open-new-window-or-tab! target-repo))
(defmethod handle :graph/open-new-window [[_ev target]]
(ui-handler/open-new-window-or-tab! target))
(defmethod handle :page/create [[_ page-name opts]]
(if (= page-name (date/today))

View File

@@ -1,7 +1,118 @@
(ns frontend.handler.graph
"Provides util handler fns for graph view"
(:require [frontend.state :as state]
[frontend.storage :as storage]))
(:require [cljs-bean.core :as bean]
[clojure.string :as string]
[electron.ipc :as ipc]
[frontend.common.idb :as idb]
[frontend.db :as db]
[frontend.graph-tab :as graph-tab]
[frontend.state :as state]
[frontend.storage :as storage]
[frontend.util :as util]
[logseq.common.config :as common-config]
[logseq.common.graph-registry :as graph-registry]
[logseq.db :as ldb]
[promesa.core :as p]))
(def graph-registry-key
"ls-graph-registry")
(def normalize-registry-entry graph-registry/normalize-entry)
(def resolve-registry-target graph-registry/resolve-target)
(def set-tab-graph! graph-tab/set-tab-graph!)
(def get-tab-graph graph-tab/get-tab-graph)
(defn- storage-registry->clj
[registry]
(let [registry' (bean/->clj registry)]
(if (sequential? registry')
(vec registry')
[])))
(defn <get-graph-registry
[]
(p/let [registry (idb/get-item graph-registry-key)]
(storage-registry->clj registry)))
(defn <upsert-graph-registry-entry!
[entry]
(let [entry' (-> entry
normalize-registry-entry
(assoc :updated-at (js/Date.now)))]
(if (util/electron?)
(ipc/ipc "upsertGraphRegistryEntry" entry')
(p/let [registry (<get-graph-registry)
registry' (graph-registry/upsert-entry registry entry')]
(idb/set-item! graph-registry-key (bean/->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 <upsert-current-graph-registry!
[]
(when-let [repo (state/get-current-repo)]
(when-let [db* (db/get-db repo)]
(<upsert-graph-registry-entry!
{:repo repo
:graph-name (common-config/strip-leading-db-version-prefix repo)
:local-graph-id (some-> (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]

View File

@@ -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))))

View File

@@ -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]

View File

@@ -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!
[]

View File

@@ -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

View File

@@ -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=<page-name>
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))))

View File

@@ -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/<get-graph-registry)
repos (->> (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/<get-block (state/get-current-repo) page-name {:children? false})]
(if block
(route-handler/redirect-to-page! block-uuid)
(notification/show! (t :deeplink/open-page-error page-name) :error false)))
(= hostname "graph")
(let [graph-name (some-> pathname
(string/replace "/" "")
string/lower-case)
[page-name block-uuid] (map #(.get search-params %)
["page" "block-id"])]
block-uuid
(p/let [block (db-async/<get-block (state/get-current-repo) block-uuid {:children? false})]
(if block
(route-handler/redirect-to-page! block-uuid)
(notification/show! (t :deeplink/open-block-error block-uuid) :error false)))
(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))))
:else
nil)
(reset! *link-to-another-graph false))
(if @*link-to-another-graph
1000
0)))))
(when (or (= graph-name current-graph-name)
@*link-to-another-graph)
(js/setTimeout
(fn []
(cond
page-name
(p/let [block (db-async/<get-block (state/get-current-repo) page-name {:children? false})]
(if block
(route-handler/redirect-to-page! block-uuid)
(notification/show! (t :deeplink/open-page-error page-name) :error false)))
(= hostname "shared")
(let [result (into {} (map (fn [key]
[(keyword key) (.get search-params key)])
["title" "url" "type" "payload"]))]
(if (:payload result)
(let [raw (common-util/safe-decode-uri-component (:payload result))
payload (-> raw
js/JSON.parse
(js->clj :keywordize-keys true))]
(intent/handle-payload payload))
(intent/handle-result result)))
block-uuid
(p/let [block (db-async/<get-block (state/get-current-repo) block-uuid {:children? false})]
(if block
(route-handler/redirect-to-page! block-uuid)
(notification/show! (t :deeplink/open-block-error block-uuid) :error false)))
:else
nil)))
:else
nil)
(reset! *link-to-another-graph false))
(if @*link-to-another-graph
1000
0)))))
(= hostname "shared")
(let [result (into {} (map (fn [key]
[(keyword key) (.get search-params key)])
["title" "url" "type" "payload"]))]
(if (:payload result)
(let [raw (common-util/safe-decode-uri-component (:payload result))
payload (-> raw
js/JSON.parse
(js->clj :keywordize-keys true))]
(intent/handle-payload payload))
(intent/handle-result result)))
:else
nil)))))

View File

@@ -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"

View File

@@ -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 "刷新远程知识库"

View File

@@ -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/<get-graph-registry
(fn []
(p/resolved [{:repo "logseq_db_demo"
:graph-name "demo"
:graph-id "local-uuid"}]))
state/pub-event! (fn [event]
(swap! events conj event))]
(open-f {:url "logseq_db_demo"
:root "/graphs/demo"}))
(p/then (fn []
(is (= [[:graph/open-new-window {:repo "logseq_db_demo"
:graph-id "local-uuid"}]]
@events))
(done)))
(p/catch (fn [error]
(is false (str error))
(done))))))))
(deftest upload-local-graph-with-confirm-asks-before-upload-test
(async done
(let [upload-fn (some-> (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/<get-graph-registry
(fn []
(p/resolved [{:repo "logseq_db_demo"
:graph-name "demo"
:graph-id "local-uuid"}]))
state/pub-event! (fn [event]
(swap! events conj event))]
(on-click #js {:shiftKey true}))
(p/then (fn []
(is (= [[:graph/open-new-window {:repo "logseq_db_demo"
:graph-id "local-uuid"}]]
@events))
(done)))
(p/catch (fn [error]
(is false (str error))
(done)))))))
(deftest electron-shift-click-opens-local-graph-in-new-window-by-repo-test
(let [events (atom [])
registry-reads (atom 0)
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])]
(with-redefs [util/electron? (constantly true)
graph-handler/<get-graph-registry
(fn []
(swap! registry-reads inc)
(p/resolved []))
state/pub-event! (fn [event]
(swap! events conj event))]
(on-click #js {:shiftKey true})
(is (= [[:graph/open-new-window "logseq_db_demo"]]
@events))
(is (zero? @registry-reads)))))

View File

@@ -0,0 +1,163 @@
(ns frontend.handler.graph-test
(:require [cljs.test :refer [async deftest is testing]]
[frontend.common.idb :as idb]
[frontend.handler.graph]
[frontend.state :as state]
[logseq.common.graph-registry :as graph-registry]
[promesa.core :as p]))
(deftest graph-registry-key-is-indexeddb-compatible-test
(let [registry-key (some-> (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-graph-registry) deref)]
(p/with-redefs [idb/get-item (fn [_]
(p/resolved
#js [#js {"repo" "logseq_db_work"
"graph-name" "work"
"graph-id" "remote-uuid"}]))]
(-> (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"}))))))

View File

@@ -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))))))

View File

@@ -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)))))

View File

@@ -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"))))))

View File

@@ -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/<get-graph-registry
(fn []
(p/resolved [{:repo "logseq_db_target"
:graph-name "target"
:graph-id "remote-uuid"}]))
state/get-current-repo (constantly "logseq_db_current")
state/sub (fn [query]
(case query
[:me :repos] [{:url "logseq_db_target"}]
nil))
state/pub-event! (fn [event] (swap! events conj event))
route-handler/redirect-to-page! (fn [page-id]
(swap! redirects conj page-id))]
(-> (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/<get-graph-registry
(fn []
(p/resolved [{:repo "logseq_db_target"
:graph-name "target"
:graph-id "remote-uuid"}]))
state/get-current-repo (constantly "logseq_db_target")
state/sub (fn [query]
(case query
[:me :repos] [{:url "logseq_db_target"}]
nil))
route-handler/redirect-to-page! (fn [page-id]
(swap! redirects conj page-id))]
(-> (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)))))))))