diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorKeyboardTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorKeyboardTest.kt index 75f48ec1..a195f10e 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorKeyboardTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidEditorKeyboardTest.kt @@ -20,7 +20,7 @@ class AndroidEditorKeyboardTest { private lateinit var backend: LuiBackend private var generation = 1 - private fun render(title: String): SemanticsNodeInteraction { + private fun render(title: String, inputPolicy: String = "{}"): SemanticsNodeInteraction { val registry = logseqExtensionRegistry { it } backend = LuiBackend(onEvent = { events.add(it) }, extensions = registry) val ops = JSONArray().put(JSONObject().put("op", "create-node").put("id", 1).put("kind", "root")) @@ -28,7 +28,7 @@ class AndroidEditorKeyboardTest { .put("fingerprint", registry.registration("outliner-editor")!!.fingerprint)) .put(JSONObject().put("op", "insert-child").put("parent", 1).put("child", 2).put("index", 0)) mapOf("block-id" to "keyboard-block", "title" to title, "caret-utf16-offset" to title.length, - "selection-revision" to 0, "input-policy" to "{}").forEach { (name, value) -> + "selection-revision" to 0, "focus-enabled" to true, "input-policy" to inputPolicy).forEach { (name, value) -> ops.put(JSONObject().put("op", "set-extension-prop").put("id", 2).put("property", name).put("value", value)) } assertTrue(backend.lastError, backend.applyJson(JSONObject().put("generation", 1).put("ops", ops).toString())) @@ -148,6 +148,32 @@ class AndroidEditorKeyboardTest { ui.waitUntil(5_000) { events.filterIsInstance().any { it.name == "return" } } ui.runOnIdle { assertEquals(1, events.filterIsInstance().count { it.name == "return" }) } } + @Test fun codeEnterInsertsLinesAndShiftEnterCreatesOneBlock() { + val editor = render("6*7", """{"code":true,"multiline":true}""") + editor.performKeyInput { pressKey(Key.Enter) } + editor.assertTextEquals("6*7\n") + editor.performTextInput("last*3\n") + editor.assertTextEquals("6*7\nlast*3\n") + ui.runOnIdle { assertFalse(events.filterIsInstance().any { it.name == "return" }) } + editor.performKeyInput { keyDown(Key.ShiftLeft); pressKey(Key.Enter); keyUp(Key.ShiftLeft) } + ui.waitUntil(5_000) { events.filterIsInstance().any { it.name == "return" } } + ui.runOnIdle { assertEquals(1, events.filterIsInstance().count { it.name == "return" }) } + } + + @Test fun codeShiftEnterReplacesUnicodeSelectionAndPreservesHandoffTyping() { + val editor = render("Hello 中文 📝 world", """{"code":true,"multiline":true}""") + editor.performTextInputSelection(TextRange(11, 6)) + editor.performKeyInput { keyDown(Key.ShiftRight); pressKey(Key.Enter); keyUp(Key.ShiftRight) } + ui.runOnIdle { + val split = events.filterIsInstance().single { it.name == "return" } + assertEquals("Hello world", split.values["title"]) + assertEquals(6, split.values["caret-utf16-offset"]) + } + editor.performTextInput("Next") + acknowledge("next-block", " world", 0) + ui.onNodeWithTag("field.outliner.block.next-block").assertIsFocused().assertTextEquals("Next world") + } + @Test fun savedWritingAssistanceFlagsReachTheNativeKeyboardAndUpdateWithoutLosingSelection() { val context = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext val preferences = context.getSharedPreferences("logseq", android.content.Context.MODE_PRIVATE) diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidFeedbackRecordingTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidFeedbackRecordingTest.kt index 1a48d976..27b295dc 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidFeedbackRecordingTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidFeedbackRecordingTest.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key import androidx.compose.ui.unit.dp import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule @@ -238,15 +239,19 @@ class AndroidFeedbackRecordingTest(private val scenario: String) { val inputBounds = editor.getUnclippedBoundsInRoot() val outputBounds = results.getUnclippedBoundsInRoot() assertTrue("Calculator results occupy the right column", outputBounds.left >= inputBounds.right) - editor.performImeAction() + editor.performKeyInput { pressKey(Key.Enter) } + editor.assertTextEquals("6*7\nlast*3\n") + drain() + val beforeCount = JSONObject(configured).getJSONObject("result").getJSONArray("outlinerRows").length() + assertEquals("Enter stays inside Calculator", beforeCount, + JSONObject(request("refresh")).getJSONObject("result").getJSONArray("outlinerRows").length()) + editor.performKeyInput { keyDown(Key.ShiftLeft); pressKey(Key.Enter); keyUp(Key.ShiftLeft) } ui.waitUntil(10_000) { drain() runCatching { editor.assertDoesNotExist() }.isSuccess } val after = JSONObject(request("refresh")).getJSONObject("result").getJSONArray("outlinerRows") - val beforeCount = JSONObject(configured).getJSONObject("result").getJSONArray("outlinerRows").length() - assertEquals("Enter creates exactly one new block", beforeCount + 1, after.length()) - results.assertTextEquals("42\n126") + assertEquals("Shift+Enter creates exactly one new block", beforeCount + 1, after.length()) content.performTouchInput { longClick() } ui.waitUntil(10_000) { drain() diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt index 2fb3da21..61c48f23 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerEditor.kt @@ -53,7 +53,7 @@ import kotlinx.coroutines.delay // Port of the former _OutlinerEditor Dart implementation. // // The block title/caret are owned by the OCaml core. Local edits emit -// text-change (debounced 80ms), Enter emits return, backspace at offset 0 +// text-change (debounced 80ms), the block-creation key emits return, backspace at offset 0 // emits a structural backspace. Model acknowledgements echo locally // published titles — those updates are swallowed so typing is never // reverted mid-keystroke. @@ -328,6 +328,10 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { return@onPreviewKeyEvent true } if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && event.isShiftPressed) { + if (code && multiline) { + returnAtSelection() + return@onPreviewKeyEvent true + } val start = value.selection.min.coerceIn(0, value.text.length) val end = value.selection.max.coerceIn(start, value.text.length) val title = value.text.replaceRange(start, end, "\n") diff --git a/apple/Package.resolved b/apple/Package.resolved index 9967e6b9..1cbd8df8 100644 --- a/apple/Package.resolved +++ b/apple/Package.resolved @@ -15,7 +15,7 @@ "kind" : "remoteSourceControl", "location" : "ssh://git@github.com/logseq/lui.git", "state" : { - "revision" : "d3b8df7a2837bc6aa2f4ca56dc1bfe5ba42dff4e" + "revision" : "f90ee61c2460f7d67d6e169863ff096b50b34d0f" } } ], diff --git a/apple/Package.swift b/apple/Package.swift index f2543acc..b313e6b1 100644 --- a/apple/Package.swift +++ b/apple/Package.swift @@ -44,7 +44,7 @@ let package = Package( .library(name: "LogseqModel", type: .dynamic, targets: ["LogseqModel"]), ], dependencies: [ - .package(url: "ssh://git@github.com/logseq/lui.git", revision: "d3b8df7a2837bc6aa2f4ca56dc1bfe5ba42dff4e"), + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "f90ee61c2460f7d67d6e169863ff096b50b34d0f"), .package(url: "https://github.com/appstefan/highlightswift.git", from: "1.1.0") ], targets: [ diff --git a/apple/Sources/Logseq/OutlinerInlineEditor.swift b/apple/Sources/Logseq/OutlinerInlineEditor.swift index ce1d6586..36498a63 100644 --- a/apple/Sources/Logseq/OutlinerInlineEditor.swift +++ b/apple/Sources/Logseq/OutlinerInlineEditor.swift @@ -383,7 +383,9 @@ private struct NativeOutlinerTextView: UIViewRepresentable { shouldChangeTextIn range: NSRange, replacementText replacement: String ) -> Bool { - let isInlineLineBreak = (textView as? FocusRetainingTextView)?.isInsertingLineBreak == true + let isShiftReturn = (textView as? FocusRetainingTextView)?.isInsertingLineBreak == true + let isInlineLineBreak = isShiftReturn && !(NativeEditorInputPolicy.usesCodePresentation(parent.inputPolicy) + && NativeEditorInputPolicy.allowsLineBreak(parent.inputPolicy)) if isAwaitingBlockHandoff { // Structure events are serialized and rebound to the latest // editor by the store. Forward them immediately so fast @@ -419,7 +421,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable { parent.onTextChange(deletion.text, deletion.caretUTF16Offset) return false } - if replacement == "\n", NativeEditorInputPolicy.allowsLineBreak(parent.inputPolicy) { return true } + if replacement == "\n", !isShiftReturn, NativeEditorInputPolicy.allowsLineBreak(parent.inputPolicy) { return true } if replacement == "\n" { LGInteractionDiagnostics.editor(textView, blockID: activeBlockID, event: "editor.return") var submittedText = textView.text ?? "" diff --git a/apple/Tests/UI/localized-dialog-cancel.swift b/apple/Tests/UI/localized-dialog-cancel.swift new file mode 100644 index 00000000..91b84c86 --- /dev/null +++ b/apple/Tests/UI/localized-dialog-cancel.swift @@ -0,0 +1,57 @@ +import SwiftUI +import UIKit +@testable import LUIAppleBackend + +@main struct LocalizedDialogCancelCheck { + @MainActor static func main() async throws { + for (cancel, delete) in [("Cancel", "Delete"), ("取消", "删除"), ("キャンセル", "削除"), ("Annuler", "Supprimer")] { + let backend = LUIAppleBackend() + let operations: [[String: Any]] = [ + ["op": "create-node", "id": 1, "kind": "column"], + ["op": "create-node", "id": 2, "kind": "dialog"], + ["op": "create-node", "id": 3, "kind": "button"], + ["op": "create-node", "id": 4, "kind": "button"], + ["op": "set-prop", "id": 2, "property": "text", "value": delete], + ["op": "set-prop", "id": 2, "property": "style-class", "value": "alert"], + ["op": "set-prop", "id": 3, "property": "text", "value": cancel], + ["op": "set-prop", "id": 3, "property": "style-class", "value": "quiet cancellation-action"], + ["op": "set-prop", "id": 4, "property": "text", "value": delete], + ["op": "set-prop", "id": 4, "property": "variant", "value": "destructive"], + ["op": "insert-child", "parent": 1, "child": 2, "index": 0], + ["op": "insert-child", "parent": 2, "child": 3, "index": 0], + ["op": "insert-child", "parent": 2, "child": 4, "index": 1] + ] + let json = String(decoding: try JSONSerialization.data(withJSONObject: ["generation": 1, "ops": operations]), as: UTF8.self) + try backend.apply(json: json) + let dialog = backend.model(id: 2)! + guard LUIDialogContentPolicy.cancelActionID(dialog: dialog, backend: backend) == 3, + LUIDialogContentPolicy.nonCancelActionIDs(dialog: dialog, backend: backend) == [4] else { + throw NSError(domain: "LocalizedDialogCancel", code: 1, + userInfo: [NSLocalizedDescriptionKey: "A localized dialog must have exactly one semantic cancellation action: \(cancel)"]) + } + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + let host = UIHostingController(rootView: LUISwiftUIRoot(backend: backend, rootID: 1)) + window.rootViewController = host + window.makeKeyAndVisible() + var alert: UIAlertController? + for _ in 0..<100 { + if let presented = host.presentedViewController as? UIAlertController { + alert = presented + break + } + try await Task.sleep(for: .milliseconds(20)) + } + guard let alert, alert.actions.count == 2, + alert.actions.filter({ $0.style == .cancel }).map(\.title) == [cancel], + alert.actions.filter({ $0.style == .destructive }).map(\.title) == [delete] else { + throw NSError(domain: "LocalizedDialogCancel", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Native alerts must not append another Cancel button: \(cancel)"]) + } + host.dismiss(animated: false) + window.isHidden = true + window.rootViewController = nil + } + print("PASS: English, Chinese, Japanese and French alerts each render one Cancel and one Delete") + } +} diff --git a/apple/Tests/UI/outliner-return-handoff.swift b/apple/Tests/UI/outliner-return-handoff.swift index 37e31cdc..f6da5f6b 100644 --- a/apple/Tests/UI/outliner-return-handoff.swift +++ b/apple/Tests/UI/outliner-return-handoff.swift @@ -117,23 +117,30 @@ struct ReturnHandoffCheck { print("FAIL: rapid Enter, Shift+Enter, and typing must preserve the new block text; returns=\(model.returns) expected=\(beforeRapid + 1) title=\(model.title.debugDescription) caret=\(editor().selectedRange.location)"); failures += 1 } for code in [true, false] { - model.inputPolicy = "{\"multiline\":false,\"code\":\(code)}" + model.inputPolicy = "{\"multiline\":\(code),\"code\":\(code)}" model.title = "x^2"; model.blockID = code ? "code" : "math"; model.caret = 3 await settle() let beforeReturn = model.returns type("\n", in: editor()) await settle() + if code { + if model.returns != beforeReturn || model.title != "x^2\n" { + print("FAIL: Enter in Code and Calculator must insert a newline"); failures += 1 + } + shiftEnter() + await settle() + } if model.returns != beforeReturn + 1 || !model.title.isEmpty { - print("FAIL: Enter in Code and Math must create a new block"); failures += 1 + print("FAIL: Shift+Enter in Code and Calculator, and Enter in Math, must create a new block"); failures += 1 } model.title = "🙂alpha beta"; model.blockID += "-newline"; model.caret = 12 await settle() editor().selectedRange = NSRange(location: 2, length: 5) let beforeNewline = model.returns - shiftEnter() + if code { type("\n", in: editor()) } else { shiftEnter() } await settle() if model.returns != beforeNewline || model.title != "🙂\n beta" || editor().selectedRange.location != 3 { - print("FAIL: Shift+Enter in Code and Math must replace the selected text with a newline"); failures += 1 + print("FAIL: the newline key must replace a UTF-16 selection inside Code and Math"); failures += 1 } } model.inputPolicy = "{}" diff --git a/logseq.opam b/logseq.opam index 112416dd..41f138b4 100644 --- a/logseq.opam +++ b/logseq.opam @@ -34,7 +34,7 @@ pin-depends: [ ["melange-transit-core.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-native.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-melange.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] - ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#d3b8df7a2837bc6aa2f4ca56dc1bfe5ba42dff4e"] + ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#f90ee61c2460f7d67d6e169863ff096b50b34d0f"] ["drive.dev" "git+https://github.com/logseq/drive.git#dd735a65ed525d1c36db38ac8e8114aa81d314a1"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"] ["mldoc.dev" "git+https://github.com/logseq/mldoc.git#f11230c367964af2b575524d00779cfb1ffc7147"] diff --git a/scripts/test-ios-native-interactions.py b/scripts/test-ios-native-interactions.py index 426e415b..9eb429d4 100644 --- a/scripts/test-ios-native-interactions.py +++ b/scripts/test-ios-native-interactions.py @@ -14,6 +14,7 @@ ("outliner-backspace-boundary.swift", "OutlinerBackspaceBoundaryCheck"), ("outliner-subtree-selection.swift", "OutlinerSubtreeSelectionCheck"), ("special-block-presentation.swift", "SpecialBlockPresentationCheck"), + ("localized-dialog-cancel.swift", "LocalizedDialogCancelCheck"), ("outliner-autosave.swift", "Check")]: source = directory / filename source.write_text("@testable import Logseq\n" + (root / "apple/Tests/UI" / filename).read_text()) diff --git a/shared/src/logseq/core/node_title.ml b/shared/src/logseq/core/node_title.ml index bebd0c66..f03b68e6 100644 --- a/shared/src/logseq/core/node_title.ml +++ b/shared/src/logseq/core/node_title.ml @@ -18,7 +18,10 @@ let builtin db id = get db id "logseq.property/built-in?" = Some (D.Bool true) let journal db id = get db id "block/journal-day" <> None || tagged db id "logseq.class/Journal" let can_edit db id = not (builtin db id || journal db id) let can_rename_page db id = is_page db id && can_edit db id -let can_delete db id = not (builtin db id) && get db id "logseq.property/hide?" <> Some (D.Bool true) +let can_delete ?(now = int_of_float (Unix.gettimeofday () *. 1000.)) db id = + not (builtin db id) + && get db id "logseq.property/hide?" <> Some (D.Bool true) + && G.int_value (get db id "block/journal-day") <> Some (Cache_model.journal_day_for_ms now) let nfc text = let normalizer = Uunf.create `NFC in diff --git a/shared/src/logseq/core/node_title.mli b/shared/src/logseq/core/node_title.mli index d1f0c7b4..92276371 100644 --- a/shared/src/logseq/core/node_title.mli +++ b/shared/src/logseq/core/node_title.mli @@ -1,7 +1,7 @@ val is_page : Datascript.db -> int -> bool val is_class : Datascript.db -> int -> bool val is_property : Datascript.db -> int -> bool -val can_delete : Datascript.db -> int -> bool +val can_delete : ?now:int -> Datascript.db -> int -> bool val can_edit : Datascript.db -> int -> bool val can_rename_page : Datascript.db -> int -> bool val name : string -> string diff --git a/shared/src/logseq/core/pending_projection.ml b/shared/src/logseq/core/pending_projection.ml index 49cac852..3e4cff66 100644 --- a/shared/src/logseq/core/pending_projection.ml +++ b/shared/src/logseq/core/pending_projection.ml @@ -868,8 +868,8 @@ let rec compile_with_literals literal_blocks db (intent : Ops.pending_intent) = = Some recycle_eid then Ok [] else if - not (Node_title.can_delete db page_eid) - then Error "Built-in page cannot be deleted" + not (Node_title.can_delete ~now:value.deleted_at db page_eid) + then Error "Protected page cannot be deleted" else let parent = match one_value db reference "block/parent" with diff --git a/shared/src/logseq/view_graphs.ml b/shared/src/logseq/view_graphs.ml index 1380c24b..fd5291f6 100644 --- a/shared/src/logseq/view_graphs.ml +++ b/shared/src/logseq/view_graphs.ml @@ -342,7 +342,7 @@ let graph_delete_dialog (context : Lui_ui.ui_context) model_source send : t ~label:(View_base.tr (Signal.sample model_source) "Graph deletion actions") ~accessibility_identifier:"toolbar.graph-delete" [ - button ~variant:`ghost + button ~variant:`ghost ~style_class:"cancellation-action" ~accessibility_identifier: "button.graph-delete.cancel" ~on_press:(press send Model.CancelDeleteGraph) @@ -372,7 +372,8 @@ let graph_delete_dialog (context : Lui_ui.ui_context) model_source send : t "Notice that we can't recover this graph after being \ deleted. Make sure you have backups before deleting it." []; - button ~accessibility_identifier:"button.graph-delete.cancel" + button ~style_class:"cancellation-action" + ~accessibility_identifier:"button.graph-delete.cancel" ~on_press:(press send Model.CancelDeleteGraph) ~text:(View_base.tr (Signal.sample model_source) "Cancel") []; button ~variant:`destructive diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index a579b2d8..84b5e309 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -12,7 +12,7 @@ let outliner_editor_view model_source block_id_source title_source caret_source if row.row_uuid = uuid then row.row_display_type else None) current.outliner_rows in let special = List.mem display_type [Some "code"; Some "math"] in StringValue (if special then Yojson.Basic.to_string (`Assoc [ - "multiline", `Bool false; "code", `Bool (display_type = Some "code"); + "multiline", `Bool (display_type = Some "code"); "code", `Bool (display_type = Some "code"); "emptyPairs", `List [`Assoc ["text", `String "[[]]"; "deleteOffset", `Int 1]]]) else Outliner_state.native_input_policy)) model_source block_id_source); Lui_ui.extension_property_signal context node "focus-enabled" diff --git a/shared/src/logseq/view_settings.ml b/shared/src/logseq/view_settings.ml index 26ca9a24..0073ec66 100644 --- a/shared/src/logseq/view_settings.ml +++ b/shared/src/logseq/view_settings.ml @@ -726,7 +726,7 @@ let sign_out_dialog model_source (context : Lui_ui.ui_context) send : t = ~label:(View_base.tr (Signal.sample model_source) "Sign out actions") ~accessibility_identifier:"toolbar.sign-out" [ - button ~variant:`ghost + button ~variant:`ghost ~style_class:"cancellation-action" ~accessibility_identifier: "button.sign-out.cancel" ~on_press:(press send Model.CancelSignOut) @@ -751,7 +751,8 @@ let sign_out_dialog model_source (context : Lui_ui.ui_context) send : t = ~value: "Sync will pause. Downloaded graphs remain available on this device." []; - button ~accessibility_identifier:"button.sign-out.cancel" + button ~style_class:"cancellation-action" + ~accessibility_identifier:"button.sign-out.cancel" ~on_press:(press send Model.CancelSignOut) ~text:(View_base.tr (Signal.sample model_source) "Cancel") []; button ~variant:`destructive @@ -1166,7 +1167,7 @@ let page_delete_dialog model_source (context : Lui_ui.ui_context) send : t = ~label:(View_base.tr (Signal.sample model_source) "Page deletion actions") ~accessibility_identifier:"toolbar.page-delete" [ - button ~variant:`ghost + button ~variant:`ghost ~style_class:"cancellation-action" ~accessibility_identifier: "button.page-delete.cancel" ~on_press:(press send Model.CancelDeleteActivePage) @@ -1188,7 +1189,8 @@ let page_delete_dialog model_source (context : Lui_ui.ui_context) send : t = ~on_dismiss:(press send Model.CancelDeleteActivePage) [ text ~value:(View_base.tr (Signal.sample model_source) "The page will be moved to Recycle.") []; - button ~accessibility_identifier:"button.page-delete.cancel" + button ~style_class:"cancellation-action" + ~accessibility_identifier:"button.page-delete.cancel" ~on_press:(press send Model.CancelDeleteActivePage) ~text:(View_base.tr (Signal.sample model_source) "Cancel") []; button ~variant:`destructive diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index 6f012b27..f225f5c2 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -431,8 +431,8 @@ let code_commands_update_the_active_editor_style () = | Some (Lui_protocol.StringValue value) -> J.from_string value | _ -> `Null in check_eq ~msg:"Code, Calculator and Math commands immediately select their editor presentation" (member "code" policy) (`Bool (label <> "Math block")); - check_eq ~msg:"Enter submits a new block while Shift+Enter remains an explicit line break" - (member "multiline" policy) (`Bool false)))) + check_eq ~msg:"Enter inserts a line break in Code and Calculator while Math creates a block" + (member "multiline" policy) (`Bool (label <> "Math block"))))) ["Code block"; "Calculator"; "Math block"]) [App_test.ios_backend; App_test.kotlin_backend] let calculator_last_tracks_successful_lines () = R.with_runtime (fun session _ _ -> diff --git a/shared/test/logseq/graph_runtime_test.ml b/shared/test/logseq/graph_runtime_test.ml index 3a39acbd..dcfdf8e6 100644 --- a/shared/test/logseq/graph_runtime_test.ml +++ b/shared/test/logseq/graph_runtime_test.ml @@ -304,6 +304,27 @@ let protected_page_deletion_never_stages_mutations () = "built-in class without a flag", [add 1 "db/ident" (Ds.Keyword "logseq.class/Protected")]; "built-in property without a flag", [add 1 "db/ident" (Ds.Keyword "logseq.property/protected-test")]] +let today_journal_deletion_never_stages_mutations () = + with_store (fun path -> + let now = Session_types.now_ms () in + let today = Model.journal_day_for_ms now in + let database = Ds.db_with [ + add 1 "block/journal-day" (Ds.Int64 (Int64.of_int today)); + add 20 "block/uuid" (Ds.Uuid "recycle-page"); + add 20 "block/title" (Ds.String "Recycle"); + add 20 "block/name" (Ds.String "recycle"); + add 20 "logseq.property/built-in?" (Ds.Bool true) + ] (base_db "Keep today's content") in + let current = Runtime.create path 42 (Ds.conn_from_db database) Runtime.default_options in + check ~msg:"today's journal cannot be deleted even through the mutation API" + (not (is_ok (Runtime.delete_page current "page" "delete-today" now))); + check_eq ~msg:"rejected deletion does not enqueue a durable operation" + (Runtime.pending_operations current) []; + check_eq ~msg:"rejected deletion preserves the journal" + (Graph_read.value (Runtime.db current) 1 "logseq.property/deleted-at") None; + check_eq ~msg:"rejected deletion preserves its content" + (Graph_read.value (Runtime.db current) 10 "block/title") (Some (Ds.String "Keep today's content"))) + let flashcard_review_is_one_atomic_millisecond_operation () = with_store (fun path -> let now = 1776000000000 in @@ -1902,6 +1923,7 @@ let cases = case "favorites update sidebar and encode the link transaction" favorites_update_sidebar_and_encode_the_link_transaction; case "protected deletions never stage mutations" protected_page_deletion_never_stages_mutations; + case "today journal deletion never stages mutations" today_journal_deletion_never_stages_mutations; case "page deletion is optimistic durable and rejects built-ins" page_deletion_is_optimistic_durable_and_rejects_built_ins; case "flashcard review is one atomic millisecond operation" diff --git a/shared/test/logseq/localization_test.ml b/shared/test/logseq/localization_test.ml index b0dbd6e4..6f9515ae 100644 --- a/shared/test/logseq/localization_test.ml +++ b/shared/test/logseq/localization_test.ml @@ -96,7 +96,40 @@ let native_search_controls_receive_translated_labels () = ["back-title", "返回"; "close-title", "关闭"; "clear-title", "清空"]) [ios_backend; kotlin_backend] -let cases = [case "native search controls receive translated labels" native_search_controls_receive_translated_labels; +let confirmation_cancel_actions_are_semantic_and_localized () = + List.iter (fun backend -> List.iter (fun language -> + List.iter (fun (identifier, request) -> + let app = App.create (backend ()) in + let local = graph "local" "Local graph" false true in + start app; + send app (Model.ApplyCoreSnapshot { (empty_core_projection ()) with + graphs = [local]; selected_graph_id = Some "local"; + sidebar = { (empty_sidebar_projection ()) with + selected_page = Some {Model.uuid = "page"; title = "Project"} } }); + send app (Model.ApplyLocalGraphIds ["local"]); + send app (Model.ApplySettingsSnapshot { (settings []) with Model.language = language }); + send app request; + flush app; + let runtime = Lui_app.runtime app in + let cancel = descendant_with_identifier runtime (Lui_app.root_node app) identifier in + check ~msg:("dialog exposes its cancellation action: " ^ identifier) (cancel <> -1); + check_eq ~msg:"cancel labels remain localized" + (property_string runtime cancel Lui_protocol.TextValue) + (Localization.translate ~language ~system_language:"en" "Cancel"); + check ~msg:"native cancellation is explicit rather than inferred from translated text" + (String.split_on_char ' ' (property_string runtime cancel Lui_protocol.StyleClass) + |> List.mem "cancellation-action"); + dispatch app (Lui_protocol.Press cancel); flush app; + check_eq ~msg:"cancelling never deletes a graph or page" + (List.filter (function Model.DeleteLocalGraphEffect _ | Model.DeletePageEffect _ -> true | _ -> false) + (App.model app).pending_effects) []) + ["button.graph-delete.cancel", Model.RequestDeleteGraph "local"; + "button.page-delete.cancel", Model.RequestDeleteActivePage; + "button.sign-out.cancel", Model.RequestSignOut]) + ["en"; "zh-CN"; "ja"]) [ios_backend; kotlin_backend] + +let cases = [case "confirmation cancel actions are semantic and localized" confirmation_cancel_actions_are_semantic_and_localized; + case "native search controls receive translated labels" native_search_controls_receive_translated_labels; case "localization" language_settings_translate_controls_without_translating_notes; case "tag and property editors use desktop translations" tag_and_property_editors_use_desktop_translations; case "property titles preserve graph names" property_titles_preserve_graph_names; diff --git a/shared/test/logseq/node_property_test.ml b/shared/test/logseq/node_property_test.ml index 7cd43414..d9671264 100644 --- a/shared/test/logseq/node_property_test.ml +++ b/shared/test/logseq/node_property_test.ml @@ -348,6 +348,7 @@ let page_rename_updates_lookup_search_and_references () = let builtin_pages_hide_delete_and_reject_deletion_requests () = let base = R.fixture () in + let today = Cache_model.journal_day_for_ms (Session_types.now_ms ()) in List.iter (fun (label, additions, allowed) -> let db = Ds.db_with additions base in let session, _ = R.make_session db in @@ -372,6 +373,9 @@ let builtin_pages_hide_delete_and_reject_deletion_requests () = (not (List.exists (function Model.DeletePageEffect _ -> true | _ -> false) confirmed.pending_effects))))) [App_test.ios_backend; App_test.kotlin_backend]) ["ordinary page", [], true; + "today's journal", [put Q.page "block/journal-day" (Ds.Int64 (Int64.of_int today))], false; + "past journal", [put Q.page "block/journal-day" (Ds.Int64 20200101L)], true; + "future journal", [put Q.page "block/journal-day" (Ds.Int64 20991231L)], true; "built-in page", [put Q.page "logseq.property/built-in?" (Ds.Bool true)], false; "built-in property without flag", [put Q.page "db/ident" (Ds.Keyword "logseq.property/protected-test")], false; "hidden system page", [put Q.page "logseq.property/hide?" (Ds.Bool true)], false] diff --git a/shared/test/logseq/pending_projection_test.ml b/shared/test/logseq/pending_projection_test.ml index f27466b1..5e98e800 100644 --- a/shared/test/logseq/pending_projection_test.ml +++ b/shared/test/logseq/pending_projection_test.ml @@ -687,6 +687,31 @@ let recycling_pages_hides_descendants_and_preserves_original_location () = (Some (Ds.String "a1")); check_eq (title recycled "page") "Page" +let journal_deletion_uses_the_recorded_local_day () = + let midnight, _ = Unix.mktime { (Unix.localtime 0.) with + Unix.tm_year = 199; tm_mon = 0; tm_mday = 1; + tm_hour = 0; tm_min = 0; tm_sec = 0; tm_isdst = false } in + let midnight = int_of_float (midnight *. 1000.) in + let day = Cache_model.journal_day_for_ms midnight in + let db = with_tx (base_db ()) [ + add 1 "block/journal-day" (Ds.Int64 (Int64.of_int day)); + add 30 "block/uuid" (Ds.Uuid "recycle-page"); + add 30 "block/title" (Ds.String "Recycle"); + add 30 "block/name" (Ds.String "recycle"); + add 30 "logseq.property/built-in?" (Ds.Bool true) + ] in + List.iter (fun (label, deleted_at, allowed) -> + let intent = Ops.Delete_page {Ops.page_uuid = "page"; order = "a0"; deleted_at} in + let result = Projection.compile db intent in + check_eq ~msg:(label ^ " uses the operation's local date during replay") + (Result.is_ok result) allowed; + check_eq ~msg:"compiling never mutates the authoritative journal" + (value db "page" "logseq.property/deleted-at") None) + ["future journal before midnight", midnight - 1, true; + "today's journal at midnight", midnight, false; + "today's journal before next midnight", midnight + 86399999, false; + "past journal after next midnight", midnight + 86400000, true] + let new_tags_can_be_referenced_by_later_pending_edits () = let db = base_db () in let intent = @@ -1308,6 +1333,7 @@ let cases = case "recycling pages hides descendants and preserves original location" recycling_pages_hides_descendants_and_preserves_original_location; + case "journal deletion uses the recorded local day" journal_deletion_uses_the_recorded_local_day; case "new tags can be referenced by later pending edits" new_tags_can_be_referenced_by_later_pending_edits; case "partial journal creation restores the missing first block"