Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="audio" android:scheme="logseq" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="graph" android:scheme="logseq" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)))
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
}
2 changes: 1 addition & 1 deletion apple/Sources/Logseq/LGPlatformCommandRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
14 changes: 12 additions & 2 deletions apple/Sources/Logseq/LogseqApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions apple/Sources/Logseq/SharedCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,30 @@ 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) {
uuid = nil
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
Expand Down
18 changes: 16 additions & 2 deletions apple/Sources/LogseqModel/ViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions apple/Tests/UI/deep-link-parser.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
43 changes: 43 additions & 0 deletions shared/src/logseq/core/rpc_session.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions shared/src/logseq/core/shared_capture.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion shared/src/logseq/core/shared_capture.mli
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions shared/test/logseq/shared_capture_test.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading