diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c4100bf0..f34c6796 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -62,6 +62,12 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/logseq/app/AndroidAppEntryParser.kt b/android/app/src/main/kotlin/com/logseq/app/AndroidAppEntryParser.kt
index 3e49ae1a..7170fb85 100644
--- a/android/app/src/main/kotlin/com/logseq/app/AndroidAppEntryParser.kt
+++ b/android/app/src/main/kotlin/com/logseq/app/AndroidAppEntryParser.kt
@@ -12,6 +12,8 @@ internal object AndroidAppEntryParser {
val pair = item.split('=', limit = 2)
mapOf("name" to decode(pair[0]), "value" to pair.getOrNull(1)?.let(::decode))
}
- return mapOf("kind" to "deep-link", "scheme" to uri.scheme, "host" to uri.host, "query" to query)
+ return mapOf("kind" to "deep-link", "scheme" to uri.scheme, "host" to uri.host, "path" to decode(uri.rawPath.orEmpty()), "query" to query)
}
+ fun blockURL(graphName: String, blockID: String): String =
+ URI("logseq", "graph", "/$graphName", "block-id=$blockID", null).toASCIIString()
}
diff --git a/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt b/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt
index cd1eff75..e6fc428a 100644
--- a/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt
+++ b/android/app/src/main/kotlin/com/logseq/app/runtime/LogseqRuntime.kt
@@ -29,6 +29,7 @@ import logseq.app.AndroidRuntimeLogLevel
import logseq.app.AndroidRuntimeLogSource
import logseq.app.AndroidRuntimeLogs
import org.json.JSONObject
+import com.logseq.app.AndroidAppEntryParser
// Kotlin port of the former Dart app state: owns the LUI
// backend, the JNI bridge, the effect drain, bootstrap/restore, graph
@@ -582,13 +583,26 @@ internal class LogseqRuntime(
return NativeEffectResolution.HostUpdate(kind = "open-node", payload = JSONObject.quote(uuid))
}
- private suspend fun applyAppEntryCoreRequest(request: String) {
+ private suspend fun applyAppEntryCoreRequest(request: String, allowGraphSwitch: Boolean = true) {
val response = bridge.callCore(request)
val decoded = JSONObject(response)
if (!decoded.optBoolean("ok")) {
error("The native core rejected the Android app entry")
}
applyCoreResponse(response)
+ val result = decoded.optJSONObject("result")
+ val graphID = result?.optString("deepLinkGraphId")?.takeIf { it.isNotEmpty() && it != "null" }
+ if (graphID != null) {
+ check(allowGraphSwitch) { "The linked graph did not finish opening" }
+ val opened = executeEffect(NativeEffect(id = 0, kind = "open-graph", text = graphID))
+ check(opened.succeeded) { opened.message }
+ if (opened is NativeEffectResolution.CoreResponse) applyCoreResponse(opened.response)
+ applyAppEntryCoreRequest(request, allowGraphSwitch = false)
+ return
+ }
+ result?.optString("deepLinkNodeUuid")?.takeIf { it.isNotEmpty() && it != "null" }?.let { uuid ->
+ applyPatch(bridge.applyHostUpdate("open-node", JSONObject.quote(uuid)))
+ }
decoded.optJSONObject("result")?.optString("deepLinkQuickAction")?.takeIf(String::isNotEmpty)?.let { action ->
applyPatch(bridge.applyHostUpdate("open-quick-action", JSONObject.quote(action)))
}
@@ -660,7 +674,7 @@ internal class LogseqRuntime(
)
"setClipboardURLs" -> platformServices.copyText(
command.uuids.joinToString("\n") {
- "logseq://graph/${batch.graphName}?block-id=$it"
+ AndroidAppEntryParser.blockURL(batch.graphName.orEmpty(), it)
}
)
"pickAttachment" -> presentAttachment("files", command.uuid)
diff --git a/android/app/src/test/kotlin/com/logseq/app/AndroidAppEntryParserTest.kt b/android/app/src/test/kotlin/com/logseq/app/AndroidAppEntryParserTest.kt
index 6290d6c1..4e51bb41 100644
--- a/android/app/src/test/kotlin/com/logseq/app/AndroidAppEntryParserTest.kt
+++ b/android/app/src/test/kotlin/com/logseq/app/AndroidAppEntryParserTest.kt
@@ -11,4 +11,15 @@ class AndroidAppEntryParserTest {
assertEquals(listOf(mapOf("name" to "text", "value" to "C++"),
mapOf("name" to "text", "value" to ""), mapOf("name" to "title", "value" to "Example")), value["query"])
}
+ @Test fun graphLinksPreserveDecodedPathAndLiteralPlusSigns() {
+ val link = AndroidAppEntryParser.deepLink("logseq://graph/Other%20%E4%B8%AD%E6%96%87%20%2B?page=C%2B%2B+notes&block-id=block")!!
+ assertEquals("/Other 中文 +", link["path"])
+ assertEquals(listOf(mapOf("name" to "page", "value" to "C+++notes"),
+ mapOf("name" to "block-id", "value" to "block")), link["query"])
+ }
+ @Test fun copiedGraphURLsRoundTripUnicodeAndSpaces() {
+ val url = AndroidAppEntryParser.blockURL("Other 中文 +", "block")
+ org.junit.Assert.assertFalse(url.contains(" "))
+ assertEquals("/Other 中文 +", AndroidAppEntryParser.deepLink(url)!!["path"])
+ }
}
diff --git a/apple/Sources/Logseq/LGPlatformCommandRouter.swift b/apple/Sources/Logseq/LGPlatformCommandRouter.swift
index d10303ac..ab544621 100644
--- a/apple/Sources/Logseq/LGPlatformCommandRouter.swift
+++ b/apple/Sources/Logseq/LGPlatformCommandRouter.swift
@@ -61,7 +61,7 @@ public final class LGPlatformCommandRouter: LGPlatformCommandHandling {
private static func blockURLs(graphName: String, blockIDs: [String]) -> String {
blockIDs.map {
- "logseq://graph/\(graphName)?block-id=\($0)"
+ SharedDeepLink.blockURL(graphName: graphName, blockID: $0)
}.joined(separator: "\n")
}
}
diff --git a/apple/Sources/Logseq/LogseqApp.swift b/apple/Sources/Logseq/LogseqApp.swift
index 55f31326..4f771915 100644
--- a/apple/Sources/Logseq/LogseqApp.swift
+++ b/apple/Sources/Logseq/LogseqApp.swift
@@ -758,8 +758,18 @@ public struct LogseqRootView : View {
case .deepLink:
guard var link = item.captureLink else { return true }
link.uuid = item.id
- guard let data = try? JSONEncoder().encode(link),
- await self.store.handleDeepLink(String(decoding: data, as: UTF8.self)) else { return false }
+ guard let data = try? JSONEncoder().encode(link) else { return false }
+ let payload = String(decoding: data, as: UTF8.self)
+ guard await self.store.handleDeepLink(payload) else { return false }
+ if let graphID = self.store.deepLinkGraphId {
+ let opened = await self.graphLifecycle.execute(LGEffect(id: 0, kind: "open-graph", text: graphID))
+ guard opened.succeeded, await self.store.handleDeepLink(payload), self.store.deepLinkGraphId == nil else { return false }
+ }
+ if let uuid = self.store.deepLinkNodeUuid {
+ guard let encoded = try? JSONEncoder().encode(uuid) else { return false }
+ do { try self.lgRuntime.applyHostUpdate(kind: "open-node", payload: String(decoding: encoded, as: UTF8.self)) }
+ catch { return false }
+ }
if let action = self.store.deepLinkQuickAction {
self.pendingQuickAction = action
self.presentPendingQuickAction()
diff --git a/apple/Sources/Logseq/SharedCapture.swift b/apple/Sources/Logseq/SharedCapture.swift
index d280d020..1d1f60bf 100644
--- a/apple/Sources/Logseq/SharedCapture.swift
+++ b/apple/Sources/Logseq/SharedCapture.swift
@@ -22,6 +22,7 @@ public struct SharedDeepLink: Codable, Sendable, Equatable {
var uuid: String?
let scheme: String?
let host: String?
+ let path: String?
let query: [QueryItem]
public init(_ url: URL) {
@@ -29,10 +30,22 @@ public struct SharedDeepLink: Codable, Sendable, Equatable {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
scheme = components?.scheme
host = components?.host
+ path = components?.path
query = (components?.queryItems ?? []).map { QueryItem(name: $0.name, value: $0.value) }
}
}
+extension SharedDeepLink {
+ static func blockURL(graphName: String, blockID: String) -> String {
+ var components = URLComponents()
+ components.scheme = "logseq"
+ components.host = "graph"
+ components.path = "/" + graphName
+ components.queryItems = [URLQueryItem(name: "block-id", value: blockID)]
+ return components.url?.absoluteString ?? ""
+ }
+}
+
public struct SharedCaptureAsset: Codable, Sendable, Equatable {
public let title: String
public let assetType: String
diff --git a/apple/Sources/LogseqModel/ViewModel.swift b/apple/Sources/LogseqModel/ViewModel.swift
index 45bf5790..14ac06a7 100644
--- a/apple/Sources/LogseqModel/ViewModel.swift
+++ b/apple/Sources/LogseqModel/ViewModel.swift
@@ -934,9 +934,13 @@ private struct CreateSyncGraphPayload: Encodable {
}
public private(set) var deepLinkQuickAction: String?
+ public private(set) var deepLinkGraphId: String?
+ public private(set) var deepLinkNodeUuid: String?
public func handleDeepLink(_ payload: String) async -> Bool {
deepLinkQuickAction = nil
+ deepLinkGraphId = nil
+ deepLinkNodeUuid = nil
await dispatchRawAndWait("handleDeepLink", payload: payload)
guard lastError == nil else { return false }
syncPending()
@@ -1133,8 +1137,18 @@ private struct CreateSyncGraphPayload: Encodable {
do {
let responseData = Data(responseJSON.utf8)
if actionName == "handleDeepLink" {
- struct Envelope: Decodable { struct Result: Decodable { let deepLinkQuickAction: String? }; let result: Result? }
- deepLinkQuickAction = try JSONDecoder().decode(Envelope.self, from: responseData).result?.deepLinkQuickAction
+ struct Envelope: Decodable {
+ struct Result: Decodable {
+ let deepLinkQuickAction: String?
+ let deepLinkGraphId: String?
+ let deepLinkNodeUuid: String?
+ }
+ let result: Result?
+ }
+ let result = try JSONDecoder().decode(Envelope.self, from: responseData).result
+ deepLinkQuickAction = result?.deepLinkQuickAction
+ deepLinkGraphId = result?.deepLinkGraphId
+ deepLinkNodeUuid = result?.deepLinkNodeUuid
}
if actionName == "bootstrapGraph" {
bootstrapPlan = try JSONDecoder().decode(LogseqBootstrapEnvelope.self, from: responseData).result?.graphBootstrap
diff --git a/apple/Tests/UI/deep-link-parser.swift b/apple/Tests/UI/deep-link-parser.swift
new file mode 100644
index 00000000..b79f6f23
--- /dev/null
+++ b/apple/Tests/UI/deep-link-parser.swift
@@ -0,0 +1,25 @@
+import Foundation
+import LogseqModel
+@testable import Logseq
+
+@main struct DeepLinkParserCheck {
+ @MainActor static func main() throws {
+ let link = SharedDeepLink(URL(string: "logseq://graph/Other%20%E4%B8%AD%E6%96%87%20%2B?page=C%2B%2B+notes&block-id=block")!)
+ let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(link)) as! [String: Any]
+ guard json["path"] as? String == "/Other 中文 +" else {
+ throw NSError(domain: "DeepLinkParser", code: 1, userInfo: [NSLocalizedDescriptionKey: "Graph links must transport the decoded graph path to the shared core"])
+ }
+ let query = json["query"] as! [[String: Any]]
+ precondition(query[0]["value"] as? String == "C+++notes")
+ precondition(query[1]["value"] as? String == "block")
+ var copied = ""
+ let router = LGPlatformCommandRouter(setClipboardText: { copied = $0 }, performHaptic: { _ in }, present: { _ in })
+ router.handle(LGPlatformCommandBatch(revision: 1, graphName: "Other 中文 +", commands: [
+ LogseqOutlinerCommand(type: "setClipboardURLs", style: nil, uuid: nil, uuids: ["block"], text: nil)
+ ]))
+ precondition(!copied.contains(" "), "Copied graph URLs must percent-encode spaces")
+ let roundtrip = try JSONSerialization.jsonObject(with: JSONEncoder().encode(SharedDeepLink(URL(string: copied)!))) as! [String: Any]
+ precondition(roundtrip["path"] as? String == "/Other 中文 +")
+ print("PASS: graph deep links preserve decoded paths, Unicode, literal plus signs, and node targets")
+ }
+}
diff --git a/shared/src/logseq/core/rpc_session.ml b/shared/src/logseq/core/rpc_session.ml
index 9dad14ea..a6dbe9d3 100644
--- a/shared/src/logseq/core/rpc_session.ml
+++ b/shared/src/logseq/core/rpc_session.ml
@@ -1520,6 +1520,48 @@ let child_context session parent_id =
| None -> current)
| None -> current
+let deep_link_response session extra =
+ match Json.from_string (snapshot_visible session) with
+ | `Assoc envelope ->
+ let fields = match List.assoc_opt "result" envelope with Some (`Assoc fields) -> fields | _ -> [] in
+ Json.to_string (`Assoc (("result", `Assoc (extra @ fields)) :: List.remove_assoc "result" envelope))
+ | _ -> Rpc.failure "invalid_deep_link" "No application projection is available"
+
+let graph_deep_link session graph page block =
+ let current = state session in
+ let matches (candidate : Api.api_graph) = candidate.id = graph || candidate.name = graph in
+ let candidates = List.filter matches current.available_graphs in
+ let candidates = if candidates <> [] then candidates else
+ List.filter (fun (candidate : Api.api_graph) ->
+ Dev_query.Unicode.lowercase candidate.name = Dev_query.Unicode.lowercase graph) current.available_graphs in
+ let target = match candidates with
+ | [candidate] when candidate.Api.ready -> Ok candidate.id
+ | [_] -> Error "The linked graph is not ready"
+ | _ :: _ -> Error "More than one graph has this name; use its graph identifier"
+ | [] -> (match current.config with
+ | Some config when config.graph_id = graph || config.graph_name = Some graph -> Ok config.graph_id
+ | _ -> Error "The linked graph is not available") in
+ match target with
+ | Error message -> Rpc.failure "invalid_deep_link" message
+ | Ok target when Option.map (fun (config : Api.api_config) -> config.graph_id) current.config <> Some target ->
+ deep_link_response session ["deepLinkGraphId", `String target]
+ | Ok _ ->
+ (match block, page with
+ | None, None -> deep_link_response session ["deepLinkQuickAction", `String "journal"]
+ | _ ->
+ let target = Option.get (match block with Some _ -> block | None -> page) in
+ let uuid = Option.bind (query_db session) (fun db ->
+ let id = match block with
+ | Some _ -> Datascript.entid db "block/uuid" (Datascript.Uuid target)
+ | None -> (match Seq.uncons (Datascript.Db.datoms db Datascript.Avet
+ ~a:"block/name" ~v:(Datascript.String (Dev_query.Unicode.lowercase target)) ()) with
+ | Some (datom, _) -> Some datom.Datascript.e
+ | None -> Datascript.entid db "block/uuid" (Datascript.Uuid target)) in
+ Option.bind id (Graph_read.uuid_for_eid db)) in
+ match uuid with
+ | Some uuid -> deep_link_response session ["deepLinkNodeUuid", `String uuid]
+ | None -> Rpc.failure "invalid_deep_link" "The linked page or block is not available")
+
let rec dispatch session action payload =
let s = state session in
let h = host session in
@@ -1552,6 +1594,7 @@ let rec dispatch session action payload =
| Error message -> Rpc.failure "invalid_deep_link" message
| Ok (`Capture text) -> dispatch session "captureShared" (Some (Json.to_string
(`Assoc (("text", `String text) :: List.filter (fun (key, _) -> key <> "text" && key <> "title" && key <> "url") fields))))
+ | Ok (`Graph (graph, page, block)) -> graph_deep_link session graph page block
| Ok action ->
let quick_action = match action with `Quick_action value -> `String value | _ -> `Null in
let response = Json.from_string (snapshot_visible session) in
diff --git a/shared/src/logseq/core/shared_capture.ml b/shared/src/logseq/core/shared_capture.ml
index f7112096..a5d393fa 100644
--- a/shared/src/logseq/core/shared_capture.ml
+++ b/shared/src/logseq/core/shared_capture.ml
@@ -37,6 +37,16 @@ let deep_link fields =
| _ -> values) [] query in
if Option.map String.lowercase_ascii scheme <> Some "logseq" then Ok (`Ignore)
else match Option.map String.lowercase_ascii host with
+ | Some "graph" ->
+ let* path = optional fields "path" in
+ let graph = Option.map (fun path -> if String.starts_with ~prefix:"/" path then
+ String.sub path 1 (String.length path - 1) else path) path in
+ (match graph with
+ | None | Some "" -> Error "A graph name or identifier is required"
+ | Some graph ->
+ let* page = optional values "page" in
+ let* block = optional values "block-id" in
+ Ok (`Graph (graph, page, block)))
| Some "capture" -> (match text values with Ok value -> Ok (`Capture value) | Error _ -> Ok (`Quick_action "capture"))
| Some ("audio" | "journal" as action) -> Ok (`Quick_action action)
| _ -> Ok (`Ignore)
diff --git a/shared/src/logseq/core/shared_capture.mli b/shared/src/logseq/core/shared_capture.mli
index 58666c8d..25f6c6fc 100644
--- a/shared/src/logseq/core/shared_capture.mli
+++ b/shared/src/logseq/core/shared_capture.mli
@@ -1,4 +1,4 @@
(** Canonical shared-note and deep-link semantics, independent of OS providers. *)
val text : (string * Yojson.Basic.t) list -> (string, string) result
val deep_link : (string * Yojson.Basic.t) list ->
- ([ `Capture of string | `Quick_action of string | `Ignore ], string) result
+ ([ `Capture of string | `Quick_action of string | `Graph of string * string option * string option | `Ignore ], string) result
diff --git a/shared/test/logseq/shared_capture_test.ml b/shared/test/logseq/shared_capture_test.ml
index b7d4b1a5..9bc66a8f 100644
--- a/shared/test/logseq/shared_capture_test.ml
+++ b/shared/test/logseq/shared_capture_test.ml
@@ -59,7 +59,33 @@ let late_recording_transcripts_are_core_owned_and_idempotent () =
check_eq (List.length children) 1;
check_eq (List.hd children).title "Spoken note"
+let graph_deep_links_resolve_graphs_pages_and_blocks () =
+ let db = Query_block_test.fixture "#Project" in
+ let catalog = {|{"graphs":[{"graph-id":"plain-1","graph-name":"Plain","graph-e2ee?":false,"graph-ready-for-use?":true},{"graph-id":"other","graph-name":"Other 中文 +","graph-e2ee?":false,"graph-ready-for-use?":true}]}|} in
+ let session = Rpc_session.create_session {Rpc_session.default_options with
+ load_graph_catalog = Some (fun () -> Some catalog); graph_db = Some (fun () -> Some db)} in
+ ignore (Rpc_test.configure_plain_session session);
+ let fields path query = ["scheme", `String "logseq"; "host", `String "graph"; "path", `String path;
+ "query", `List (List.map (fun (name, value) -> `Assoc ["name", `String name; "value", `String value]) query)] in
+ let navigate path query = call session "handleDeepLink" (fields path query) in
+ let result path query = let response = navigate path query in
+ check_eq ~msg:(J.to_string response) (U.member "ok" response) (`Bool true); Rpc_test.response_result response in
+ check_eq (U.member "deepLinkQuickAction" (result "/Plain" [])) (`String "journal");
+ check_eq (U.member "deepLinkNodeUuid" (result "/Plain" ["page", "Workspace"])) (`String Query_block_test.page);
+ check_eq (U.member "deepLinkNodeUuid" (result "/plain-1" ["block-id", Query_block_test.alpha])) (`String Query_block_test.alpha);
+ check_eq (U.member "deepLinkNodeUuid" (result "/Plain" ["page", "Workspace"; "block-id", Query_block_test.beta])) (`String Query_block_test.beta);
+ let other = result "/Other 中文 +" ["page", "Workspace"] in
+ check_eq ~msg:"a graph switch must finish before resolving nodes in that graph"
+ (U.member "deepLinkGraphId" other) (`String "other");
+ check_eq (U.member "deepLinkNodeUuid" other) `Null;
+ check_eq (Option.map (fun (config : Api.api_config) -> config.graph_id) (Rpc_session.state session).config) (Some "plain-1");
+ List.iter (fun (path, query) -> check_eq (U.member "ok" (navigate path query)) (`Bool false))
+ ["/Missing graph", []; "/", []; "/Plain", ["page", "Missing page"]; "/Plain", ["block-id", "missing"]];
+ ignore (Rpc_test.dispatch_json session "configure" {|{"baseUrl":"http://127.0.0.1:8787","graphId":"other","token":"access"}|});
+ check_eq (U.member "deepLinkNodeUuid" (result "/Other 中文 +" ["page", "Workspace"])) (`String Query_block_test.page)
+
let cases = [
+ case "graph deep links resolve graph page and block targets without changing the wrong graph" graph_deep_links_resolve_graphs_pages_and_blocks;
case "late recording transcripts are core owned and idempotent" late_recording_transcripts_are_core_owned_and_idempotent;
case "shared text is canonical and retries are idempotent" shared_text_is_canonical_and_retries_are_idempotent;
case "empty or invalid shared payload does not create a note" empty_or_invalid_shared_payload_does_not_create_a_note;