ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
300 lines
9.8 KiB
Rust
300 lines
9.8 KiB
Rust
//! Kernel lists for references, review and checks (DOC-09 to 12).
|
|
|
|
use std::io;
|
|
|
|
use crate::Rect;
|
|
|
|
/// A comment from `.uno:ViewAnnotations`.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Note {
|
|
pub id: String,
|
|
/// "0" for a thread's first comment.
|
|
pub parent: String,
|
|
pub author: String,
|
|
pub text: String,
|
|
pub resolved: bool,
|
|
/// ISO 8601, as the kernel gives it.
|
|
pub at: String,
|
|
pub anchor: Rect,
|
|
}
|
|
|
|
/// A tracked change from `.uno:AcceptTrackedChanges`.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Change {
|
|
pub index: u64,
|
|
pub author: String,
|
|
pub kind: String,
|
|
pub description: String,
|
|
pub at: String,
|
|
pub anchor: Option<Rect>,
|
|
}
|
|
|
|
/// Words and characters, in the document and the selection.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Counts {
|
|
pub words: u64,
|
|
pub characters: u64,
|
|
pub selected_words: u64,
|
|
pub selected_characters: u64,
|
|
}
|
|
|
|
/// A misspelled word's menu: suggestions as the kernel's rules.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Spelling {
|
|
/// Label and `ApplyRule` value for each suggestion.
|
|
pub suggestions: Vec<(String, String)>,
|
|
}
|
|
|
|
/// The counts from `.uno:WordCount`.
|
|
pub fn counts(json: &str) -> io::Result<Counts> {
|
|
let list = parse(json)?;
|
|
let (document, selection) = (&list["document"], &list["selection"]);
|
|
Ok(Counts {
|
|
words: number(document, "words")?,
|
|
characters: number(document, "characters")?,
|
|
selected_words: number(selection, "words")?,
|
|
selected_characters: number(selection, "characters")?,
|
|
})
|
|
}
|
|
|
|
/// A context menu's spelling part, if it has one.
|
|
pub fn spelling(json: &str) -> io::Result<Option<Spelling>> {
|
|
let list = parse(json)?;
|
|
let items = entries(&list, "menu")?;
|
|
let commands: Vec<&str> = items
|
|
.iter()
|
|
.filter_map(|item| item["command"].as_str())
|
|
.collect();
|
|
if !commands
|
|
.iter()
|
|
.any(|command| command.starts_with(".uno:SpellCheckIgnoreAll"))
|
|
{
|
|
return Ok(None);
|
|
}
|
|
let suggestions = items
|
|
.iter()
|
|
.filter_map(|item| {
|
|
let rule = item["command"]
|
|
.as_str()?
|
|
.strip_prefix(".uno:SpellCheckApplySuggestion?ApplyRule:string=")?;
|
|
Some((item["text"].as_str()?.to_string(), rule.to_string()))
|
|
})
|
|
.collect();
|
|
Ok(Some(Spelling { suggestions }))
|
|
}
|
|
|
|
/// A heading from `.uno:Outline`.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Heading {
|
|
pub text: String,
|
|
/// 0 for the top level.
|
|
pub level: u64,
|
|
pub rect: Rect,
|
|
/// The cursor sits under it.
|
|
pub current: bool,
|
|
}
|
|
|
|
/// The document's headings, in order.
|
|
pub fn outline(json: &str) -> io::Result<Vec<Heading>> {
|
|
let list = parse(json)?;
|
|
entries(&list, "outline")?
|
|
.iter()
|
|
.map(|entry| {
|
|
Ok(Heading {
|
|
text: text(entry, "text")?,
|
|
level: number(entry, "level")?,
|
|
rect: rect(&text(entry, "rect")?)?,
|
|
current: entry["current"]
|
|
.as_bool()
|
|
.ok_or_else(|| invalid(format!("no current flag: {entry}")))?,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The document's comments, in kernel order.
|
|
pub fn notes(json: &str) -> io::Result<Vec<Note>> {
|
|
let list = parse(json)?;
|
|
entries(&list, "comments")?
|
|
.iter()
|
|
.map(|entry| {
|
|
Ok(Note {
|
|
id: number(entry, "id")?.to_string(),
|
|
parent: number(entry, "parentId")?.to_string(),
|
|
author: text(entry, "author")?,
|
|
text: plain(&text(entry, "html")?),
|
|
resolved: text(entry, "resolved")? == "true",
|
|
at: text(entry, "dateTime")?,
|
|
anchor: rect(&text(entry, "anchorPos")?)?,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The document's tracked changes, in kernel order.
|
|
pub fn changes(json: &str) -> io::Result<Vec<Change>> {
|
|
let list = parse(json)?;
|
|
entries(&list, "redlines")?
|
|
.iter()
|
|
.map(|entry| {
|
|
let range = text(entry, "textRange")?;
|
|
let first = range.split(';').next().unwrap_or_default();
|
|
Ok(Change {
|
|
index: number(entry, "index")?,
|
|
author: text(entry, "author")?,
|
|
kind: text(entry, "type")?,
|
|
description: text(entry, "description")?,
|
|
at: text(entry, "dateTime")?,
|
|
anchor: (!first.trim().is_empty())
|
|
.then(|| rect(first))
|
|
.transpose()?,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// A bookmark from `.uno:Bookmarks`.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Bookmark {
|
|
pub name: String,
|
|
/// None when the layout does not show it.
|
|
pub rect: Option<Rect>,
|
|
}
|
|
|
|
/// The document's bookmarks.
|
|
pub fn bookmarks(json: &str) -> io::Result<Vec<Bookmark>> {
|
|
let list = parse(json)?;
|
|
entries(&list, "bookmarks")?
|
|
.iter()
|
|
.map(|entry| {
|
|
Ok(Bookmark {
|
|
name: text(entry, "name")?,
|
|
rect: entry
|
|
.get("rect")
|
|
.map(|_| rect(&text(entry, "rect")?))
|
|
.transpose()?,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The selected formula from `.uno:Formula`, if one is.
|
|
pub fn formula(json: &str) -> io::Result<Option<String>> {
|
|
let list = parse(json)?;
|
|
match &list["formula"] {
|
|
serde_json::Value::Null => Ok(None),
|
|
value => value
|
|
.as_str()
|
|
.map(|formula| Some(formula.to_string()))
|
|
.ok_or_else(|| invalid(format!("a bad formula: {value}"))),
|
|
}
|
|
}
|
|
|
|
fn parse(json: &str) -> io::Result<serde_json::Value> {
|
|
serde_json::from_str(json).map_err(|error| invalid(format!("a bad kernel list: {error}")))
|
|
}
|
|
|
|
fn entries<'a>(list: &'a serde_json::Value, key: &str) -> io::Result<&'a Vec<serde_json::Value>> {
|
|
list[key]
|
|
.as_array()
|
|
.ok_or_else(|| invalid(format!("the list has no {key}")))
|
|
}
|
|
|
|
fn text(entry: &serde_json::Value, key: &str) -> io::Result<String> {
|
|
entry[key]
|
|
.as_str()
|
|
.map(str::to_string)
|
|
.ok_or_else(|| invalid(format!("an entry has no {key}: {entry}")))
|
|
}
|
|
|
|
fn number(entry: &serde_json::Value, key: &str) -> io::Result<u64> {
|
|
entry[key]
|
|
.as_u64()
|
|
.ok_or_else(|| invalid(format!("an entry has no {key}: {entry}")))
|
|
}
|
|
|
|
/// "x, y, w, h" in twips.
|
|
fn rect(text: &str) -> io::Result<Rect> {
|
|
let parts: Vec<i32> = text
|
|
.split(',')
|
|
.map(|part| part.trim().parse())
|
|
.collect::<Result<_, _>>()
|
|
.map_err(|_| invalid(format!("a bad rectangle: {text}")))?;
|
|
match parts[..] {
|
|
[x, y, w, h] => Ok((x, y, w, h)),
|
|
_ => Err(invalid(format!("a bad rectangle: {text}"))),
|
|
}
|
|
}
|
|
|
|
/// The kernel's simple comment HTML as text.
|
|
fn plain(html: &str) -> String {
|
|
let mut out = String::new();
|
|
let mut rest = html;
|
|
// An empty paragraph holds one br.
|
|
let mut broke = false;
|
|
while let Some(open) = rest.find('<') {
|
|
out.push_str(&rest[..open]);
|
|
broke &= open == 0;
|
|
let Some(close) = rest[open..].find('>') else {
|
|
break;
|
|
};
|
|
let tag = rest[open + 1..open + close].trim().to_ascii_lowercase();
|
|
if tag.starts_with("br") || ((tag == "/div" || tag == "/p") && !broke) {
|
|
out.push('\n');
|
|
}
|
|
broke = tag.starts_with("br");
|
|
rest = &rest[open + close + 1..];
|
|
}
|
|
out.push_str(rest);
|
|
// The last paragraph's end is no line.
|
|
out.strip_suffix('\n')
|
|
.unwrap_or(&out)
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace(""", "\"")
|
|
.replace("'", "'")
|
|
.replace(" ", " ")
|
|
.replace("&", "&")
|
|
}
|
|
|
|
fn invalid(message: String) -> io::Error {
|
|
io::Error::new(io::ErrorKind::InvalidData, message)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn comments_and_changes_parse() {
|
|
let notes = notes(r#"{"comments":[{"id":1,"parentId":0,"author":"A","html":"<div>One & two</div><div>three</div>","resolved":"false","dateTime":"2026-10-04T10:00:00","anchorPos":"4490, 14759, 1, 231","textRange":""}]}"#)
|
|
.expect("notes parse");
|
|
assert_eq!(notes[0].text, "One & two\nthree");
|
|
assert_eq!(
|
|
plain("<div>a</div><div><br/></div><div>Next</div>"),
|
|
"a\n\nNext"
|
|
);
|
|
assert_eq!(plain("<div>a<br/>b</div>"), "a\nb");
|
|
assert_eq!(
|
|
plain("<div>a</div><div><br/></div><div><br/></div>"),
|
|
"a\n\n"
|
|
);
|
|
assert_eq!(notes[0].anchor, (4490, 14759, 1, 231));
|
|
let changes = changes(r#"{"redlines":[{"index":1,"author":"A","type":"Insert","comment":"","description":"Insert x","dateTime":"2026-10-04T10:00:00","textRange":"4490, 14759, 648, 230; 1, 2, 3, 4"}]}"#)
|
|
.expect("changes parse");
|
|
assert_eq!(changes[0].anchor, Some((4490, 14759, 648, 230)));
|
|
assert!(super::notes(r#"{"comments":[{"id":1}]}"#).is_err());
|
|
assert_eq!(formula("{}").expect("empty"), None);
|
|
let counts = counts(r#"{"document":{"words":11,"characters":41,"charactersNoSpaces":31},"selection":{"words":2,"characters":7,"charactersNoSpaces":6}}"#)
|
|
.expect("counts parse");
|
|
assert_eq!((counts.words, counts.selected_characters), (11, 7));
|
|
let menu = r#"{"menu":[{"text":"The","type":"command","command":".uno:SpellCheckApplySuggestion?ApplyRule:string=Spelling_The"},{"type":"separator"},{"text":"Ignore All","type":"command","command":".uno:SpellCheckIgnoreAll?Type:string=Spelling"}]}"#;
|
|
let found = spelling(menu)
|
|
.expect("the menu parses")
|
|
.expect("a spelling menu");
|
|
assert_eq!(found.suggestions, [("The".into(), "Spelling_The".into())]);
|
|
let other = r#"{"menu":[{"text":"Cut","type":"command","command":".uno:Cut"}]}"#;
|
|
assert_eq!(spelling(other).expect("the menu parses"), None);
|
|
}
|
|
}
|