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;