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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 44 additions & 25 deletions app/application.ml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ type timeline_notice =
| Copy_failed of string
| Import_failed of string

let operation_failure = function
let operation_failure ?(can_retry_imports = false) = function
| Some (Delete_failed message) ->
Some
( "Delete failed"
Expand All @@ -165,8 +165,12 @@ let operation_failure = function
Some
( "Attachment not added"
, message
, "Your journal entry and other attachments are kept. Select the file again to \
retry." )
, if can_retry_imports then
"Your journal entry and other attachments are kept. Select Retry imports to \
try again."
else
"Your journal entry and other attachments are kept. Select the file again to \
retry." )
| None | Some Delete_undo -> None
;;

Expand Down Expand Up @@ -2245,26 +2249,24 @@ let composer_content
match assets with
| None -> []
| Some assets ->
let disabled = not (enabled && not saving && Journal_capture.can_attach capture) in
(* The shared buttons composite has no disabled state. Native leaf buttons
retain the same glass capsule and 44-point hit areas. *)
let action ~label ~icon source =
V.buttons_action
~label
~icon
~on_press:
(Ui.Event.Handler.create (fun _ ->
if enabled && (not saving) && Journal_capture.can_attach capture
then assets.on_attach source))
()
Lui_elements.button
~label ~icon:(`app icon) ~variant:`ghost ~size:`icon
~foreground:"foreground" ~width:44 ~height:44 ~disabled
~on_press:(fun _ -> if not disabled then assets.on_attach source)
[]
in
[ V.buttons
~actions:
([ action ~label:"文件" ~icon:"doc" Journal_asset_import.Files
; action ~label:"照片" ~icon:"photo" Journal_asset_import.Photos
]
@
if assets.camera
then [ action ~label:"相机" ~icon:"camera" Journal_asset_import.Camera ]
else [])
()
[ V.of_lui
(Lui_elements.button_group ~gap:0 ~height:44 ~background:"glass"
~corner_radius:999
([ action ~label:"文件" ~icon:"doc" Journal_asset_import.Files
; action ~label:"照片" ~icon:"photo" Journal_asset_import.Photos ]
@ if assets.camera then
[ action ~label:"相机" ~icon:"camera" Journal_asset_import.Camera ]
else []))
|> V.with_test_id (Ui.Test_id.string (scope ^ "-attach"))
]
in
Expand Down Expand Up @@ -2323,7 +2325,16 @@ let composer_content
~value:(Journal_capture.value capture)
~send_disabled:(not can_submit)
?attachments
?feedback:(Option.map live_region_text error)
?feedback:
(let messages =
Option.to_list error
@ if List.length pending >= Journal_capture.attachment_limit then
[ "9 attachments maximum. Remove one to add another." ]
else []
in
match messages with
| [] -> None
| messages -> Some (V.column (List.map live_region_text messages)))
~actions:(attach @ [ task ] @ discard)
~on_edit:on_edit.native
~on_submit:ignored
Expand Down Expand Up @@ -4478,6 +4489,11 @@ let start
when Hashtbl.mem import_worker_requests request_id ->
let generation, operation, owner = Hashtbl.find import_worker_requests request_id in
Hashtbl.remove import_worker_requests request_id;
let interruption =
if Hashtbl.mem capture_staged_items operation then
"Import was interrupted. Select Retry imports to try again."
else "Import was interrupted. Select the file again."
in
let capture_completion =
match Hashtbl.find_opt capture_staged_items operation with
| None -> Effect.ignore
Expand All @@ -4503,10 +4519,10 @@ let start
timeline_notice =
(match owner with
| None ->
Some (Import_failed "Import was interrupted. Select the file again.")
Some (Import_failed interruption)
| Some _ -> state.timeline_notice)
; import_completion =
Some (operation, Some "Import was interrupted. Select the file again.")
Some (operation, Some interruption)
})
in
Effect.bind
Expand Down Expand Up @@ -6741,7 +6757,10 @@ let start
Some
(error_info_page
~sync_error:(Option.bind state.manager (fun manager -> manager.last_error))
~operation_failure:(operation_failure state.timeline_notice)
~operation_failure:
(operation_failure
~can_retry_imports:(state.failed_capture_imports <> [])
state.timeline_notice)
(newest_first_worker_errors state)
dispatch)
in
Expand Down
22 changes: 21 additions & 1 deletion app/journal_capture.ml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,27 @@ let rebind t ~session_number =
;;

let phase t = t.phase
let source_is_blank source = String.equal (String.trim source) ""
(* Unicode White_Space, shared by button admission and attachment-only naming.
Do not trim meaningful drafts or treat format characters as whitespace. *)
let source_is_blank source =
let whitespace code =
(code >= 0x0009 && code <= 0x000d)
|| (code >= 0x2000 && code <= 0x200a)
|| match code with
| 0x0020 | 0x0085 | 0x00a0 | 0x1680
| 0x2028 | 0x2029 | 0x202f | 0x205f | 0x3000 -> true
| _ -> false
in
let rec loop offset =
if offset = String.length source then true
else
let decoded = String.get_utf_8_uchar source offset in
Uchar.utf_decode_is_valid decoded
&& whitespace (Uchar.to_int (Uchar.utf_decode_uchar decoded))
&& loop (offset + Uchar.utf_decode_length decoded)
in
loop 0
;;

let can_save t =
t.phase = Editing && ((not (source_is_blank (source t))) || t.pending_attachments <> [])
Expand Down
2 changes: 1 addition & 1 deletion app/journal_view.ml
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,7 @@ module View = struct
~placeholder
~text:(Text_editing.Value.text value)
~autofocus
~submit_on_enter:true
~submit_on_enter:false
~send_disabled_signal:(Signal.constant context.Lui_ui.ui_scheduler send_disabled)
~actions:(List.map (fun action -> action.mount) actions)
~on_input:
Expand Down
87 changes: 80 additions & 7 deletions test/application_view_test.ml
Original file line number Diff line number Diff line change
Expand Up @@ -681,14 +681,16 @@ let run_favorites_native_visibility
done;
if source.title = "First attachment"
&& detail_capture <> Some "selection-error"
&& (detail_capture <> Some "retry-imports"
&& (not (List.mem detail_capture [Some "retry-imports"; Some "interrupted-imports"])
|| List.length (List.filter (( = ) "First attachment") (Atomic.get imports_entered)) = 1)
then (
while not (Atomic.get import_release) do
Eio.Time.Mono.sleep (W.Request_context.clock context) 0.001
done;
Atomic.incr imports_finished;
Ok (Service.Asset_imported (Error "Fixture first attachment failed")))
if List.mem detail_capture [Some "interrupted-imports"; Some "legacy-interrupted"]
then Error "Fixture interrupted import"
else Ok (Service.Asset_imported (Error "Fixture first attachment failed")))
else
let module A = Logseq_db_types.Asset_descriptor in
let asset =
Expand Down Expand Up @@ -1474,6 +1476,38 @@ let run_favorites_native_visibility
in
let original_root_content = Option.get (find "text" "Timeline fixture 1") in
enter 0;
if scenario = "legacy-interrupted" then (
(* No pure reducer owns native completion copy or the Worker queue.
The existing non-staged adapter is tested via its public callback;
it has no visible iOS opener, so this is not a GUI claim. *)
let adapter = Hashtbl.fold (fun id values found ->
match List.assoc_opt "_extension" values, List.assoc_opt "payload" values with
| Some (`String "journal-asset-import"), Some (`String payload) ->
let request = Yojson.Safe.Util.member "request" (Yojson.Safe.from_string payload) in
if Yojson.Safe.Util.member "staged" request = `Bool false then Some id else found
| _ -> found) props None |> Option.get in
let operation = G.Uuid.to_string (uuid 34001) in
let path = Filename.temp_file "journal-legacy-import-" ".txt" in
Fun.protect ~finally:(fun () -> Sys.remove path) (fun () ->
let picked = `Assoc [
"operation",`String operation;"asset",`String (G.Uuid.to_string (uuid 34002));
"localMutation",`String (G.Uuid.to_string (uuid 34003));
"metadataMutation",`String (G.Uuid.to_string (uuid 34004));
"path",`String path;"title",`String "First attachment";"type",`String "txt"] in
hooks.extension_event adapter "event" (Yojson.Safe.to_string
(`Assoc ["id",`Int 1;"payload",`String (Yojson.Safe.to_string picked)])) |> consume;
Atomic.set import_release true;
wait "non-staged interruption requires source reselection" (fun () ->
Hashtbl.fold (fun _ values found -> found ||
match List.assoc_opt "payload" values with
| Some (`String payload) ->
let json = Yojson.Safe.from_string payload in
Yojson.Safe.Util.member "completion" json = `String operation
&& Yojson.Safe.Util.member "error" json =
`String "Import was interrupted. Select the file again."
| _ -> false) props false);
Alcotest.(check bool) "non-staged import cannot offer retained-byte retry" true
(Option.is_none (find "accessibility-label" "Retry imports"))));
Alcotest.(check bool)
"old Append toolbar action is removed"
true
Expand All @@ -1496,7 +1530,7 @@ let run_favorites_native_visibility
if
List.mem
scenario
[ "attachments"; "attachment-only"; "offscreen-attachments"; "selection-error"; "picker-owner"; "retry-imports" ]
[ "attachments"; "attachment-only"; "offscreen-attachments"; "selection-error"; "picker-owner"; "retry-imports"; "attachment-limit"; "interrupted-imports" ]
then (
dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "文件")));
let adapter, request =
Expand Down Expand Up @@ -1539,7 +1573,9 @@ let run_favorites_native_visibility
[ "action", `String "picked-batch"
; "request", request
; ( "items"
, `List [ first; second ] )
, `List (if scenario = "attachment-limit" then
first :: second :: List.init 7 (fun i -> item (i + 3) (Printf.sprintf "Extra attachment %d" (i + 3)))
else [ first; second ]) )
]
in
let batch = if scenario = "selection-error" then
Expand Down Expand Up @@ -1569,6 +1605,24 @@ let run_favorites_native_visibility
"accessibility-identifier"
("composer-attachment:" ^ G.Uuid.to_string (uuid 30002)))
= 1);
if scenario = "attachment-limit" then (
(* Capture.can_attach already rejects the tenth pick; only the
real view boundary owns disabled controls and cap feedback. *)
let check enabled = List.iter (fun label ->
let node = Option.get (find "accessibility-label" label) in
Alcotest.(check (option bool)) (label ^ " enabled matches capacity")
(Some enabled)
(Option.map Yojson.Safe.Util.to_bool
(List.assoc_opt "enabled" (Hashtbl.find props node)))) ["文件";"照片";"相机"] in
check false;
Alcotest.(check bool) "cap explains how to add another attachment" true
(Option.is_some (find "text" "9 attachments maximum. Remove one to add another."));
for i = 3 to 9 do
dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" (Printf.sprintf "Remove Extra attachment %d" i))))
done;
check true;
Alcotest.(check bool) "removal retires cap feedback" true
(Option.is_none (find "text" "9 attachments maximum. Remove one to add another.")));
if scenario = "selection-error" then (
wait "partial selection failure is visible on its owner" (fun () -> Option.is_some (find "text" "Fixture partial selection failed"));
collapse (); back (); enter 2; open_capture ();
Expand Down Expand Up @@ -1645,10 +1699,21 @@ let run_favorites_native_visibility
(Atomic.get imports_entered);
Atomic.set import_release true;
wait "both imports finish" (fun () -> Atomic.get imports_finished = 2);
if scenario = "retry-imports"
if List.mem scenario ["retry-imports";"interrupted-imports"]
then (
wait "failed import exposes target retry" (fun () ->
Option.is_some (find "accessibility-label" "Retry imports"));
dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Details")));
wait "retained imports explain in-place retry" (fun () ->
Option.is_some (find "text"
"Your journal entry and other attachments are kept. Select Retry imports to try again."));
if scenario = "interrupted-imports" then
Alcotest.(check bool) "interrupted retained import gives retry guidance" true
(Option.is_some (find "text" "Import was interrupted. Select Retry imports to try again."));
Alcotest.(check bool) "retained import never requires reselection" true
(Option.is_none (find "text"
"Your journal entry and other attachments are kept. Select the file again to retry."));
dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Close")));
(* Routes reducers own the path, but cannot express native
feedback ancestry or layout. Mount the real failure through the
public Application boundary; navigation must cover the
Expand Down Expand Up @@ -1748,14 +1813,14 @@ let run_favorites_native_visibility
"import retry does not create another child"
1
(Atomic.get child_attempts));
if not (List.mem scenario [ "retry-imports"; "selection-error" ]) then (
if not (List.mem scenario [ "retry-imports"; "interrupted-imports"; "selection-error" ]) then (
wait "failed import offers explicit discard" (fun () -> Option.is_some (find "accessibility-label" "Dismiss"));
dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Dismiss"))));
wait "all staging files are released" (fun () ->
List.for_all (fun p -> not (Sys.file_exists p)) !staged_paths);
Alcotest.(check (list string))
"imports target the newly appended child"
(List.init (if scenario = "retry-imports" then 3 else 2) (fun _ -> G.Uuid.to_string child.uuid))
(List.init (if List.mem scenario ["retry-imports";"interrupted-imports"] then 3 else 2) (fun _ -> G.Uuid.to_string child.uuid))
(List.map G.Uuid.to_string (Atomic.get import_targets));
if scenario = "offscreen-attachments"
then
Expand Down Expand Up @@ -3122,6 +3187,11 @@ let run_favorites_native_visibility
true
(Option.is_some (find "style-class" "composer-input"));
let editor = Option.get (find "style-class" "composer-input") in
(* Navigation/Capture reducers cannot express the native keyboard label. *)
Alcotest.(check (option bool)) "multiline composer requests Return"
(Some false)
(Option.map Yojson.Safe.Util.to_bool
(List.assoc_opt "submit-on-enter" (Hashtbl.find props editor)));
Hashtbl.clear regions;
dispatch (Lui_protocol.TextChanged (editor, "iOS persistent draft"));
for _ = 1 to 10 do
Expand Down Expand Up @@ -4649,6 +4719,9 @@ let () =
; "selection-error"
; "picker-owner"
; "retry-imports"
; "attachment-limit"
; "interrupted-imports"
; "legacy-interrupted"
] )
; ( "Capture attachment imports"
, [ Alcotest.test_case
Expand Down
41 changes: 41 additions & 0 deletions test/journal_routes_test.ml
Original file line number Diff line number Diff line change
Expand Up @@ -2811,6 +2811,45 @@ let test_detail_retry_uses_128_item_first_page () =
| _ -> fail "Detail retry lost its first-page owner"
;;

(* Capture owns admission, source preservation and attachment-only naming;
reproduce Unicode blank input through its public reducer boundary. *)
let test_unicode_blank_capture_admission () =
let whitespace = List.init 5 (fun i -> Uchar.of_int (i + 9))
@ List.map Uchar.of_int [0x20;0x85;0xa0;0x1680]
@ List.init 11 (fun i -> Uchar.of_int (i + 0x2000))
@ List.map Uchar.of_int [0x2028;0x2029;0x202f;0x205f;0x3000] in
let encoded chars =
let b = Buffer.create 64 in
List.iter (Buffer.add_utf_8_uchar b) chars; Buffer.contents b in
let blank_sources = "" :: "  \n " :: encoded whitespace :: List.map (fun c -> encoded [c]) whitespace in
let admit capture = Journal_capture.admit_save capture
~mutation_id:"70000000-0000-4000-9000-000000000014"
~block_id:"70000000-0000-4000-a000-000000000014"
~sibling_order:"000000000014" ~calendar_generation:7L
~creation_time:(creation_time 543) in
let pick = staged_pick ~operation:"70000000-0000-4000-a000-00000000a014"
~path:"/tmp/blank-title.txt" ~title:"blank-title.txt" ~file_type:"txt" in
List.iter (fun source ->
let capture = Journal_capture.create ~session_number:14L ~source in
require (not (Journal_capture.can_save capture)) "Unicode blank %S enabled Send" source;
require (Option.is_none (snd (admit capture))) "Unicode blank %S admitted Save" source;
let attached = Journal_capture.add_attachment capture pick in
require (Journal_capture.can_save attached) "Unicode blank attachment-only disabled Save";
match snd (admit attached) with
| Some (Journal_graph_request.Capture {command;_}) ->
require_string "blank-title.txt" command.source "blank attachment-only title"
| _ -> fail "Unicode blank attachment-only did not admit Save") blank_sources;
List.iter (fun source ->
let capture = Journal_capture.create ~session_number:15L ~source:"" in
let capture = Journal_capture.update_source capture ~source in
require (Journal_capture.can_save capture) "Meaningful text %S disabled Send" source;
match snd (admit capture) with
| Some (Journal_graph_request.Capture {command;_}) ->
require_string source command.source "meaningful source must remain exact"
| _ -> fail "Meaningful text %S did not admit Save" source)
[" 中文\n second line ";" 🌱 ";"​";""]
;;

(* These regressions reproduce the review findings through Detail's public
owner, including native visibility observations and admitted completions. *)
let followup_block number parent =
Expand Down Expand Up @@ -3145,6 +3184,8 @@ let test_followup_review_regressions () =
;;

let tests =
[ "Unicode blank Capture admission", test_unicode_blank_capture_admission
] @
[ "PR63 public review regressions", test_followup_review_regressions
] @
[ "projected unknown children are read once", test_projected_unknown_children_are_read_once
Expand Down
Loading