From c42647779071db2da8a1ee24d6524b6410a222c4 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 26 Oct 2026 23:52:57 +0800 Subject: [PATCH] Fix quote editing, historical dates, and iPad controls --- .../app/AndroidFeedbackRecordingTest.kt | 21 ++- .../logseq/app/AndroidNodePropertiesTest.kt | 51 +++++++ .../com/logseq/app/runtime/LuiDispatch.kt | 24 +++- .../app/ui/extensions/OutlinerEditor.kt | 20 ++- .../app/ui/extensions/PropertyExtensions.kt | 55 +++++++- .../Logseq/LGNavigationExtension.swift | 133 +++++++++++++++--- apple/Tests/UI/ipad-floating-keyboard.swift | 99 +++++++++++++ apple/Tests/UI/run-simulator-runtime-check.py | 2 +- apple/Tests/UI/sidebar-long-graph-name.swift | 66 +++++++++ shared/src/logseq/core/block_clipboard.ml | 2 +- shared/src/logseq/core/dev_query.ml | 16 ++- shared/src/logseq/core/dev_query.mli | 1 + shared/src/logseq/core/editor_command.ml | 18 +-- shared/src/logseq/core/editor_command.mli | 3 +- .../src/logseq/core/editor_command_effects.ml | 12 +- shared/src/logseq/core/journal.ml | 9 ++ shared/src/logseq/core/journal.mli | 1 + shared/src/logseq/core/outliner_state.ml | 24 +++- shared/src/logseq/core/outliner_state.mli | 1 + shared/src/logseq/core/pending_projection.ml | 49 +++++-- shared/src/logseq/core/result_view.ml | 2 +- shared/src/logseq/core/rpc_wire.ml | 1 + shared/src/logseq/localization.ml | 26 ++++ shared/src/logseq/view_outliner.ml | 12 ++ shared/src/logseq/view_query.ml | 12 +- shared/test/logseq/block_clipboard_test.ml | 19 +++ shared/test/logseq/editor_command_test.ml | 73 ++++++++++ shared/test/logseq/journal_format_test.ml | 8 ++ shared/test/logseq/node_property_test.ml | 52 +++++++ 29 files changed, 740 insertions(+), 72 deletions(-) create mode 100644 apple/Tests/UI/ipad-floating-keyboard.swift create mode 100644 apple/Tests/UI/sidebar-long-graph-name.swift 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 27b295dc..46ca5432 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidFeedbackRecordingTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidFeedbackRecordingTest.kt @@ -217,7 +217,7 @@ class AndroidFeedbackRecordingTest(private val scenario: String) { ui.onNodeWithTag("button.outliner.code-language.option.python").performClick(); pause() } } - "calculator-live" -> { + "calculator-live", "calculator-toolbar" -> { mount(2) val configured = request("nodeProperty", JSONObject().put("action", "setCodeLanguage") .put("blockUuid", uuid(12)).put("value", "calc").toString()) @@ -245,13 +245,30 @@ class AndroidFeedbackRecordingTest(private val scenario: String) { 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) } + if (scenario == "calculator-toolbar") { + ui.mainClock.autoAdvance = false + try { + editor.performTextInput("pending🙂") + ui.onNodeWithTag("button.outliner.editor.newBlock").performClick() + drain() + } finally { + ui.mainClock.autoAdvance = true + } + } else { + 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") assertEquals("Shift+Enter creates exactly one new block", beforeCount + 1, after.length()) + if (scenario == "calculator-toolbar") { + val source = (0 until after.length()).map { after.getJSONObject(it).getJSONObject("block") } + .single { it.getString("uuid") == uuid(12) } + assertEquals("The toolbar preserves typing that has not reached the debounced core update", + "6*7\nlast*3\npending🙂", source.getString("title")) + } content.performTouchInput { longClick() } ui.waitUntil(10_000) { drain() diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidNodePropertiesTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidNodePropertiesTest.kt index 97739e16..ac1b6233 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidNodePropertiesTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidNodePropertiesTest.kt @@ -18,7 +18,13 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.espresso.Espresso.closeSoftKeyboard import androidx.test.platform.app.InstrumentationRegistry import com.logseq.app.runtime.LogseqNativeBridge import com.logseq.app.runtime.LuiPatchFilter @@ -44,6 +50,51 @@ class AndroidNodePropertiesTest { if (::bridge.isInitialized) ui.runOnIdle { bridge.close() } } + @Test fun historicalDatesCanBeOpenedTypedAndSelected() { + val backend = LuiBackend(onEvent = { dispatchLuiEvent(it, bridge) }, + extensions = logseqExtensionRegistry { it }, icons = logseqIconResolver) + val filter = LuiPatchFilter(backend) + bridge = LogseqNativeBridge(onPatch = { if (it.isNotEmpty()) filter.applyJson(it) }) + val root = bridge.initialize(authenticationCode = 3) + val snapshot = JSONObject(InstrumentationRegistry.getInstrumentation().context.assets + .open("node-properties.json").bufferedReader().use { it.readText() }) + val properties = snapshot.getJSONObject("result").getJSONObject("nodeProperties").getJSONArray("properties") + for (i in 0 until properties.length()) { + val property = properties.getJSONObject(i) + if (property.getString("attr") == "user.property/Due") property.put("editorValue", "0044-03-15") + } + filter.applyJson(bridge.applySnapshot(snapshot.toString())) + bridge.applyHostUpdate("graph-loading", "false").takeIf { it.isNotEmpty() }?.let(filter::applyJson) + ui.setContent { MaterialExpressiveTheme(colorScheme = LogseqTheme.lightScheme, typography = LogseqTheme.typography) { + Box(Modifier.fillMaxSize()) { backend.Content(root) } + } } + ui.onNodeWithTag("properties.expand.page").performClick() + val editor = "property.page.user.property/Due" + ui.onNodeWithTag("property.value.page.user.property/Due").performScrollTo().performClick() + fun input() = ui.onNodeWithTag("view.edit.date.input.$editor") + input().assertTextEquals("0044-03-15") + val calendar = hasAnyAncestor(hasTestTag("view.edit.date.$editor")) + ui.onNode(hasText("March 15", substring = true) and calendar, useUnmergedTree = true).performScrollTo().performClick() + input().assertTextEquals("0044-03-15") + input().performTextReplacement("2026-10-26") + closeSoftKeyboard() + ui.onNode(hasText("October 23", substring = true) and calendar, useUnmergedTree = true).performScrollTo().performClick() + input().assertTextEquals("2026-10-23") + input().performTextReplacement("0044-03-15") + ui.onNodeWithTag("view.edit.save.$editor").performClick() + ui.runOnIdle { + var selected: JSONObject? = null + repeat(32) { + val encoded = bridge.takeEffect() + if (encoded.isNotEmpty()) { + val effect = JSONObject(encoded).optJSONObject("effect") + if (effect?.optString("kind") == "node-property") selected = JSONObject(effect.getString("text")) + } + } + assertEquals("Historical dates remain calendar dates across the native bridge", "0044-03-15", selected?.getString("value")) + } + } + @Test fun pagePropertiesUseBlocksAndNativeTypedEditors() { val backend = LuiBackend(onEvent = { dispatchLuiEvent(it, bridge) }, extensions = logseqExtensionRegistry { it }, icons = logseqIconResolver) diff --git a/android/app/src/main/kotlin/com/logseq/app/runtime/LuiDispatch.kt b/android/app/src/main/kotlin/com/logseq/app/runtime/LuiDispatch.kt index 1a1bcb2e..535217f8 100644 --- a/android/app/src/main/kotlin/com/logseq/app/runtime/LuiDispatch.kt +++ b/android/app/src/main/kotlin/com/logseq/app/runtime/LuiDispatch.kt @@ -2,13 +2,35 @@ package com.logseq.app.runtime import dev.lui.LuiEvent +internal object NativeEditorInput { + private var owner: Any? = null + private var pending: (() -> Unit)? = null + + fun register(owner: Any, flush: () -> Unit) { + this.owner = owner + pending = flush + } + + fun unregister(owner: Any) { + if (this.owner === owner) { + this.owner = null + pending = null + } + } + + fun flush() { pending?.invoke() } +} + // Kotlin port of the former lui_dispatch.dart: LUI events → native // bridge calls, including the extension (text, value) extraction the OCaml // side reinterprets by identifier/name. internal fun dispatchLuiEvent(event: LuiEvent, native: LogseqNativeBridge) { when (event) { is LuiEvent.Appear -> native.appear(event.node) - is LuiEvent.Press -> native.press(event.node) + is LuiEvent.Press -> { + NativeEditorInput.flush() + native.press(event.node) + } is LuiEvent.LongPress -> native.longPress(event.node) is LuiEvent.DoublePress -> native.doublePress(event.node) is LuiEvent.Change -> native.change(event.node) 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 61c48f23..2cabfcbd 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf @@ -48,6 +49,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import dev.lui.LuiExtensionContext +import com.logseq.app.runtime.NativeEditorInput import kotlinx.coroutines.delay // Port of the former _OutlinerEditor Dart implementation. @@ -169,7 +171,23 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { "caret-utf16-offset" to caretOffset(value, title), ) } - DisposableEffect(Unit) { onDispose { debounceSeq++ } } + val inputOwner = remember { Any() } + SideEffect { + if (focusEnabled) NativeEditorInput.register(inputOwner) { + if (!awaitingHandoff && value.text != modelTitle && value.text != publishedTitles.lastOrNull()) { + debounceSeq++ + publishedTitles.add(value.text) + context.emit("text-change", "title" to value.text, + "caret-utf16-offset" to caretOffset(value, value.text)) + } + } else NativeEditorInput.unregister(inputOwner) + } + DisposableEffect(Unit) { + onDispose { + debounceSeq++ + NativeEditorInput.unregister(inputOwner) + } + } fun submitReturn(title: String, caret: Int) { if (awaitingHandoff) { diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt index 13bf5849..69a434ed 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/PropertyExtensions.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDefaults +import androidx.compose.material3.DatePickerFormatter import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TimeInput import androidx.compose.material3.rememberDatePickerState @@ -36,6 +38,12 @@ import dev.lui.LuiExtensionValueKind import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import java.util.Date +import java.util.Locale +import android.icu.text.DateFormat +import android.icu.text.DisplayContext +import android.icu.util.GregorianCalendar +import android.icu.util.TimeZone import java.time.ZoneOffset import kotlinx.coroutines.flow.distinctUntilChanged @@ -121,8 +129,26 @@ private fun PropertyDatePicker(context: LuiExtensionContext) { if (time) (source.toLongOrNull()?.let(Instant::ofEpochMilli) ?: Instant.now()).atZone(zone) else (runCatching { LocalDate.parse(source) }.getOrDefault(LocalDate.now())).atStartOfDay(zone) } - val date = rememberDatePickerState(initialSelectedDateMillis = initial.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()) + val date = rememberDatePickerState( + initialSelectedDateMillis = initial.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(), + yearRange = 1..9999, + ) val clock = rememberTimePickerState(initialHour = initial.hour, initialMinute = initial.minute) + LaunchedEffect(source) { + val selected = if (time) source.toLongOrNull()?.let { Instant.ofEpochMilli(it).atZone(zone) } + else runCatching { LocalDate.parse(source).atStartOfDay(zone) }.getOrNull() + if (selected != null && selected.year in date.yearRange) { + val millis = selected.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + if (date.selectedDateMillis != millis) { + date.selectedDateMillis = millis + date.displayedMonthMillis = millis + } + if (time) { + clock.hour = selected.hour + clock.minute = selected.minute + } + } + } LaunchedEffect(Unit) { snapshotFlow { Triple(date.selectedDateMillis, clock.hour, clock.minute) }.distinctUntilChanged().collect { (millis, hour, minute) -> if (millis != null) { @@ -133,11 +159,36 @@ private fun PropertyDatePicker(context: LuiExtensionContext) { } } Column(Modifier.fillMaxWidth().testTag(context.string("identifier") ?: "")) { - DatePicker(state = date, showModeToggle = false) + DatePicker(state = date, dateFormatter = CalendarDateFormatter, showModeToggle = false) if (time) TimeInput(state = clock) } } +@OptIn(ExperimentalMaterial3Api::class) +private object CalendarDateFormatter : DatePickerFormatter { + private val formatters = mutableMapOf, DateFormat>() + + private fun format(millis: Long?, skeleton: String, locale: Locale): String? = millis?.let { + val formatter = formatters.getOrPut(skeleton to locale) { + DateFormat.getInstanceForSkeleton(skeleton, locale).apply { + // Keep ICU labels on the same proleptic Gregorian calendar as the picker state. + calendar = GregorianCalendar(TimeZone.GMT_ZONE, locale).apply { + gregorianChange = Date(Long.MIN_VALUE) + } + setContext(DisplayContext.CAPITALIZATION_FOR_STANDALONE) + } + } + formatter.format(Date(it)) + } + + override fun formatMonthYear(monthMillis: Long?, locale: Locale): String? = + format(monthMillis, DatePickerDefaults.YearMonthSkeleton, locale) + + override fun formatDate(dateMillis: Long?, locale: Locale, forContentDescription: Boolean): String? = + format(dateMillis, if (forContentDescription) DatePickerDefaults.YearMonthWeekdayDaySkeleton + else DatePickerDefaults.YearAbbrMonthDaySkeleton, locale) +} + @Composable private fun TableViewport(context: LuiExtensionContext) { val scroll = rememberScrollState() diff --git a/apple/Sources/Logseq/LGNavigationExtension.swift b/apple/Sources/Logseq/LGNavigationExtension.swift index 92534b5e..e0ec79ff 100644 --- a/apple/Sources/Logseq/LGNavigationExtension.swift +++ b/apple/Sources/Logseq/LGNavigationExtension.swift @@ -22,8 +22,77 @@ enum LGNavigationSurfacePolicy { ) -> CGFloat { max(iconWidth, minimumHitTarget) } + + static func sidebarGraphWidth(_ availableWidth: CGFloat) -> CGFloat { + // Keep room for Settings, the system sidebar toggle, and toolbar margins. + max(44, availableWidth - 2 * 44 - 3 * 16) + } } +#if os(iOS) +private struct LGDockedKeyboardInsetReader: UIViewRepresentable { + let update: (CGFloat) -> Void + + func makeUIView(context: Context) -> Probe { Probe(update: update) } + func updateUIView(_ view: Probe, context: Context) { view.update = update } + + final class Probe: UIView { + var update: (CGFloat) -> Void + private var marker: Marker? + + init(update: @escaping (CGFloat) -> Void) { + self.update = update + super.init(frame: .zero) + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func didMoveToWindow() { + super.didMoveToWindow() + marker?.removeFromSuperview() + marker = nil + guard let host = window?.rootViewController?.view else { return } + // Observe the root view's docked guide; the rendered content may already + // have been resized by SwiftUI's floating-keyboard avoidance. + host.keyboardLayoutGuide.followsUndockedKeyboard = false + let marker = Marker { [weak self] inset in self?.update(inset) } + marker.translatesAutoresizingMaskIntoConstraints = false + marker.isUserInteractionEnabled = false + host.addSubview(marker) + NSLayoutConstraint.activate([ + marker.topAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor), + marker.bottomAnchor.constraint(equalTo: host.bottomAnchor), + marker.leadingAnchor.constraint(equalTo: host.leadingAnchor), + marker.widthAnchor.constraint(equalToConstant: 1), + ]) + self.marker = marker + } + } + + private final class Marker: UIView { + let update: (CGFloat) -> Void + private var lastInset: CGFloat = -1 + + init(update: @escaping (CGFloat) -> Void) { + self.update = update + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + guard let host = superview else { return } + let inset = max(0, host.bounds.maxY - host.keyboardLayoutGuide.layoutFrame.minY - host.safeAreaInsets.bottom) + guard abs(inset - lastInset) > 0.5 else { return } + lastInset = inset + DispatchQueue.main.async { [weak self] in self?.update(inset) } + } + } +} +#endif + @MainActor enum LGNavigationExtension { static let identifier = "native-navigation-stack" @@ -180,32 +249,40 @@ private struct LGSidebarNavigation: View { var body: some View { let _ = context.revision - NavigationStack { - child(at: 0) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(background.ignoresSafeArea()) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - .toolbarBackground(background, for: .navigationBar) - #endif - .toolbar { + GeometryReader { geometry in + NavigationStack { + child(at: 0) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(background.ignoresSafeArea()) #if os(iOS) - if #available(iOS 26.0, *) { - ToolbarItem(placement: .topBarLeading) { - child(at: 1).fixedSize(horizontal: true, vertical: false) - } - .sharedBackgroundVisibility(.hidden) - ToolbarItem(placement: .topBarTrailing) { child(at: 2) } + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(background, for: .navigationBar) + #endif + .toolbar { + #if os(iOS) + if #available(iOS 26.0, *) { + ToolbarItem(placement: .topBarLeading) { + child(at: 1) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: LGNavigationSurfacePolicy.sidebarGraphWidth(geometry.size.width)) + } .sharedBackgroundVisibility(.hidden) - } else { - ToolbarItem(placement: .topBarLeading) { child(at: 1) } - ToolbarItem(placement: .topBarTrailing) { child(at: 2) } + ToolbarItem(placement: .topBarTrailing) { child(at: 2) } + .sharedBackgroundVisibility(.hidden) + } else { + ToolbarItem(placement: .topBarLeading) { + child(at: 1).lineLimit(1).truncationMode(.middle) + .frame(maxWidth: LGNavigationSurfacePolicy.sidebarGraphWidth(geometry.size.width)) + } + ToolbarItem(placement: .topBarTrailing) { child(at: 2) } + } + #else + ToolbarItem(placement: .navigation) { child(at: 1) } + ToolbarItem(placement: .primaryAction) { child(at: 2) } + #endif } - #else - ToolbarItem(placement: .navigation) { child(at: 1) } - ToolbarItem(placement: .primaryAction) { child(at: 2) } - #endif - } + } } } @@ -531,6 +608,7 @@ private struct LGNavigationContent: View { @State private var nativePop: LGNativeNavigationPop? #if os(iOS) @State private var transitions = LGNavigationTransitions() + @State private var dockedKeyboardInset: CGFloat = 0 #endif @State private var usesSystemGroupedBackground = false @State private var visibleScrollTitle: String? @@ -587,6 +665,15 @@ private struct LGNavigationContent: View { sizedBottomChrome } } + #if os(iOS) + .padding(.bottom, dockedKeyboardInset) + .ignoresSafeArea(.keyboard, edges: UIDevice.current.userInterfaceIdiom == .pad ? .bottom : []) + .background { + if UIDevice.current.userInterfaceIdiom == .pad { + LGDockedKeyboardInsetReader { dockedKeyboardInset = $0 } + } + } + #endif .background(routeBackground.ignoresSafeArea()) } diff --git a/apple/Tests/UI/ipad-floating-keyboard.swift b/apple/Tests/UI/ipad-floating-keyboard.swift new file mode 100644 index 00000000..77cd12de --- /dev/null +++ b/apple/Tests/UI/ipad-floating-keyboard.swift @@ -0,0 +1,99 @@ +import SwiftUI +import UIKit +@testable import Logseq +@testable import LUIAppleBackend + +@MainActor private final class FloatingKeyboardEffects: LGEffectExecuting { + func execute(_ effect: LGEffect) async -> LGEffectResolution { + .init(succeeded: true, message: "", output: .discard) + } +} + +@MainActor private final class KeyboardFrameSamples { + var frames: [CGRect] = [] +} + +// Run on iPad, then use the Simulator keyboard controls to float and move the keyboard. +@main struct IPadFloatingKeyboardCheck { + @MainActor static func main() async throws { + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: FloatingKeyboardEffects()) + try runtime.applyCoreResponse(#"{"ok":true,"result":{"selectedGraphId":"demo","graphName":"Keyboard regression","selectedPage":{"uuid":"page","title":"Keyboard regression"},"outlinerRows":[{"depth":0,"block":{"uuid":"editor","title":"Keyboard test","pageId":"page","markup":[{"type":"text","text":"Keyboard test"}]}}],"outlinerState":{"editing":{"uuid":"editor","title":"Keyboard test","caretUTF16Offset":13}}}}"#) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let backend = runtime.renderer.backend + var frames: [Int: CGRect] = [:] + backend.frameReportingEnabled = true + backend.onFramesReport = { frames = $0 } + let samples = KeyboardFrameSamples() + let observer = NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidChangeFrameNotification, + object: nil, queue: .main) { notification in + let frame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue + MainActor.assumeIsolated { if let frame { samples.frames.append(frame) } } + } + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { + NotificationCenter.default.removeObserver(observer) + runtime.stop(); window.isHidden = true; window.rootViewController = nil + } + func nodes(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(nodes) + } + var processed = 0 + var floatingCount = 0 + var firstFloatingBar: CGRect? + if ProcessInfo.processInfo.environment["LOGSEQ_KEYBOARD_CHECK_MODE"] == "docked" { + for _ in 0..<100 where samples.frames.last?.height ?? 0 < 80 { + try await Task.sleep(for: .milliseconds(100)) + } + try await Task.sleep(for: .milliseconds(400)) + func toolbarFrame() throws -> CGRect { + guard let id = backend.rootIDs.flatMap(nodes).first(where: { + backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == "toolbar.outliner.editor" + }), let frame = frames[id] else { throw failure("Missing editor toolbar") } + return frame + } + guard let keyboard = samples.frames.last, keyboard.height > 80, + abs(try toolbarFrame().maxY - keyboard.minY) < 24 else { + throw failure("The docked keyboard must keep the editor toolbar above it: keyboard=\(String(describing: samples.frames.last)) toolbar=\(String(describing: try? toolbarFrame()))") + } + window.endEditing(true) + try await Task.sleep(for: .milliseconds(800)) + guard try toolbarFrame().maxY >= window.bounds.maxY - window.safeAreaInsets.bottom - 24 else { + throw failure("Hiding the keyboard must return the editor toolbar to the bottom") + } + print("PASS: docked and dismissed keyboards preserve the editor toolbar position") + return + } + print("READY: float the iPad software keyboard and move it twice") + for _ in 0..<3600 { + try await Task.sleep(for: .milliseconds(100)) + guard processed < samples.frames.count else { continue } + let keyboard = samples.frames[processed]; processed += 1 + guard keyboard.width > 0, keyboard.height > 80, keyboard.width < window.bounds.width * 0.8 else { continue } + try await Task.sleep(for: .milliseconds(300)) + guard let id = backend.rootIDs.flatMap(nodes).first(where: { + backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == "toolbar.outliner.editor" + }), let toolbar = frames[id] else { throw failure("The editor toolbar must stay mounted with a floating keyboard") } + guard toolbar.maxY >= window.bounds.maxY - window.safeAreaInsets.bottom - 24, + toolbar.maxY <= window.bounds.maxY else { + throw failure("Floating keyboards must not move the toolbar away from the bottom: keyboard=\(keyboard), toolbar=\(toolbar), window=\(window.bounds)") + } + if let firstFloatingBar, abs(firstFloatingBar.minY - toolbar.minY) > 1 { + throw failure("Moving a floating keyboard moved the action bar") + } + firstFloatingBar = toolbar; floatingCount += 1 + if floatingCount >= 2 { + print("PASS: floating keyboard movement preserves the bottom action bar position") + return + } + } + throw failure("Move the floating keyboard twice to complete the native interaction check") + } + + static func failure(_ message: String) -> NSError { + NSError(domain: "IPadFloatingKeyboard", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/apple/Tests/UI/run-simulator-runtime-check.py b/apple/Tests/UI/run-simulator-runtime-check.py index 40d88f30..ac7b56aa 100644 --- a/apple/Tests/UI/run-simulator-runtime-check.py +++ b/apple/Tests/UI/run-simulator-runtime-check.py @@ -102,7 +102,7 @@ def main(): "CFBundleIdentifier": bundle, "CFBundleExecutable": "RuntimeCheck", "CFBundleName": "Runtime check", "CFBundlePackageType": "APPL", "CFBundleVersion": "1", "MinimumOSVersion": "17.0", - "UIDeviceFamily": [1], "UILaunchScreen": {}, + "UIDeviceFamily": [1, 2], "UILaunchScreen": {}, })) for resource in (ROOT / "apple/.build/Logseq.app").glob("*.bundle"): shutil.copytree(resource, app / resource.name) diff --git a/apple/Tests/UI/sidebar-long-graph-name.swift b/apple/Tests/UI/sidebar-long-graph-name.swift new file mode 100644 index 00000000..77d07abe --- /dev/null +++ b/apple/Tests/UI/sidebar-long-graph-name.swift @@ -0,0 +1,66 @@ +import SwiftUI +import UIKit +@testable import Logseq +@testable import LUIAppleBackend + +@main struct SidebarLongGraphNameCheck { + @MainActor static func main() async throws { + func snapshot(_ name: String) throws -> String { + String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": [ + "selectedGraphId": name, "graphName": name, + "graphs": [["id": name, "name": name, "isReady": true, "isEncrypted": false]], + "blocks": [], "outlinerRows": [], "outlinerState": ["selectedBlockIds": []] + ]]), as: UTF8.self) + } + let first = try snapshot("Short") + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: LGCoreEffectExecutor(callCore: { _ in first })) + try runtime.applyCoreResponse(first) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let backend = runtime.renderer.backend + var frames: [Int: CGRect] = [:] + backend.frameReportingEnabled = true + backend.onFramesReport = { frames = $0 } + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true; window.rootViewController = nil } + func nodes(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(nodes) + } + func node(_ identifier: String) throws -> Int { + guard let id = backend.rootIDs.flatMap(nodes).first(where: { + backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier + }) else { throw failure("Missing " + identifier) } + return id + } + try await Task.sleep(for: .milliseconds(600)) + try backend.performPress(node: node("button.sidebar")) + for name in ["Short", "Abhilesh_Knowledge_Graph", String(repeating: "Long graph name ", count: 8), "Short again"] { + try runtime.applyCoreResponse(snapshot(name)) + try await Task.sleep(for: .milliseconds(800)) + let settingsID = try node("button.sidebar.settings") + let graphID = try node("button.graph-switch") + for _ in 0..<100 where frames[settingsID] == nil || frames[graphID] == nil { + try await Task.sleep(for: .milliseconds(100)) + } + guard let settings = frames[settingsID], let graph = frames[graphID], + settings.width >= 40, settings.height >= 40, + window.bounds.contains(settings), window.bounds.contains(graph), + settings.minX >= graph.maxX - 1 else { + throw failure("Global settings must remain visible and unobscured for graph \(name): settings=\(String(describing: frames[settingsID])) graph=\(String(describing: frames[graphID]))") + } + } + try backend.performPress(node: node("button.sidebar.settings")) + try await Task.sleep(for: .milliseconds(300)) + guard backend.rootIDs.flatMap(nodes).contains(where: { + backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == "sheet.settings" + }) else { throw failure("Global settings must still open after switching graphs") } + print("PASS: long graph names and graph switches preserve the visible global settings button") + } + + static func failure(_ message: String) -> NSError { + NSError(domain: "SidebarLongGraphName", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/shared/src/logseq/core/block_clipboard.ml b/shared/src/logseq/core/block_clipboard.ml index 6d59ebe0..0a0614b6 100644 --- a/shared/src/logseq/core/block_clipboard.ml +++ b/shared/src/logseq/core/block_clipboard.ml @@ -277,7 +277,7 @@ let insert db ~now ~fresh_uuid content nodes = let paste db ~graph ~now ~fresh_uuid content = protect (fun () -> let id = eid db content.target_uuid in - let literal = List.mem (get db id "logseq.property.node/display-type") [D.Keyword "code"; D.Keyword "math"] in + let literal = List.mem (G.display_type_for_eid db id) [Some "code"; Some "math"; Some "quote"] in let rich = if literal then None else decode ~graph content.blocks_edn in let source = normalize_lines content.plain_text in let nodes = match rich with Some nodes -> Some nodes | None when literal -> None diff --git a/shared/src/logseq/core/dev_query.ml b/shared/src/logseq/core/dev_query.ml index 7d72764f..78b629a7 100644 --- a/shared/src/logseq/core/dev_query.ml +++ b/shared/src/logseq/core/dev_query.ml @@ -970,8 +970,8 @@ let date_of_formatter (fmt : string) (s : string) : (int * int * int) option = (match tok with | TokLit c -> if i < n && s.[i] = c then go rest (i + 1) (y, m, d) else None - | TokYear _ -> - try_num rest i (y, m, d) 8 (fun v (_, mm, dd) -> (v, mm, dd)) + | TokYear width -> + try_num rest i (y, m, d) width (fun v (_, mm, dd) -> (v, mm, dd)) | TokMonthNum -> try_num rest i (y, m, d) 2 (fun v (yy, _, dd) -> (yy, v, dd)) | TokDay -> @@ -1061,9 +1061,15 @@ let parse_journal_title ?(formatters = built_in_journal_title_formatters) (* date-time-util/journal-title->int *) let journal_title_to_int ?(formatters = built_in_journal_title_formatters) (title : string) : int option = - match parse_journal_title ~formatters title with - | Some ms -> Some (date_to_int ms) - | None -> None + if String.for_all is_digit title && String.length title <> 8 then None + else + let title = capitalize_all title in + List.find_map (fun fmt -> + match date_of_formatter fmt title with + | Some (year, month, day) -> + let value = year * 10000 + month * 100 + day in + if Journal.valid_day value then Some value else None + | None -> None) formatters (* common-date/valid-journal-title? *) let valid_journal_title ?(formatters = built_in_journal_title_formatters) diff --git a/shared/src/logseq/core/dev_query.mli b/shared/src/logseq/core/dev_query.mli index c52da247..b44ebe09 100644 --- a/shared/src/logseq/core/dev_query.mli +++ b/shared/src/logseq/core/dev_query.mli @@ -18,6 +18,7 @@ module Unicode : sig end module Date_time_util : sig val valid_journal_title : ?formatters:string list -> string -> bool + val journal_title_to_int : ?formatters:string list -> string -> int option type period = Days | Weeks | Months | Years | Hours | Minutes val time_ms : unit -> int64 val minus : period -> int -> int64 -> int64 diff --git a/shared/src/logseq/core/editor_command.ml b/shared/src/logseq/core/editor_command.ml index 6ec5e016..bb9f0f14 100644 --- a/shared/src/logseq/core/editor_command.ml +++ b/shared/src/logseq/core/editor_command.ml @@ -71,22 +71,16 @@ let candidates db query = |> List.stable_sort (fun (_, a) (_, b) -> compare b a) |> List.map fst -let journal_reference db ms = - let day = Cache_model.journal_day_for_ms ms in +let journal_reference_for_day db day = let uuid = Option.bind db (fun db -> Read.journal_page_uuid db day) |> Option.value ~default:(Journal.page_uuid day) in "[[" ^ uuid ^ "]]" -let date_ms value = - try - if String.length value <> 10 || value.[4] <> '-' || value.[7] <> '-' then invalid_arg "date"; - let y = int_of_string (String.sub value 0 4) and m = int_of_string (String.sub value 5 2) - and d = int_of_string (String.sub value 8 2) in - let seconds, tm = Unix.mktime { (Unix.localtime 0.) with tm_year = y - 1900; tm_mon = m - 1; tm_mday = d; - tm_hour = 0; tm_min = 0; tm_sec = 0; tm_isdst = false } in - if tm.Unix.tm_year <> y - 1900 || tm.tm_mon <> m - 1 || tm.tm_mday <> d then invalid_arg "date"; - Ok (int_of_float (seconds *. 1000.)) - with _ -> Error "Choose a valid date" +let journal_reference db ms = journal_reference_for_day db (Cache_model.journal_day_for_ms ms) + +let date_day value = match Journal.day_of_string value with + | Some day -> Ok day + | None -> Error "Choose a valid date" let timestamp value = try diff --git a/shared/src/logseq/core/editor_command.mli b/shared/src/logseq/core/editor_command.mli index 78418723..410f033c 100644 --- a/shared/src/logseq/core/editor_command.mli +++ b/shared/src/logseq/core/editor_command.mli @@ -20,7 +20,8 @@ val candidates : Datascript.db option -> string -> entry list val picker_name : picker -> string val picker_of_name : string -> picker option val journal_reference : Datascript.db option -> int -> string -val date_ms : string -> (int, string) result +val journal_reference_for_day : Datascript.db option -> int -> string +val date_day : string -> (int, string) result val timestamp : string -> (int, string) result val link : image:bool -> string -> (string, string) result diff --git a/shared/src/logseq/core/editor_command_effects.ml b/shared/src/logseq/core/editor_command_effects.ml index 077fc376..4b0ff3fa 100644 --- a/shared/src/logseq/core/editor_command_effects.ml +++ b/shared/src/logseq/core/editor_command_effects.ml @@ -116,8 +116,7 @@ let clone_template db ~now ~fresh_uuid target source draft = let plan db ~now ~fresh_uuid operation = try Ok (match operation with - | Ensure_journal ms -> - let day = Cache_model.journal_day_for_ms ms in + | Ensure_journal day -> if Read.journal_page_uuid db day <> None then [] else [Ops.Create_journal {page_uuid = Journal.page_uuid day; block_uuid = fresh_uuid (); block_title = ""; title = Journal.title db day; journal_day = day; created_at = now}] @@ -147,7 +146,14 @@ let plan db ~now ~fresh_uuid operation = order = position (Read.string_value (Read.value db target "block/order")) (next_order db target); created_at = now}; Ops.Set_property {uuid = fresh; attr = "block/link"; expected = None; value = Some (Ops.Ref_uuid source)}]] | Apply_template (target, source, draft) -> clone_template db ~now ~fresh_uuid target source draft - | Apply (uuid, Properties fields) -> List.map (fun (attr, value) -> set db uuid attr value) fields + | Apply (uuid, Properties fields) -> + List.map (fun (attr, value) -> set db uuid attr value) fields + @ (if List.assoc_opt "logseq.property.node/display-type" fields = Some (Some (Ops.Keyword_value "quote")) then + match Option.bind (Ds.entid db "db/ident" (Ds.Keyword "logseq.class/Quote-block")) + (fun id -> Read.uuid_for_eid db id) with + | Some tag_uuid -> [Ops.Add_tag {uuid; tag_uuid}] + | None -> [] + else []) | Apply (uuid, Choice (attr, value)) -> [edit db ~now ~fresh_uuid uuid attr (Option.value ~default:"" value)] | Apply (uuid, Code lang) -> [set db uuid "logseq.property.node/display-type" (Some (Ops.Keyword_value "code"))] @ (match lang with None -> [] | Some lang -> [set db uuid "logseq.property.code/lang" (Some (Ops.String_value lang))]) diff --git a/shared/src/logseq/core/journal.ml b/shared/src/logseq/core/journal.ml index 3cd513ad..3f48d01f 100644 --- a/shared/src/logseq/core/journal.ml +++ b/shared/src/logseq/core/journal.ml @@ -134,6 +134,15 @@ let valid_day day = let days = match month with 2 -> if leap then 29 else 28 | 4 | 6 | 9 | 11 -> 30 | _ -> 31 in year >= 1 && year <= 9999 && month >= 1 && month <= 12 && date >= 1 && date <= days +let day_of_string text = + let compact = if String.length text = 10 && text.[4] = '-' && text.[7] = '-' then + String.sub text 0 4 ^ String.sub text 5 2 ^ String.sub text 8 2 + else text in + if String.length compact <> 8 || not (String.for_all (fun c -> c >= '0' && c <= '9') compact) + then None + else let day = int_of_string compact in + if valid_day day then Some day else None + let format db = Option.bind (D.entid db "db/ident" (D.Keyword "logseq.class/Journal")) (fun eid -> match Seq.uncons (D.Db.datoms db D.Eavt ~e:eid ~a:format_attr ()) with diff --git a/shared/src/logseq/core/journal.mli b/shared/src/logseq/core/journal.mli index f8bff115..dcb8cd83 100644 --- a/shared/src/logseq/core/journal.mli +++ b/shared/src/logseq/core/journal.mli @@ -14,6 +14,7 @@ val weekday_index : int * int * int -> int val formatter_of_date : string -> int * int * int -> string val int_to_journal_title : int -> string -> string val valid_day : int -> bool +val day_of_string : string -> int option val format : Datascript.db -> string val day_title : int -> string val title : Datascript.db -> int -> string diff --git a/shared/src/logseq/core/outliner_state.ml b/shared/src/logseq/core/outliner_state.ml index 930bcb33..cd849c64 100644 --- a/shared/src/logseq/core/outliner_state.ml +++ b/shared/src/logseq/core/outliner_state.ml @@ -49,6 +49,7 @@ type outliner_toolbar = | Audio | Attachment | Hide_keyboard + | New_block | Copy | Delete | Copy_reference @@ -1306,7 +1307,8 @@ let apply_editor_action context state editing action = let tm = Unix.localtime (Unix.gettimeofday ()) in let seconds, _ = Unix.mktime {tm with tm_mday = tm.tm_mday + offset} in let ms = int_of_float (seconds *. 1000.) in - finish (insert_at_caret editing (journal_reference context.db ms) 0) [Execute_editor (Ensure_journal ms)] + finish (insert_at_caret editing (journal_reference context.db ms) 0) + [Execute_editor (Ensure_journal (Cache_model.journal_day_for_ms ms))] | Pick picker -> step {state with editing = Some editing; autocomplete = Some {kind = Picker picker; query = ""}} [] | Code language when String.trim editing.title <> "" @@ -1325,10 +1327,10 @@ let submit_completion context state value = | Error message -> step state [Editor_error message] in (match picker with | Link | Image -> inserted (link ~image:(picker = Image) value) - | Date -> (match date_ms value with + | Date -> (match date_day value with | Error message -> step state [Editor_error message] - | Ok ms -> let next, commands = inserted (Ok (journal_reference context.db ms)) in - step next (commands @ [Execute_editor (Ensure_journal ms)])) + | Ok day -> let next, commands = inserted (Ok (journal_reference_for_day context.db day)) in + step next (commands @ [Execute_editor (Ensure_journal day)])) | Deadline | Scheduled -> (match int_of_string_opt value with | Some _ -> apply_editor_action context state editing @@ -1499,6 +1501,16 @@ let update context (state : outliner_state) message = step {state with editing = Some {editing with title; caret}; autocomplete = None; completion_dismissed = true} [Paste_blocks content] | _ -> step state [Editor_error "Paste target is no longer being edited"]) + | Text_changed {title; caret} + when caret > 0 && String_kit.starts_with ~prefix:">" title + && Option.fold ~none:false ~some:(fun (editing : editor_draft) -> + (title = ">" || editing.title = "") + && Option.fold ~none:false ~some:(fun (block : Model.block) -> block.display_type = None) + (find_block context editing.uuid)) state.editing -> + let editing = Option.get state.editing in + apply_editor_action context state + {editing with title = String.sub title 1 (String.length title - 1); caret = caret - 1} + (Editor_command.Properties ["logseq.property.node/display-type", Some (Ops.Keyword_value "quote")]) | Text_pasted value | Text_changed value -> (match state.editing with | Some editing -> @@ -1591,6 +1603,10 @@ let update context (state : outliner_state) message = | Some editing -> step state [ Cycle_task_status editing.uuid; Haptic Impact ] | None -> step state []) + | Toolbar New_block -> + (match state.editing with + | Some editing -> split_or_outdent context {state with autocomplete = None} editing + | None -> step state []) | Toolbar Hide_keyboard -> step { state with editing = None; autocomplete = None } diff --git a/shared/src/logseq/core/outliner_state.mli b/shared/src/logseq/core/outliner_state.mli index e45f494d..31adad62 100644 --- a/shared/src/logseq/core/outliner_state.mli +++ b/shared/src/logseq/core/outliner_state.mli @@ -47,6 +47,7 @@ type outliner_toolbar = | Audio | Attachment | Hide_keyboard + | New_block | Copy | Delete | Copy_reference diff --git a/shared/src/logseq/core/pending_projection.ml b/shared/src/logseq/core/pending_projection.ml index 3e4cff66..b09d6eb6 100644 --- a/shared/src/logseq/core/pending_projection.ml +++ b/shared/src/logseq/core/pending_projection.ml @@ -377,30 +377,52 @@ let create_attrs db uuid title created_at = nested clipboard operations share the same creation and deduplication rules. *) let resolve_page_references literal_blocks db intent = let created = ref [] and seen = Hashtbl.create 8 in + let resolve name = + let day = Dev_query.Date_time_util.journal_title_to_int + ~formatters:(Journal.journal_title_formatters (Some (Journal.format db))) name in + match day with + | Some day when day / 10000 >= 1 && Journal.valid_day day -> + (match Graph_read.journal_page_uuid db day with + | Some _ as uuid -> uuid + | None -> + let key = "journal:" ^ string_of_int day in + (match Hashtbl.find_opt seen key with + | Some uuid -> Some uuid + | None -> + let uuid = Journal.page_uuid day in + let block_uuid = match Ds.squuid () with Ds.Uuid uuid -> uuid | _ -> assert false in + Hashtbl.add seen key uuid; + created := Ops.Create_journal {page_uuid = uuid; block_uuid; block_title = ""; + title = Journal.title db day; journal_day = day; + created_at = int_of_float (Unix.gettimeofday () *. 1000.)} :: !created; + Some uuid)) + | _ -> + (match Graph_read.unique_named_uuid false db name with + | Some _ as uuid -> uuid + | None -> + let key = "page:" ^ Node_title.name name in + match Hashtbl.find_opt seen key with + | Some uuid -> Some uuid + | None -> + let uuid = match Ds.squuid () with Ds.Uuid uuid -> uuid | _ -> assert false in + Hashtbl.add seen key uuid; + created := Ops.Create_page {uuid; title = name; created_at = int_of_float (Unix.gettimeofday () *. 1000.)} :: !created; + Some uuid) in let title uuid text = let literal = match Hashtbl.find_opt literal_blocks uuid with | Some literal -> literal | None -> Option.fold ~none:false ~some:(fun id -> List.mem (Graph_read.display_type_for_eid db id) [Some "code"; Some "math"]) (Ds.entid db "block/uuid" (Ds.Uuid uuid)) in - if literal then text else Ref_text.to_ids (fun name -> - match Graph_read.unique_named_uuid false db name with - | Some _ as uuid -> uuid - | None -> - let key = Node_title.name name in - match Hashtbl.find_opt seen key with - | Some uuid -> Some uuid - | None -> - let uuid = match Ds.squuid () with Ds.Uuid uuid -> uuid | _ -> assert false in - Hashtbl.add seen key uuid; - created := Ops.Create_page {uuid; title = name; created_at = int_of_float (Unix.gettimeofday () *. 1000.)} :: !created; - Some uuid) (Graph_read.unique_named_uuid true db) text in + if literal then text else Ref_text.to_ids resolve (Graph_read.unique_named_uuid true db) text in let next = match intent with | Ops.Save_title value -> Ops.Save_title {value with title = title value.uuid value.title} | Ops.Insert_block value -> Ops.Insert_block {value with title = title value.uuid value.title} | Ops.Split_block value -> Ops.Split_block {value with before = title value.uuid value.before; after = title value.uuid value.after} | Ops.Merge_backward value -> Ops.Merge_backward {value with title = title value.uuid value.title} | Ops.Create_journal value -> Ops.Create_journal {value with block_title = title value.block_uuid value.block_title} + | Ops.Create_property_value ({content = Ops.String_value text; _} as value) -> + Ops.Create_property_value {value with content = Ops.String_value (title value.uuid text)} | _ -> intent in if !created = [] then next else Ops.Atomic (List.rev !created @ [next]) @@ -457,7 +479,8 @@ let rec compile_with_literals literal_blocks db (intent : Ops.pending_intent) = if Ds.entid db "block/uuid" (Ds.Uuid value.uuid) <> None then Error "property value UUID already exists" else if Ds.entid db "block/uuid" (Ds.Uuid value.owner_uuid) = None then Error "property owner no longer exists" else if Ds.entid db "db/ident" (Ds.Keyword value.property_ident) = None then Error "property no longer exists" - else Ok [entity_tx (Ds.Temp_id ("pending/" ^ value.uuid)) (property_value_attrs db value)] + else Ok (entity_tx (Ds.Temp_id ("pending/" ^ value.uuid)) (property_value_attrs db value) + :: (match value.content with Ops.String_value text -> title_tx db value.uuid text | _ -> [])) | Ops.Save_title value -> (match string_value (one_value db (lookup value.uuid) "block/title") diff --git a/shared/src/logseq/core/result_view.ml b/shared/src/logseq/core/result_view.ml index 0738cdff..ca763fee 100644 --- a/shared/src/logseq/core/result_view.ml +++ b/shared/src/logseq/core/result_view.ml @@ -419,7 +419,7 @@ let calendar_day text = else text in if String.length compact <> 8 || not (digits compact) then None else let day = int_of_string compact in - if day / 10000 < 1 || Dev_query.Time.local_date_of_journal_day (Dev_query.Time.local_tz ()) day = None + if not (Journal.valid_day day) then invalid_arg "Enter a valid calendar date"; Some day let resolve_reference db p value = diff --git a/shared/src/logseq/core/rpc_wire.ml b/shared/src/logseq/core/rpc_wire.ml index 82267199..f51f12ec 100644 --- a/shared/src/logseq/core/rpc_wire.ml +++ b/shared/src/logseq/core/rpc_wire.ml @@ -47,6 +47,7 @@ let toolbar_action wire = | "audio" -> Ok Outliner.Audio | "attachment" -> Ok Outliner.Attachment | "hideKeyboard" -> Ok Outliner.Hide_keyboard + | "newBlock" -> Ok Outliner.New_block | "copy" -> Ok Outliner.Copy | "delete" -> Ok Outliner.Delete | "copyReference" -> Ok Outliner.Copy_reference diff --git a/shared/src/logseq/localization.ml b/shared/src/logseq/localization.ml index 79779dcc..f6df704f 100644 --- a/shared/src/logseq/localization.ml +++ b/shared/src/logseq/localization.ml @@ -1,6 +1,7 @@ (* UI translations reuse Logseq desktop dictionaries; note content is never translated. *) let dictionaries = [ ("af", [ + ("Create new block", "Skep 'n nuwe blok"); ("Back", "Terug"); ("Close", "Sluit"); ("Rename page", "Hernoem"); @@ -236,6 +237,7 @@ let dictionaries = [ ("value", "Waarde") ]); ("ar", [ + ("Create new block", "إنشاء كتلة جديدة"); ("Back", "رجوع"); ("Close", "إغلاق"); ("Rename page", "إعادة تسمية"); @@ -471,6 +473,7 @@ let dictionaries = [ ("value", "القيمة") ]); ("ca", [ + ("Create new block", "Crear bloc nou"); ("Back", "Enrere"); ("Close", "Tanca"); ("Rename page", "Reanomenar"); @@ -706,6 +709,7 @@ let dictionaries = [ ("value", "Valor") ]); ("cs", [ + ("Create new block", "Vytvořit nový blok"); ("Back", "Zpět"); ("Close", "Zavřít"); ("Rename page", "Přejmenovat"); @@ -941,6 +945,7 @@ let dictionaries = [ ("value", "Hodnota") ]); ("de", [ + ("Create new block", "Neuen Block erstellen"); ("Back", "Zurück"); ("Close", "Schließen"); ("Rename page", "Umbenennen"); @@ -1176,6 +1181,7 @@ let dictionaries = [ ("value", "Wert") ]); ("en", [ + ("Create new block", "Create new block"); ("Back", "Back"); ("Close", "Close"); ("Rename page", "Rename"); @@ -1419,6 +1425,7 @@ let dictionaries = [ ("value", "value") ]); ("es", [ + ("Create new block", "Crear bloque nuevo"); ("Back", "Ir hacia atrás"); ("Close", "Cerrar"); ("Rename page", "Renombrar"); @@ -1654,6 +1661,7 @@ let dictionaries = [ ("value", "Valor") ]); ("fa", [ + ("Create new block", "بلوک جدید"); ("Back", "بازگشت"); ("Close", "بستن"); ("Rename page", "تغییر نام"); @@ -1889,6 +1897,7 @@ let dictionaries = [ ("value", "مقدار") ]); ("fr", [ + ("Create new block", "Créer un nouveau bloc"); ("Back", "Retour en arrière"); ("Close", "Fermer"); ("Rename page", "Renommer"); @@ -2124,6 +2133,7 @@ let dictionaries = [ ("value", "Valeur") ]); ("id", [ + ("Create new block", "Buat blok baru"); ("Back", "Kembali"); ("Close", "Tutup"); ("Rename page", "Ganti nama"); @@ -2359,6 +2369,7 @@ let dictionaries = [ ("value", "Nilai") ]); ("it", [ + ("Create new block", "Crea un nuovo blocco"); ("Back", "Indietro"); ("Close", "Chiudi"); ("Rename page", "Rinomina"); @@ -2594,6 +2605,7 @@ let dictionaries = [ ("value", "Valore") ]); ("ja", [ + ("Create new block", "新しいブロック"); ("Back", "戻る"); ("Close", "閉じる"); ("Rename page", "名前を変更"); @@ -2829,6 +2841,7 @@ let dictionaries = [ ("value", "値") ]); ("ko", [ + ("Create new block", "블록 만들기"); ("Back", "뒤로"); ("Close", "닫기"); ("Rename page", "이름 변경"); @@ -3064,6 +3077,7 @@ let dictionaries = [ ("value", "값") ]); ("nb-no", [ + ("Create new block", "Opprett ny blokk"); ("Back", "Tilbake"); ("Close", "Lukk"); ("Rename page", "Gi nytt navn"); @@ -3299,6 +3313,7 @@ let dictionaries = [ ("value", "Verdi") ]); ("nl", [ + ("Create new block", "Maak nieuw blok"); ("Back", "Terug"); ("Close", "Sluiten"); ("Rename page", "Hernoemen"); @@ -3534,6 +3549,7 @@ let dictionaries = [ ("value", "Waarde") ]); ("pl", [ + ("Create new block", "Utwórz nowy blok"); ("Back", "Wstecz"); ("Close", "Zamknij"); ("Rename page", "Zmień nazwę"); @@ -3769,6 +3785,7 @@ let dictionaries = [ ("value", "Wartość") ]); ("pt-br", [ + ("Create new block", "Criar novo bloco"); ("Back", "Voltar"); ("Close", "Fechar"); ("Rename page", "Renomear"); @@ -4004,6 +4021,7 @@ let dictionaries = [ ("value", "Valor") ]); ("pt-pt", [ + ("Create new block", "Criar novo bloco"); ("Back", "Voltar"); ("Close", "Fechar"); ("Rename page", "Renomear"); @@ -4239,6 +4257,7 @@ let dictionaries = [ ("value", "Valor") ]); ("ru", [ + ("Create new block", "Создать новый блок"); ("Back", "Назад"); ("Close", "Закрыть"); ("Rename page", "Переименовать"); @@ -4474,6 +4493,7 @@ let dictionaries = [ ("value", "Значение") ]); ("sk", [ + ("Create new block", "Vytvoriť nový blok"); ("Back", "Späť"); ("Close", "Zavrieť"); ("Rename page", "Premenovať"); @@ -4709,6 +4729,7 @@ let dictionaries = [ ("value", "Hodnota") ]); ("tr", [ + ("Create new block", "Yeni blok oluştur"); ("Back", "Geri"); ("Close", "Kapat"); ("Rename page", "Yeniden adlandır"); @@ -4951,6 +4972,7 @@ let dictionaries = [ ("value", "Değer") ]); ("uk", [ + ("Create new block", "Створити новий блок"); ("Back", "Повернутися"); ("Close", "Закрити"); ("Rename page", "Перейменувати"); @@ -5193,6 +5215,7 @@ let dictionaries = [ ("value", "значення") ]); ("vi", [ + ("Create new block", "Tạo khối mới"); ("Back", "Quay lại"); ("Close", "Đóng"); ("Back", "Quay lại"); @@ -5237,6 +5260,7 @@ let dictionaries = [ ("month", "tháng") ]); ("zh-CN", [ + ("Create new block", "创建块"); ("Back", "返回"); ("Close", "关闭"); ("Rename page", "重命名"); @@ -5427,6 +5451,7 @@ let dictionaries = [ ("Version", "版本") ]); ("zh-cn", [ + ("Create new block", "创建块"); ("1 day ago", "1 天前"); ("1 month ago", "1 个月前"); ("1 week ago", "1 周前"); @@ -5550,6 +5575,7 @@ let dictionaries = [ ("value", "值") ]); ("zh-hant", [ + ("Create new block", "建立塊"); ("Back", "返回"); ("Close", "關閉"); ("Rename page", "重新命名"); diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index 84b5e309..5c5936dd 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -1054,6 +1054,16 @@ let outliner_autocomplete_bar (context : Lui_ui.ui_context) model_source send : let outliner_editor_toolbar (context : Lui_ui.ui_context) model_source send : t = + let new_block = + if_ ~test:(Signal.map (fun (current : Model.logseq_model) -> + Option.fold ~none:false ~some:(fun editing -> + List.exists (fun (row : Model.outline_row) -> row.row_uuid = editing.Model.editing_uuid + && row.row_display_type = Some "code") current.outliner_rows) current.outliner_editing) model_source) + (button ~icon:(`app "add") ~variant:`ghost ~size:`icon + ~width:(if Lui_ui.host context = KotlinHost then 48 else 38) ~height:42 + ~label:(View_base.tr (Signal.sample model_source) "Create new block") + ~accessibility_identifier:"button.outliner.editor.newBlock" + ~on_press:(fun _ -> ignore (send (Model.PerformOutlinerToolbarAction "newBlock"))) []) in if Lui_ui.host context = KotlinHost then let task_status (current : Model.logseq_model) = match current.outliner_editing with @@ -1102,6 +1112,7 @@ let outliner_editor_toolbar (context : Lui_ui.ui_context) model_source send "button.outliner.editor.moveUp" "moveUp" send; toolbar_button model_source (`app "toolbar-move-down") "Move down" "button.outliner.editor.moveDown" "moveDown" send; + new_block; toolbar_button model_source (`app "toolbar-tag") "Tag" "button.outliner.editor.tag" "tag" send; toolbar_button model_source (`app "toolbar-camera") "Photo" @@ -1149,6 +1160,7 @@ let outliner_editor_toolbar (context : Lui_ui.ui_context) model_source send "button.outliner.editor.moveUp" "moveUp"; apple_icon_button (`app "toolbar-move-down") "Move down" "button.outliner.editor.moveDown" "moveDown"; + new_block; apple_icon_button (`app "toolbar-tag") "Tag" "button.outliner.editor.tag" "tag"; apple_icon_button (`app "toolbar-camera") "Photo" diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index a2bb2bf7..251bd27a 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -227,7 +227,8 @@ let filter_value_title col value = match value with | Some choice -> jtext (jfield "title" choice) | None -> text_value value) values) | value -> input_value value -let temporal_picker kind id source set : t = fun context parent -> +let temporal_picker kind id source set : t = + let calendar : t = fun context parent -> let node = Lui_ui.extension context "native-property-date" in attach context parent node; Lui_ui.extension_property context node "kind" (Lui_protocol.StringValue kind); @@ -237,7 +238,14 @@ let temporal_picker kind id source set : t = fun context parent -> | Lui_protocol.ExtensionEvent (_, _, "change", fields) -> (match Lui_protocol.String_map.find_opt "value" fields with Some (Lui_protocol.StringValue value) -> set value | _ -> ()) | _ -> ()); - node + node in + if kind = "date" then + column ~gap:8 [ + text_field ~text_signal:source ~placeholder:"YYYY-MM-DD" ~label:"YYYY-MM-DD" + ~accessibility_identifier:("view.edit.date.input." ^ id) + ~on_input:(function Lui_protocol.TextChanged (_, value) -> set value | _ -> ()) []; + calendar] + else calendar let temporal_default kind = let now = Unix.gettimeofday () in diff --git a/shared/test/logseq/block_clipboard_test.ml b/shared/test/logseq/block_clipboard_test.ml index 167ba41a..32baabd2 100644 --- a/shared/test/logseq/block_clipboard_test.ml +++ b/shared/test/logseq/block_clipboard_test.ml @@ -300,7 +300,26 @@ let pasted_literal_blocks_do_not_create_reference_pages () = "$$\n[[Literal destination]]\n$$"; "Before math\n\n$$\n[[Literal destination]]\n$$"] +let quote_paste_preserves_paragraphs_inside_one_block () = + List.iter (fun (before, start, length) -> R.with_runtime (fun session runtime _ -> + set runtime target "logseq.property.node/display-type" (Pending_ops.Keyword_value "quote"); + rename runtime target before; + let before_count = List.length (Graph_runtime.blocks_for_page runtime Q.page) in + let plain = "First paragraph\r\n\r\nSecond paragraph\n\n- A quoted list\n- Another item" in + let result = paste ~start ~length session target before plain in success result; + let expected, caret = Block_clipboard.inline {target_uuid = target; before_title = before; + selection_start = start; selection_length = length; plain_text = plain; blocks_edn = ""} in + check_eq ~msg:"Quote retains all pasted paragraphs and list lines" (title runtime target) expected; + check_eq ~msg:"pasting into Quote creates no sibling blocks" + (List.length (Graph_runtime.blocks_for_page runtime Q.page)) before_count; + check_eq (value runtime target "logseq.property.node/display-type") (Some (D.Keyword "quote")); + let editing = member "editing" (member "outlinerState" (member "result" result)) in + check_eq (member "uuid" editing) (`String target); + check_eq (member "caretUTF16Offset" editing) (`Int caret))) + ["", 0, 0; "Before 🙂 replace after", 10, 7] + let cases = [ + case "Quote paste retains paragraphs and Markdown in one block" quote_paste_preserves_paragraphs_inside_one_block; case "pasted code and math never create reference pages" pasted_literal_blocks_do_not_create_reference_pages; case "reference-created pages are searchable immediately" created_reference_pages_are_searchable_immediately; case "pasted wiki references create and deduplicate ordinary pages" pasted_page_references_create_missing_pages; diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index f225f5c2..2f20c2a5 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -421,6 +421,76 @@ let templates_clone_without_mutating_the_source () = R.with_runtime (fun session check_eq (value runtime Q.beta "block/title") (Some (Ds.String "Beta result")); check_eq ~msg:"template application does not create a live link" (value runtime Q.alpha "block/link") None) +let typed_quote_marker_converts_only_ordinary_blocks () = + R.with_runtime (fun session runtime _ -> + ignore (start session ""); + let response = type_at session ">Batched quote" 14 in + check_eq ~msg:"debounced typing into a blank block still consumes the quote shortcut" + (member "title" (draft response)) (`String "Batched quote"); + check_eq (value runtime Q.alpha "logseq.property.node/display-type") (Some (Ds.Keyword "quote"))); + R.with_runtime (fun session runtime _ -> + let response = start session ">" in + check_eq ~msg:"typing > consumes the quote shortcut" (member "title" (draft response)) (`String ""); + check_eq (member "caretUTF16Offset" (draft response)) (`Int 0); + check_eq ~msg:"quote conversion is persisted immediately" + (value runtime Q.alpha "logseq.property.node/display-type") (Some (Ds.Keyword "quote")); + let db = Graph_runtime.db runtime in + let id = Option.get (Ds.entid db "block/uuid" (Ds.Uuid Q.alpha)) in + check ~msg:"the shortcut creates a real Quote instance" + (Graph_read.entity_is_instance_of db id "logseq.class/Quote-block"); + ignore (type_at session "Quoted text" 11); save session; + check_eq (value runtime Q.alpha "block/title") (Some (Ds.String "Quoted text"))); + List.iter (fun title -> R.with_runtime (fun session runtime _ -> + ignore (start session title); save session; + check_eq ~msg:"an embedded or pasted quote marker stays text" + (value runtime Q.alpha "logseq.property.node/display-type") None)) ["before >"; "> text"]; + List.iter (fun label -> R.with_runtime (fun session runtime _ -> + ignore (run session label); ignore (type_at session ">" 1); save session; + check_eq ~msg:"code and math do not interpret quote shortcuts" + (value runtime Q.alpha "block/title") (Some (Ds.String ">")); + check_eq (value runtime Q.alpha "logseq.property.node/display-type") + (Some (Ds.Keyword (if label = "Math block" then "math" else "code"))))) + ["Code block"; "Calculator"; "Math block"] + +let code_toolbar_creates_a_block_without_a_hardware_keyboard () = + List.iter (fun backend -> List.iter (fun label -> R.with_runtime (fun session runtime _ -> + ignore (run session label); ignore (type_at session "6*7\nlast*3" 10); + let before = List.length (Graph_runtime.blocks_for_page runtime Q.page) in + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + let host = Lui_app.runtime app in + let button = App_test.descendant_with_identifier host (Lui_app.root_node app) "button.outliner.editor.newBlock" in + check ~msg:"the software keyboard exposes New block for Code and Calculator" (button <> -1); + App_test.dispatch app (Lui_protocol.Press button); App_test.flush app; + check ~msg:"the native toolbar forwards the shared structural action" + (List.exists (function Model.OutlinerToolbarEffect (_, "newBlock") -> true | _ -> false) + (App.model app).pending_effects)); + let response = event session "toolbar" ["action", `String "newBlock"] in + check_eq (List.length (Graph_runtime.blocks_for_page runtime Q.page)) (before + 1); + check ~msg:"the new block receives editor focus" (member "uuid" (draft response) <> `String Q.alpha); + check_eq (value runtime Q.alpha "block/title") (Some (Ds.String "6*7\nlast*3")))) + ["Code block"; "Calculator"]) [App_test.ios_backend; App_test.kotlin_backend] + +let date_command_accepts_typed_historical_dates () = + List.iter (fun backend -> R.with_runtime (fun session runtime _ -> + ignore (run session "Date picker"); + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + let host = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier host (Lui_app.root_node app) id in + let input = find "view.edit.date.input.editor-command" in + check ~msg:"date commands provide a direct date input" (input <> -1); + App_test.dispatch app (Lui_protocol.TextChanged (input, "0044-03-15")); App_test.flush app; + App_test.dispatch app (Lui_protocol.Press (find "button.outliner.autocomplete.submit")); App_test.flush app; + check ~msg:"historical dates can be submitted without scrolling a calendar" + (List.exists (function Model.EditorOutlinerEventEffect (_, payload) -> + let json = J.from_string payload in + member "type" json = `String "submitAutocomplete" && member "value" json = `String "0044-03-15" + | _ -> false) + (App.model app).pending_effects)); + ignore (submit session "0044-03-15"); save session; + check ~msg:"typed historical dates create a real journal through the date command" + (Graph_read.journal_page_uuid (Graph_runtime.db runtime) 440315 <> None))) + [App_test.ios_backend; App_test.kotlin_backend] + let code_commands_update_the_active_editor_style () = List.iter (fun backend -> List.iter (fun label -> R.with_runtime (fun session _ _ -> ignore (run session label); @@ -909,6 +979,9 @@ let task_dates_are_visible_and_editable_in_the_outline () = [App_test.ios_backend; App_test.kotlin_backend] let cases = [ + case "typed quote markers match master without changing literal blocks" typed_quote_marker_converts_only_ordinary_blocks; + case "Code and Calculator toolbars create blocks with software keyboards" code_toolbar_creates_a_block_without_a_hardware_keyboard; + case "date commands accept directly typed historical dates" date_command_accepts_typed_historical_dates; case "code commands immediately update the active editor presentation" code_commands_update_the_active_editor_style; case "calculator last remembers successful expressions across blank and comment lines" calculator_last_tracks_successful_lines; case "embedded outliner bullets use the master arrow" embedded_outliner_bullets_match_master; diff --git a/shared/test/logseq/journal_format_test.ml b/shared/test/logseq/journal_format_test.ml index 6e57462f..136451d0 100644 --- a/shared/test/logseq/journal_format_test.ml +++ b/shared/test/logseq/journal_format_test.ml @@ -201,7 +201,15 @@ let search_reindexes_journals_and_references_when_format_changes () = check ~msg:"references in indexed block titles follow the chosen journal format" (List.exists (fun (hit : Search_index.indexed_search_hit) -> hit.uuid = Q.alpha && String_kit.includes ~sub:"23-10-2026" hit.title) hits)) +let reference_dates_parse_without_epoch_conversion () = + List.iter (fun (title, expected) -> + check_eq ~msg:title (Dev_query.Date_time_util.journal_title_to_int title) expected) + ["2026", None; "2026-10", None; "2026-02-30", None; + "20261026", Some 20261026; "2026-10-26", Some 20261026; + "0044-03-15", Some 440315; "Mar 15th, 0044", Some 440315] + let cases = [ + case "reference dates parse without epoch conversion or partial years" reference_dates_parse_without_epoch_conversion; case "search refreshes journal and reference labels on format changes" search_reindexes_journals_and_references_when_format_changes; case "date commands preserve existing and new journal identities" date_commands_keep_stable_journal_references_after_format_changes; case "open destinations refresh date labels immediately" open_destinations_refresh_without_reopening; diff --git a/shared/test/logseq/node_property_test.ml b/shared/test/logseq/node_property_test.ml index d9671264..a6fce5fb 100644 --- a/shared/test/logseq/node_property_test.ml +++ b/shared/test/logseq/node_property_test.ml @@ -15,6 +15,20 @@ let edit session uuid p value extra = "blockUuid", `String uuid; "attr", member "attr" p; "expected", member "raw" p; "value", `String value] @ extra))) +let sync_all runtime conn = + List.iter (fun operation -> + let before = (Graph_runtime.state runtime).server_t in + let _, tx = match Graph_runtime.prepare_sync runtime operation with + | Ok value -> value | Error message -> fail message in + let journal = match Journal_sync.decode (J.to_string (`Assoc ["type", `String "tx/batch/ok"; + "t-before", `Int before; "t", `Int (before + 1); "txs", `List [`Assoc ["t", `Int (before + 1); + "tx-id", `String operation.Pending_ops.operation_id; "tx", `String tx]]])) with + | Ok value -> value | Error message -> fail message in + check_ok (Graph_runtime.stage runtime {operation with Pending_ops.state = Pending_ops.Accepted (before + 1)}); + let ids, changed = match Journal_sync.apply (fun value -> Ok value) conn before journal with + | Ok value -> value | Error message -> fail message in + Graph_runtime.rebase runtime journal.t ids changed) (Graph_runtime.pending_operations runtime) + let projection_and_navigation () = let db = R.fixture () in let db = Ds.db_with [put Q.page R.summary (Ds.Ref_to (Q.ref_uuid R.text_b)); @@ -240,6 +254,11 @@ let temporal_properties_display_and_edit () = R.with_runtime (fun session runtim check_eq (member "editorValue" due) (`String "2026-10-01"); ignore (result (edit session Q.alpha due "2026-10-22" [])); check_eq (member "editorValue" (property R.due (result (read session Q.alpha)))) (`String "2026-10-22"); + let due = property R.due (result (read session Q.alpha)) in + ignore (result (edit session Q.alpha due "0044-03-15" [])); + check_eq (member "editorValue" (property R.due (result (read session Q.alpha)))) (`String "0044-03-15"); + check ~msg:"historical Date properties reference a real Journal" + (Graph_read.journal_page_uuid (Graph_runtime.db runtime) 440315 <> None); ignore (result (edit session Q.alpha p "1792719000000" [])); let p = property attr (result (read session Q.alpha)) in check_eq (member "editorValue" p) (`String "1792719000000"); @@ -770,7 +789,40 @@ let page_tags_use_a_separate_overflow_action () = R.with_runtime (fun session ru check_eq ~msg:"invalid tags do not partially write" (Graph_runtime.pending_operations runtime) pending; List.iter (fun operation -> check_ok (Graph_runtime.prepare_sync runtime operation)) pending) +let text_property_references_create_journals_and_keep_partial_dates_literal () = + List.iter (fun (date, day) -> R.with_runtime (fun session runtime conn -> + let p = property R.summary (result (read session Q.alpha)) in + ignore (result (edit session Q.alpha p ("Opened [[" ^ date ^ "]]") [])); + let db = Graph_runtime.db runtime in + let journal = match Graph_read.journal_page_uuid db day with + | Some uuid -> uuid | None -> fail "A full date reference in a text property must create a Journal" in + let id = Option.get (Ds.entid db "block/uuid" (Ds.Uuid journal)) in + check ~msg:"date references point to a Journal, not an ordinary Page" + (Graph_read.entity_is_instance_of db id "logseq.class/Journal"); + let p = property R.summary (result (read session Q.alpha)) in + let block = List.hd (items "blocks" p) in + check_eq (member "title" block) (`String ("Opened [[" ^ journal ^ "]]")); + let owner = Option.get (Ds.entid db "block/uuid" (Ds.Uuid (R.text (member "uuid" block)))) in + check ~msg:"new text property references are indexed immediately" (List.mem id (Graph_read.ref_eids db owner "block/refs")); + let p = property R.summary (result (read session Q.alpha)) in + ignore (result (edit session Q.alpha p "[[2026-10-26]] and [[2026]] and [[2026-10]]" [])); + check_eq ~msg:"different spellings reuse the same journal identity" + (Graph_read.journal_page_uuid (Graph_runtime.db runtime) day) (Some journal); + List.iter (fun name -> + let uuid = Option.get (Graph_read.unique_named_uuid false (Graph_runtime.db runtime) name) in + let db = Graph_runtime.db runtime in + let id = Option.get (Ds.entid db "block/uuid" (Ds.Uuid uuid)) in + check_eq (Graph_read.value db id "block/journal-day") None) ["2026"; "2026-10"]; + let restored = Graph_runtime.create runtime.path 42 conn {Graph_runtime.default_options with auto_create_today = false} in + check_eq ~msg:"journal references survive an offline restart" + (Graph_read.journal_page_uuid (Graph_runtime.db restored) day) (Some journal); + sync_all restored conn; + check_eq ~msg:"journal references compile and synchronize with dependent text edits" + (Graph_runtime.pending_operations restored) [])) + ["2026-10-26", 20261026; "Oct 26th, 2026", 20261026; "0044-03-15", 440315] + let cases = [ + case "text property date references create Journals while partial dates remain Pages" text_property_references_create_journals_and_keep_partial_dates_literal; case "page tags are added independently of Rename page" page_tags_use_a_separate_overflow_action; case "wrapped node values navigate to their actual target safely" wrapped_node_values_resolve_only_the_owning_property; case "property creation validates names and reuses definitions safely" property_creation_rejects_unsafe_names_and_preserves_definitions;