From c13787f71d27d7c5dccdfeec7ceaf85739e46498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 10 Oct 2026 01:27:20 -0400 Subject: [PATCH] ElyDoc: one unreadable note time spares the other notes and the changes; each is named, read aloud and bounded (D-04) --- agents.md | 1 + apps/ely-doc/src/review.rs | 95 +++++++++++++++++++++++--------- apps/ely-doc/src/review/tests.rs | 37 +++++++++++++ crates/ely-ui/src/i18n/refs.rs | 8 +++ tasks/BUGS.md | 2 +- tests/scripts/doc-review.sh | 61 ++++++++++++++++++++ 6 files changed, 177 insertions(+), 27 deletions(-) create mode 100755 tests/scripts/doc-review.sh diff --git a/agents.md b/agents.md index fd75fec..8355677 100644 --- a/agents.md +++ b/agents.md @@ -146,6 +146,7 @@ tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构 tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板) tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限) tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限) +tests/scripts/doc-review.sh 真窗口 ElyDoc:无日期的批注逐条写明作者与原因(AX 可读),多条时批注与修订列表仍在窗口内(需辅助功能权限) tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限) tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志 tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程 diff --git a/apps/ely-doc/src/review.rs b/apps/ely-doc/src/review.rs index 83b0069..456a310 100644 --- a/apps/ely-doc/src/review.rs +++ b/apps/ely-doc/src/review.rs @@ -8,11 +8,11 @@ use std::{ use ely_gpui_component::{ buttons::{Button, ButtonVariant, IconButton, SegmentedControl}, collab::{Comment, CommentSidebar, Peer, Thread}, - feedback::EmptyState, + feedback::{EmptyState, InlineMessage}, forms::{Switch, TextInput}, layout::ScrollArea, lists::{List, ListItem}, - primitives::IconName, + primitives::{IconName, Severity}, theme::ControlSize, }; use ely_lok::{ @@ -20,7 +20,7 @@ use ely_lok::{ review::{Change, Note}, }; use gpui::{ - App, Context, Entity, EventEmitter, IntoElement, Menu, MenuItem, ParentElement as _, Render, + App, Context, Entity, EventEmitter, InteractiveElement as _, IntoElement, Menu, StatefulInteractiveElement as _, MenuItem, ParentElement as _, Render, SharedString, Styled as _, Subscription, Window, actions, div, }; @@ -86,6 +86,8 @@ pub struct Review { shown: Option, notes: Vec, threads: Vec, + /// Notes whose time does not read: time and why. + unread: Vec, pub(crate) changes: Vec, active: Option, pane: Pane, @@ -100,6 +102,7 @@ impl Review { shown: None, notes: Vec::new(), threads: Vec::new(), + unread: Vec::new(), changes: Vec::new(), active: None, pane: Pane::Comments, @@ -117,6 +120,7 @@ impl Review { self.active = None; self.notes.clear(); self.threads.clear(); + self.unread.clear(); self.changes.clear(); cx.notify(); let Some((view, doc)) = tab else { @@ -161,21 +165,8 @@ impl Review { return; } shown.reading = false; - let read = read.and_then(|(notes, changes)| { - let threads = threads(¬es).map_err(std::io::Error::other)?; - Ok((notes, threads, changes)) - }); match read { - Ok((notes, threads, changes)) => { - log::info!( - "doc: review lists {} notes, {} changes", - notes.len(), - changes.len() - ); - review.notes = notes; - review.threads = threads; - review.changes = changes; - } + Ok((notes, changes)) => review.take_lists(notes, changes), Err(error) => cx.emit(LokError(format!( "the review lists are unreadable: {error}" ))), @@ -366,12 +357,61 @@ impl Render for Review { .p_2() .child(head) .child(pane) + // Many bad notes scroll; the lists stay in reach. + .children(self.unread_alert(cx).map(|alert| { + ScrollArea::new("doc-review-unread-scroll").vertical().w_full().max_h(gpui::rems(6.)).flex_none().child(alert) + })) .child(div().flex_1().min_h_0().child(body)) } } -/// The notes as threads under their first comment. -fn threads(notes: &[Note]) -> Result, String> { +/// A note whose time does not read, and why. +#[derive(Clone, Debug, PartialEq)] +pub struct Unread { + pub author: String, + pub at: String, + pub error: String, +} + +impl Review { + /// The kernel's lists; one bad time spares the rest. + fn take_lists(&mut self, notes: Vec, changes: Vec) { + let (threads, unread) = threads(¬es); + for bad in &unread { + log::error!("doc: a bad comment time {:?}: {}", bad.at, bad.error); + } + log::info!("doc: review lists {} notes, {} changes", notes.len(), changes.len()); + (self.notes, self.threads, self.unread, self.changes) = (notes, threads, unread, changes); + } + + /// Each unreadable note, by its author and the reason. + fn unread_lines(&self, cx: &App) -> Vec { + let line = |bad: &Unread| { + Docs::text(cx, "doc.review.unread", &[("author", &bad.author), ("at", &bad.at), ("error", &bad.error)]) + }; + self.unread.iter().map(line).collect() + } + + /// The lines as one alert assistive tech reads whole. + fn unread_alert(&self, cx: &App) -> Option { + let lines = self.unread_lines(cx); + let said: SharedString = lines.join("\n").into(); + (!lines.is_empty()).then(|| { + div() + .id("doc-review-unread") + .role(gpui::Role::Alert) + .aria_label(said) + .flex() + .flex_col() + .gap_1() + .children(lines.into_iter().map(|line| InlineMessage::new(Severity::Danger, line))) + .into_any_element() + }) + } +} + +/// Notes threaded under their first comment; bad times apart. +fn threads(notes: &[Note]) -> (Vec, Vec) { let parents: HashMap<&str, &str> = notes .iter() .map(|note| (note.id.as_str(), note.parent.as_str())) @@ -384,13 +424,16 @@ fn threads(notes: &[Note]) -> Result, String> { id.to_string() }; let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect(); - let mut threads: Vec = Vec::new(); + let (mut threads, mut unread): (Vec, Vec) = (Vec::new(), Vec::new()); for note in notes { - let at = note - .at - .parse::() - .and_then(|at| at.to_zoned(jiff::tz::TimeZone::system())) - .map_err(|error| format!("a bad comment time {}: {error}", note.at))?; + let read = note.at.parse::().and_then(|at| at.to_zoned(jiff::tz::TimeZone::system())); + let at = match read { + Ok(at) => at, + Err(error) => { + unread.push(Unread { author: note.author.clone(), at: note.at.clone(), error: error.to_string() }); + continue; + } + }; let hue = authors .iter() .position(|name| *name == note.author) @@ -412,7 +455,7 @@ fn threads(notes: &[Note]) -> Result, String> { }), } } - Ok(threads) + (threads, unread) } #[cfg(test)] diff --git a/apps/ely-doc/src/review/tests.rs b/apps/ely-doc/src/review/tests.rs index 48266e1..360b063 100644 --- a/apps/ely-doc/src/review/tests.rs +++ b/apps/ely-doc/src/review/tests.rs @@ -82,3 +82,40 @@ fn comments_thread_and_changes_accept_or_reject() { } assert!(!text.contains("gone"), "the rejected words go"); } + +#[test] +fn one_unreadable_time_spares_the_other_notes() { + let note = |id: &str, parent: &str, at: &str| Note { + id: id.into(), + parent: parent.into(), + author: "A".into(), + text: "t".into(), + resolved: false, + at: at.into(), + anchor: (0, 0, 0, 0), + }; + // The kernel writes an empty date as 0-00-00. + let mut undated = note("2", "0", "0-00-00"); + undated.author = "Undated".into(); + let notes = vec![note("1", "0", "2025-03-01T10:00:00"), undated, note("3", "1", "2025-03-02T09:00:00")]; + let (threads, unread) = threads(¬es); + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].comments.len(), 2, "the reply stays in its thread"); + assert_eq!((unread.len(), unread[0].author.as_str(), unread[0].at.as_str()), (1, "Undated", "0-00-00")); + // Through the refresh path: changes stay, the line names both. + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = gpui::TestAppContext::single(); + let Some(docs) = crate::tests::docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let review = cx.update(|app| docs.read(app).review.clone()); + let change = Change { index: 7, author: "B".into(), kind: "Insert".into(), description: String::new(), at: "2025-03-01T10:00:00".into(), anchor: None }; + let lines = cx.update(|cx| review.update(cx, |review, cx| { + review.take_lists(notes, vec![change]); + assert_eq!(review.changes.len(), 1, "the changes stay"); + review.unread_lines(cx) + })); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("Undated") && lines[0].contains(&unread[0].error), "{}", lines[0]); +} diff --git a/crates/ely-ui/src/i18n/refs.rs b/crates/ely-ui/src/i18n/refs.rs index 89786e0..6dd6f6b 100644 --- a/crates/ely-ui/src/i18n/refs.rs +++ b/crates/ely-ui/src/i18n/refs.rs @@ -50,6 +50,10 @@ pub(super) const EN: &[(&str, &str)] = &[ ("doc.review.accept_all", "Accept All"), ("doc.review.reject_all", "Reject All"), ("doc.review.panel", "Comments and Changes"), + ( + "doc.review.unread", + "A comment by {author} has an unreadable time {at}: {error}", + ), ("doc.review.comments", "Comments {n}"), ("doc.review.changes", "Changes {n}"), ("doc.review.accept", "Accept"), @@ -101,6 +105,10 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("doc.review.accept_all", "全部接受"), ("doc.review.reject_all", "全部拒绝"), ("doc.review.panel", "批注与修订"), + ( + "doc.review.unread", + "{author} 的一条批注时间读不出({at}):{error}", + ), ("doc.review.comments", "批注 {n}"), ("doc.review.changes", "修订 {n}"), ("doc.review.accept", "接受"), diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 9326b94..fcad4c2 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -50,7 +50,7 @@ - [x] D-01 导出或打印对话框开着时关闭其标签即崩溃 — Docs 实现 tab_closed,关标签即撤下其导出对话框;R1 PASS - [x] D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录 — 选数据时记下模板标签,预览副本不作模板;R1 测试绕过选 CSV 入口;R2 PASS - [x] D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名) — 后台读取,回来核对标签、视图与对话框请求号 -- [ ] D-04 一条批注的时间读不出,全部批注与修订都不显示 +- [x] D-04 一条批注的时间读不出,全部批注与修订都不显示 — 逐条解析,读不出的批注写明作者与原因(AX 可读、可滚动),修订照留 - [ ] D-05 邮件合并某条记录失败时内核文档泄漏 - [ ] D-06 导航栏大纲点击按当前列表取下标,后台重读后可能越界 - [ ] D-07 导航栏隐藏时仍随每次光标移动重读大纲与书签 diff --git a/tests/scripts/doc-review.sh b/tests/scripts/doc-review.sh new file mode 100755 index 0000000..86b1865 --- /dev/null +++ b/tests/scripts/doc-review.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# ElyDoc: undated notes are named; the lists stay visible. +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } +work=$(mktemp -d) +trap 'kill "${pid:-0}" 2>/dev/null || true; rm -rf "$work"' EXIT +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml" +swiftc -O -o "$work/axv" "$root/tests/scripts/ax-values.swift" +[ "$("$work/axv" $$)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; } +# One dated note, twenty without a date, one tracked insertion. +python3 - "$work/notes.docx" <<'PY' +import sys, zipfile +W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' +notes = ['Valid note'] +paras = [] +for i in range(21): + if i: + notes.append(f'Note {i}') + paras.append(f'Para {i}') +paras.append('Tracked') +rel = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' +files = { + '[Content_Types].xml': '', + '_rels/.rels': f'', + 'word/_rels/document.xml.rels': f'', + 'word/comments.xml': f'' + ''.join(notes) + '', + 'word/document.xml': f'' + ''.join(paras) + '', +} +with zipfile.ZipFile(sys.argv[1], 'w') as z: + for name, text in files.items(): + z.writestr(name, text) +PY +ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" XDG_DATA_HOME="$work/data" \ + perl -e 'alarm 120; exec @ARGV' "$root/target/debug/ely-doc" "$work/notes.docx" >"$work/out" 2>"$work/err" & +pid=$! +log() { find "$work" -path '*/ElyOffice/ely-doc/ely.log' 2>/dev/null | head -n 1; } +for _ in $(seq 200); do grep -q "review lists 21 notes, 1 changes" "$(log)" 2>/dev/null && break; sleep 0.2; done +grep -q "review lists 21 notes, 1 changes" "$(log)" || fail "the lists did not read: $(tail -n 3 "$(log)")" +"$work/axv" "$pid" >/dev/null +osascript -e "tell application \"System Events\" to tell (first process whose unix id is $pid)" -e 'set frontmost to true' \ + -e 'click menu item "Comments and Changes" of menu "Review" of menu bar 1' -e 'end tell' >/dev/null +seen= +for _ in $(seq 10); do + seen=$("$work/axv" "$pid") + grep -q "ValidAuthor: Valid note" <<<"$seen" && break + sleep 1 +done +[ "$(grep -c "A comment by Undated[0-9]* has an unreadable time" <<<"$seen")" = 20 ] || fail "each undated note is not named" +# The valid note's center lies inside the window. +read -r top height < <(osascript -e "tell application \"System Events\" to tell (first process whose unix id is $pid)" \ + -e 'set {x, y} to position of window 1' -e 'set {w, h} to size of window 1' -e 'return (y as text) & " " & (h as text)' -e 'end tell') +at=$(grep "ValidAuthor: Valid note" <<<"$seen" | head -n 1 | sed 's/.*at=[0-9]*,//') +[ -n "$at" ] && [ "$at" -lt $((top + height)) ] || fail "the valid note sits below the window ($at, window ends $((top + height)))" +echo "ok: doc-review"