Files
deckcraft/crates/engine/src/cmd/review.rs
T

457 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Review tab: comments, spelling, accessibility; Slide Show requests.
use std::sync::Arc;
use deckcraft_model::Comment;
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session, UiRequest};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("comment.add", "New Comment", ["Review", "Comments"], Some("Cmd+Alt+M"), "{text, x?, y?, id?: shape}", has_slide, add),
cmd!("comment.reply", "Reply", ["Comments"], None, "{index, text}", has_slide, reply),
cmd!("comment.delete", "Delete Comment", ["Review", "Comments"], None, "{index? | all?: bool}", has_slide, delete),
cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve),
cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list),
cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility),
cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{lang?, ignore?:[word]} → [{slide, shape, para, start, end, word, lang, suggestions}] (each run in its own language, lang? for runs without one; Hunspell dictionaries installed on the system, else a built-in list of common misspellings)", has_doc, spelling),
cmd!(
"review.spellingChange",
"Change Spelling",
["Review", "Proofing"],
None,
"{shape, word, to, all?, para?, start?} → {changed} (replace the whole word in that shape's text, the one at para/start, or in every shape with all)",
has_doc,
spelling_change
),
cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary),
cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info),
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
let i = s.doc()?.selection.slide;
start(s, i)
}),
cmd!(
"show.setup",
"Set Up Slide Show…",
["Slide Show", "Set Up"],
None,
"{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, from?, to?}",
has_doc,
setup
),
cmd!(
"show.customShow",
"Custom Slide Show",
["Slide Show", "Start Slide Show"],
None,
"{name, slides: [index]} | {name, delete: true}",
has_doc,
custom_show
),
]
}
fn now() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let (y, m, d) = super::design::civil((secs / 86_400) as i64);
let t = secs % 86_400;
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60)
}
#[cfg(target_arch = "wasm32")]
{
String::new()
}
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").ok_or_else(|| bad("comment.add", "missing `text`"))?.to_string();
let author = s.prefs.author.clone();
let shape = id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied()));
let (x, y) = (f64_or(p, "x", 10.0), f64_or(p, "y", 10.0));
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.add", "no slide"))?);
let initials: String = author.split_whitespace().filter_map(|w| w.chars().next()).collect();
sl.comments.push(Comment { author: author.clone(), initials, text, date: now(), x, y, resolved: false, replies: vec![], shape });
Ok(json!({"index": sl.comments.len() - 1}))
})
}
fn reply(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").ok_or_else(|| bad("comment.reply", "missing `index`"))?;
let text = str_param(p, "text").unwrap_or("").to_string();
let author = s.prefs.author.clone();
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.reply", "no slide"))?);
let c = sl.comments.get_mut(i).ok_or_else(|| bad("comment.reply", "no such comment"))?;
c.replies.push(Comment { author, text, date: now(), ..Default::default() });
Ok(())
})?;
ok()
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let all = bool_or(p, "all", false);
let i = usize_param(p, "index");
s.edit(|doc, sel| {
if all {
for sl in &mut doc.slides {
Arc::make_mut(sl).comments.clear();
}
return Ok(());
}
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.delete", "no slide"))?);
match i {
Some(i) if i < sl.comments.len() => {
sl.comments.remove(i);
}
None => sl.comments.clear(),
_ => return Err(bad("comment.delete", "no such comment")),
}
Ok(())
})?;
ok()
}
fn resolve(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").ok_or_else(|| bad("comment.resolve", "missing `index`"))?;
let v = bool_or(p, "resolved", true);
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.resolve", "no slide"))?);
sl.comments.get_mut(i).ok_or_else(|| bad("comment.resolve", "no such comment"))?.resolved = v;
Ok(())
})?;
ok()
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
Ok(serde_json::to_value(st.doc.slides.get(i).map(|x| x.comments.clone()).unwrap_or_default()).unwrap_or_default())
}
fn accessibility(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let mut out = vec![];
for (i, sl) in st.doc.slides.iter().enumerate() {
if sl.title().trim().is_empty() {
out.push(json!({"slide": i, "issue": "Missing slide title"}));
}
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
let needs_alt = matches!(
sh.kind,
deckcraft_model::ShapeKind::Picture { .. }
| deckcraft_model::ShapeKind::Chart(_)
| deckcraft_model::ShapeKind::Media(_)
| deckcraft_model::ShapeKind::Group { .. }
);
if needs_alt && sh.descr.trim().is_empty() && !sh.decorative {
out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": "Missing alternative text"}));
}
});
// Low-contrast text against the slide background.
if let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(&st.doc, sl) {
let bg = match deckcraft_model::resolve::background(&ctx, Some(sl)) {
(deckcraft_model::Fill::Solid { color }, ph) => Some(ctx.color(&color, ph)),
_ => None,
};
if let Some(bg) = bg {
for sh in &sl.shapes {
let Some(t) = &sh.text else { continue };
let Some(para) = t.paragraphs.iter().find(|p| !p.is_empty()) else { continue };
let rp = deckcraft_model::resolve::run(&ctx, sh, para, &para.runs.first().map(|r| r.props.clone()).unwrap_or_default());
let (fill, _) = deckcraft_model::resolve::fill(&ctx, sh);
if fill.as_ref().is_some_and(|f| !f.is_none()) {
continue;
}
let fg = deckcraft_model::resolve::text_color(&ctx, &rp);
if fg.contrast(bg) < 3.0 {
out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({:.1}:1)", fg.contrast(bg))}));
}
}
}
}
}
Ok(Value::Array(out))
}
/// Frequent misspellings, used when no dictionary for the language is installed.
const COMMON: &[&str] = &[
"teh",
"recieve",
"seperate",
"occured",
"untill",
"wich",
"becuase",
"definately",
"thier",
"alot",
"accomodate",
"acheive",
"adress",
"begining",
"beleive",
"calender",
"commited",
"enviroment",
"goverment",
"independant",
"neccessary",
"occassion",
"publically",
"reccomend",
"tommorow",
"wierd",
];
/// The language a check uses: the `lang` parameter, else the first run that says, else US English.
fn check_lang(doc: &deckcraft_model::Presentation, p: &Value) -> String {
if let Some(l) = str_param(p, "lang") {
return l.to_string();
}
let mut found = None;
for sl in &doc.slides {
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
if found.is_none()
&& let Some(t) = &sh.text
{
found = t.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone());
}
});
}
found.unwrap_or_else(|| "en-US".into())
}
fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let default = check_lang(&st.doc, p);
let ignore: Vec<String> =
p.get("ignore").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_lowercase).collect()).unwrap_or_default();
let mut dicts: std::collections::HashMap<String, Option<Arc<crate::spell::Dictionary>>> = Default::default();
let mut out = vec![];
for (i, sl) in st.doc.slides.iter().enumerate() {
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
let Some(t) = &sh.text else { return };
for (pi, para) in t.paragraphs.iter().enumerate() {
for w in crate::spell::paragraph_words(para, &default) {
if ignore.contains(&w.text.to_lowercase()) {
continue;
}
let dict = dicts.entry(w.lang.clone()).or_insert_with(|| crate::spell::dictionary(&w.lang)).clone();
let bad = match &dict {
Some(d) => !crate::spell::is_correct(d, &w.text),
None => COMMON.contains(&w.text.to_lowercase().as_str()),
};
if bad {
let sug = dict.as_ref().map(|d| d.suggest(&w.text, 5)).unwrap_or_default();
out.push(json!({"slide": i, "shape": sh.id, "para": pi, "start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "suggestions": sug}));
}
}
}
});
}
Ok(Value::Array(out))
}
fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
let word = str_param(p, "word").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.addToDictionary", "missing `word`"))?;
crate::spell::add_user_word(word).map_err(|e| bad("review.addToDictionary", e.to_string()))?;
Ok(json!({"word": word.trim(), "file": crate::spell::user_dictionary_path().map(|p| p.display().to_string())}))
}
/// Replace whole-word `from` with `to` in `text`; how many were replaced.
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
let mut n = 0;
let mut at = 0;
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len();
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after {
at = end;
continue;
}
text.replace_range(i..end, to);
n += 1;
at = i + to.len();
if !all {
break;
}
}
n
}
fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
let word = str_param(p, "word").filter(|w| !w.is_empty()).ok_or_else(|| bad("review.spellingChange", "missing `word`"))?.to_string();
let to = str_param(p, "to").ok_or_else(|| bad("review.spellingChange", "missing `to`"))?.to_string();
let all = bool_or(p, "all", false);
let shape = p.get("shape").and_then(Value::as_u64);
if shape.is_none() && !all {
return Err(bad("review.spellingChange", "give `shape`, or `all: true`"));
}
// One exact occurrence (a squiggled word): paragraph `para`, from character `start`.
if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) {
let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?);
let end = start + word.chars().count();
let slide = {
let st = s.doc()?;
let found = st.doc.slides.iter().position(|sl| {
deckcraft_model::find_shape(&sl.shapes, id)
.and_then(|sh| sh.text.as_ref()?.paragraphs.get(para))
.is_some_and(|pp| pp.text().chars().skip(start).take(end - start).collect::<String>() == word)
});
found.ok_or_else(|| bad("review.spellingChange", "the word isn't there"))?
};
s.edit(|doc, _| {
let sl = doc.slides.get_mut(slide).ok_or_else(|| bad("review.spellingChange", "no slide"))?;
let sh = deckcraft_model::find_shape_mut(&mut Arc::make_mut(sl).shapes, id).ok_or_else(|| bad("review.spellingChange", "no shape"))?;
if let Some(pp) = sh.text.as_mut().and_then(|t| t.paragraphs.get_mut(para)) {
replace_chars(pp, start, end, &to);
}
Ok(())
})?;
return Ok(json!({"changed": 1}));
}
// Nothing to change makes no undo step.
let mut any = false;
let st = s.doc()?;
for sl in &st.doc.slides {
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
if shape.is_none_or(|id| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64()) == Some(id))
&& let Some(t) = &sh.text
{
any |= t.paragraphs.iter().flat_map(|p| &p.runs).any(|r| replace_word(&mut r.text.clone(), &word, &to, false) > 0);
}
});
}
if !any {
return Ok(json!({"changed": 0}));
}
let changed = s.edit(|doc, _| {
let mut n = 0;
let id_of = |sh: &deckcraft_model::Shape| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64());
for sl in &mut doc.slides {
let mut hit = false;
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
hit |= shape.is_none_or(|id| id_of(sh) == Some(id)) && sh.text.as_ref().is_some_and(|t| t.text().contains(word.as_str()));
});
if !hit || (!all && n > 0) {
continue;
}
walk_text_mut(&mut Arc::make_mut(sl).shapes, 0, &mut |sh| {
if shape.is_some_and(|id| id_of(sh) != Some(id)) || (!all && n > 0) {
return;
}
let Some(t) = sh.text.as_mut() else { return };
for r in t.paragraphs.iter_mut().flat_map(|p| p.runs.iter_mut()) {
if !all && n > 0 {
break;
}
n += replace_word(&mut r.text, &word, &to, all);
}
});
}
Ok(n)
})?;
Ok(json!({"changed": changed}))
}
/// Replace characters `start..end` of `p` with `to`, in the run where they start (later runs lose
/// the rest of the range).
fn replace_chars(p: &mut deckcraft_model::Paragraph, start: usize, end: usize, to: &str) {
let mut at = 0;
let mut placed = false;
for r in &mut p.runs {
let n = r.char_len();
let (a, b) = (start.max(at), end.min(at + n));
if a < b && r.kind == deckcraft_model::text::RunKind::Text {
let byte = |c: usize| r.text.char_indices().nth(c).map_or(r.text.len(), |(i, _)| i);
let (x, y) = (byte(a - at), byte(b - at));
r.text.replace_range(x..y, if placed { "" } else { to });
placed = true;
}
at += n;
}
p.runs.retain(|r| !(r.kind == deckcraft_model::text::RunKind::Text && r.text.is_empty()));
}
fn walk_text_mut(shapes: &mut [deckcraft_model::Shape], depth: usize, f: &mut dyn FnMut(&mut deckcraft_model::Shape)) {
if depth > 64 {
return;
}
for sh in shapes.iter_mut() {
f(sh);
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
walk_text_mut(children, depth + 1, f);
}
}
}
fn dictionary_info(s: &mut Session, p: &Value) -> Result<Value> {
let lang = match s.doc() {
Ok(st) => check_lang(&st.doc, p),
Err(_) => str_param(p, "lang").unwrap_or("en-US").to_string(),
};
let dirs = crate::spell::search_dirs();
let found = crate::spell::find_in(&dirs, &lang).map(|(a, _)| a.with_extension("").display().to_string());
let available = crate::spell::dictionary(&lang).is_some();
Ok(json!({"lang": lang, "available": available, "found": found, "folders": dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>()}))
}
fn start(s: &mut Session, from: usize) -> Result<Value> {
s.ui_requests.push(UiRequest::StartShow { from });
Ok(json!({"from": from}))
}
fn setup(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|doc, _| {
let sh = &mut doc.show;
if let Some(t) = str_param(p, "type") {
sh.show_type = t.to_string();
if t == "kiosk" {
sh.loop_until_esc = true;
}
}
if let Some(v) = bool_param(p, "loop") {
sh.loop_until_esc = v;
}
if let Some(v) = bool_param(p, "noNarration") {
sh.without_narration = v;
}
if let Some(v) = bool_param(p, "noAnimation") {
sh.without_animation = v;
}
if let Some(v) = bool_param(p, "useTimings") {
sh.use_timings = v;
}
match (p.get("from").and_then(Value::as_u64), p.get("to").and_then(Value::as_u64)) {
(Some(a), Some(b)) => sh.range = Some((a.min(u32::MAX as u64) as u32, b.min(u32::MAX as u64) as u32)),
(None, None) if p.get("all").is_some() => sh.range = None,
_ => {}
}
Ok(serde_json::to_value(&*sh).unwrap_or_default())
})
}
fn custom_show(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("show.customShow", "missing `name`"))?.to_string();
let delete = bool_or(p, "delete", false);
let idx: Vec<usize> =
p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()).unwrap_or_default();
s.edit(|doc, _| {
doc.custom_shows.retain(|c| c.name != name);
if !delete {
let slides = idx.iter().filter_map(|i| doc.slides.get(*i).map(|x| x.id)).collect();
doc.custom_shows.push(deckcraft_model::CustomShow { name: name.clone(), slides });
}
Ok(())
})?;
ok()
}