mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 20:37:10 -06:00
* Tables: select, insert and move with the mouse; double-click a border to AutoFit Like Word, on top of the border resizing: - Selection areas: just above a column the pointer becomes a down arrow, left of a row the row arrow, along a cell's left edge (inside it) the cell arrow. A click selects the column, row or cell; a drag selects every one up to the pointer. Selected cells are shaded as cells, empty ones too. - Insert buttons: left of a row border and above a column border a "+" shows with the border marked; a click adds a row or column there and puts the caret in it. - Move handle past the table's top-left corner: a click selects the table; a drag shows a dashed outline and, on release, the table floats where it is dropped with the text wrapping around it (Word's behaviour). One undo step. Nested tables and tables outside the main text are only selected. - A double-click on a column border fits the column left of it to its contents (the right neighbour gives or takes the room; on the right edge the table's width changes, up to the right margin). - Table Layout > Select > Select Column (catalog 418/449). Whole-cell selection: a column (or a rectangle of cells over several rows) is not one run of the text, so selecting it makes a block of its cells' paragraphs (`CellRange` on the existing `ColumnBlock`). Bold, alignment, Delete, typing, copy and numbering act on those cells only (a numbered column is one list); table commands (shading, merge, delete columns) take the rectangle; Tab moves on without clearing it; Undo brings the selection back. Cells of one row, or whole rows, stay an ordinary stream selection. The format painter no longer clears a block's text (edit.pasteFormat was handled like a paste). The mini toolbar stays hidden over whole-cell selections and during these drags: pinned above the selection it covered the column areas, the "+" buttons and the move handle. Engine: crates/engine/src/cmd/table_mouse.rs - table.selectAt, table.insertAt, table.moveDrag, table.fitColumn (page coordinates, so agents and the control channel can do the same) and table.selectColumn; hit testing reuses table_resize's drawn geometry, and borders keep priority for resizing. 10 engine tests (areas, a column edited cell by cell, numbering, rows/cells/drags, insert buttons, move, fit, format painter, hostile params). UI: crates/ui-egui/src/table_mouse.rs, wired into the existing canvas mouse handler (no extra ui.interact areas, so clicks in a cell's text still place the caret); kittest tests with real pointer events: column click, row drag, "+" button, move-handle drag in one undo step, double-click fit. New labels translated in all eleven catalogs. Progress docs updated: gaps, ui-parity, target-app-parity, ROADMAP, parity checklist. `cargo xtask ci` passes (fmt, clippy, test, assets, layers, wasm). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tables: a selected column's word count is its cells'; merged cells shade once With whole cells selected (a column, or a rectangle that isn't one run of the text) the status bar said "12 of 23 words": `Session::selected_text` took everything between the selection's ends. It now takes each piece's own text, a line each, for a column block too, so the status bar, Word Count and the other callers see what is selected. Since #669 a vertically merged cell is one box as tall as its rows, with a `covered` box for each continuation row; shading selected cells skips those, so a merged cell isn't shaded twice. `selected_paths` follows main's `para_paths_in` (#418): a selection ending at the start of the next paragraph doesn't take it. - Test: a selected column's text is its three cells' and Word Count says three words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tables: double-click AutoFit and the "+" buttons work as in Word Checked against Word (2019, Windows): - A double-click on a column border fits the column left of it to its contents and the columns right of it keep their widths, moving with the border, so the table narrows or widens (up to the right margin). The right neighbour used to give or take the difference. - A "+" insert button selects the new row or column (the caret used to go into it); typing then goes into its first cell. If the table can't take another row or column, nothing changes. - A double-click along a cell's left edge (the cell selection area) selects its row. - Tests: engine (fit keeps the columns on the right; insert selects the new row or column, typing lands in its first cell) and kittest (real double-click on a border; "+" selects the row; double-click by a cell's edge selects the row). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
473 lines
18 KiB
Rust
473 lines
18 KiB
Rust
//! Command implementations, one module per area. Each module exposes `specs()`.
|
|
|
|
pub mod accessibility;
|
|
pub mod canvas;
|
|
pub mod caret;
|
|
pub mod chart;
|
|
pub mod citations;
|
|
pub mod column;
|
|
pub mod connectors;
|
|
pub mod controls;
|
|
pub mod design;
|
|
pub mod draw;
|
|
pub mod edit;
|
|
pub mod equation;
|
|
pub mod fields;
|
|
pub mod file;
|
|
pub mod find_pattern;
|
|
pub mod fmt_revisions;
|
|
pub mod font;
|
|
pub mod format;
|
|
pub mod index;
|
|
pub mod ink_shape;
|
|
pub mod insert;
|
|
pub mod inspector;
|
|
pub mod lists;
|
|
pub mod mailings;
|
|
pub mod moves;
|
|
pub mod objects;
|
|
pub mod options;
|
|
pub mod outline;
|
|
pub mod page;
|
|
pub mod para;
|
|
pub mod paste;
|
|
pub mod phonetic;
|
|
pub mod protect;
|
|
pub mod references;
|
|
pub mod review;
|
|
pub mod smart_art;
|
|
pub mod speech;
|
|
pub mod table;
|
|
pub mod table_draw;
|
|
pub mod table_mouse;
|
|
pub mod table_resize;
|
|
pub mod table_style;
|
|
pub mod templates;
|
|
pub mod text;
|
|
pub mod tools;
|
|
pub mod view;
|
|
pub mod wordart;
|
|
|
|
use serde_json::{Value, json};
|
|
use wordcraft_doc::props::CharProps;
|
|
use wordcraft_doc::{Pos, Revision, RevisionKind, StoryRef};
|
|
|
|
use crate::{CmdError, CmdResult, Registry, Session};
|
|
|
|
/// The full registry.
|
|
pub fn registry() -> Registry {
|
|
let mut v = Vec::new();
|
|
v.extend(text::specs());
|
|
v.extend(caret::specs());
|
|
v.extend(column::specs());
|
|
v.extend(edit::specs());
|
|
v.extend(paste::specs());
|
|
v.extend(format::specs());
|
|
v.extend(font::specs());
|
|
v.extend(phonetic::specs());
|
|
v.extend(para::specs());
|
|
v.extend(lists::specs());
|
|
v.extend(inspector::specs());
|
|
v.extend(view::specs());
|
|
v.extend(outline::specs());
|
|
v.extend(insert::specs());
|
|
v.extend(equation::specs());
|
|
v.extend(page::specs());
|
|
v.extend(table::specs());
|
|
v.extend(table_style::specs());
|
|
v.extend(table_draw::specs());
|
|
v.extend(table_resize::specs());
|
|
v.extend(table_mouse::specs());
|
|
v.extend(review::specs());
|
|
v.extend(file::specs());
|
|
v.extend(options::specs());
|
|
v.extend(design::specs());
|
|
v.extend(references::specs());
|
|
v.extend(fields::specs());
|
|
v.extend(mailings::specs());
|
|
v.extend(citations::specs());
|
|
v.extend(index::specs());
|
|
v.extend(objects::specs());
|
|
v.extend(smart_art::specs());
|
|
v.extend(chart::specs());
|
|
v.extend(draw::specs());
|
|
v.extend(canvas::specs());
|
|
v.extend(wordart::specs());
|
|
v.extend(connectors::specs());
|
|
v.extend(tools::specs());
|
|
v.extend(templates::specs());
|
|
v.extend(protect::specs());
|
|
v.extend(speech::specs());
|
|
v.extend(controls::specs());
|
|
Registry::new(v)
|
|
}
|
|
|
|
/// A shortcut that differs on macOS, where Word for Mac moves it (⌘Q quits, ⌘Space is
|
|
/// Spotlight, ⌘⌥H hides other apps…): `mac` there, `other` everywhere else.
|
|
pub const fn mac_or(mac: &'static str, other: &'static str) -> &'static str {
|
|
if cfg!(target_os = "macos") { mac } else { other }
|
|
}
|
|
|
|
/// A shortcut with no macOS equivalent (Option+letter types the character there).
|
|
pub const fn non_mac(k: &'static str) -> &'static str {
|
|
mac_or("", k)
|
|
}
|
|
|
|
/// Parse a position from JSON (`{"story":"body","path":[0],"off":3}` or `{"block":0,"off":3}`).
|
|
pub fn parse_pos(v: &Value) -> Option<Pos> {
|
|
if let Ok(p) = serde_json::from_value::<Pos>(v.clone()) {
|
|
return Some(p);
|
|
}
|
|
let block = v.get("block")?.as_u64()? as usize;
|
|
let off = v.get("off").and_then(Value::as_u64).unwrap_or(0) as usize;
|
|
Some(Pos::body(block, off))
|
|
}
|
|
|
|
pub fn pos_json(p: &Pos) -> Value {
|
|
serde_json::to_value(p).unwrap_or(Value::Null)
|
|
}
|
|
|
|
/// The standard result: the selection after the command.
|
|
pub fn sel_result(s: &Session) -> CmdResult {
|
|
Ok(json!({"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus)}))
|
|
}
|
|
|
|
/// The paragraphs the selection covers: those between its ends (not the next one when it ends at
|
|
/// that one's start, #418) or, for whole table cells that aren't one run of the text (part of a
|
|
/// column), just the cells' own.
|
|
pub(crate) fn selected_paths(s: &Session) -> Vec<wordcraft_doc::Path> {
|
|
if s.cell_range().is_some()
|
|
&& let Some(segs) = s.column_segments()
|
|
{
|
|
return segs.iter().map(|(a, _)| a.path.clone()).collect();
|
|
}
|
|
let (a, b) = s.sel.ordered();
|
|
s.doc.para_paths_in(&a, &b)
|
|
}
|
|
|
|
/// Delete the selection (respecting track changes); returns the collapsed position.
|
|
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
|
|
let (a, b) = s.sel.ordered();
|
|
if a == b {
|
|
return Ok(a);
|
|
}
|
|
controls::check_delete(s, &a, &b)?;
|
|
let at = if s.doc.settings.track_changes { track_delete(s, &a, &b)? } else { s.doc.delete_range(&a, &b)? };
|
|
s.sel = crate::Selection::caret(at.clone());
|
|
Ok(at)
|
|
}
|
|
|
|
/// A revision record for the current author.
|
|
pub fn new_revision(s: &mut Session, kind: RevisionKind) -> u32 {
|
|
let date = now_iso();
|
|
// Never one end of a move, nor the deletion a cut may still turn into one.
|
|
let held = s.pending_move.as_ref().map(|m| m.rev);
|
|
if let Some((i, _)) = s
|
|
.doc
|
|
.revisions
|
|
.iter()
|
|
.enumerate()
|
|
.rev()
|
|
.find(|(i, r)| r.kind == kind && r.author == s.author && r.date == date && r.move_name.is_none() && held != Some(*i as u32))
|
|
{
|
|
return i as u32;
|
|
}
|
|
s.doc.revisions.push(Revision { kind, author: s.author.clone(), date, move_name: None });
|
|
(s.doc.revisions.len() - 1) as u32
|
|
}
|
|
|
|
/// Whether the paragraph at `path` is followed by another paragraph in the same container (so
|
|
/// its paragraph mark can be removed by joining the two).
|
|
pub fn has_next_para(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> bool {
|
|
matches!(s.doc.block(story, &path.with_last(path.last().saturating_add(1))), Some(wordcraft_doc::Block::Para(_)))
|
|
}
|
|
|
|
/// Remove the paragraph mark of the paragraph at `path`: the next paragraph in the same container
|
|
/// joins it. The joined paragraph keeps this paragraph's properties and ends with the next one's
|
|
/// mark (and that mark's revisions). Returns `false`, changing nothing, when no paragraph follows
|
|
/// in the container (a story's or cell's last mark can't be removed).
|
|
pub fn join_next_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path) -> Result<bool, CmdError> {
|
|
if !has_next_para(s, story, path) {
|
|
return Ok(false);
|
|
}
|
|
let next = path.with_last(path.last().saturating_add(1));
|
|
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
|
|
let revs = s.doc.para(story, &next).map(|p| (p.mark.ins, p.mark.del)).unwrap_or_default();
|
|
s.doc.delete_range(&Pos { story, path: path.clone(), off: len }, &Pos { story, path: next, off: 0 })?;
|
|
let p = s.doc.para_mut(story, path)?;
|
|
(p.mark.ins, p.mark.del) = revs;
|
|
p.touch();
|
|
Ok(true)
|
|
}
|
|
|
|
/// Tracked deletion: own insertions are removed, other text is marked deleted.
|
|
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
|
|
let rid = new_revision(s, RevisionKind::Delete);
|
|
track_delete_as(s, a, b, rid)
|
|
}
|
|
|
|
/// [`track_delete`] marking the deleted text with revision `rid`.
|
|
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<Pos, CmdError> {
|
|
let author = s.author.clone();
|
|
// Remove text this author inserted (it never existed for the reader); mark the rest.
|
|
let paths = s.doc.paths_between(a, b);
|
|
for path in paths.iter().rev() {
|
|
let Some(p) = s.doc.para(a.story, path) else { continue };
|
|
let from = if *path == a.path { a.off } else { 0 };
|
|
let to = if *path == b.path { b.off } else { p.len() };
|
|
// Text already deleted keeps its deletion (and its author), like Word.
|
|
let ranges: Vec<(usize, usize, bool)> = p
|
|
.run_ranges()
|
|
.filter(|(r, c)| r.end > from && r.start < to && c.del.is_none())
|
|
.map(|(r, c)| {
|
|
let own = c.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == author);
|
|
(r.start.max(from), r.end.min(to), own)
|
|
})
|
|
.collect();
|
|
let para = s.doc.para_mut(a.story, path)?;
|
|
for (x, y, own) in ranges.into_iter().rev() {
|
|
if own {
|
|
para.delete(x, y)?;
|
|
} else {
|
|
para.format(x, y, &|c| c.del = Some(rid))?;
|
|
}
|
|
}
|
|
// The paragraph mark is inside the range unless this is its last paragraph. Like text,
|
|
// a mark this author inserted is removed (the paragraphs join again), any other is
|
|
// marked deleted; one already deleted keeps its deletion. Done after the text so a join
|
|
// can't pull the next paragraph's text into this one's range.
|
|
if *path != b.path && has_next_para(s, a.story, path) {
|
|
let (ins, del) = s.doc.para(a.story, path).map(|p| (p.mark.ins, p.mark.del)).unwrap_or_default();
|
|
if del.is_none() {
|
|
let own = ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == author);
|
|
if own {
|
|
join_next_para(s, a.story, path)?;
|
|
} else {
|
|
let p = s.doc.para_mut(a.story, path)?;
|
|
p.mark.del = Some(rid);
|
|
p.touch();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(a.clone())
|
|
}
|
|
|
|
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
|
|
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
|
insert_text(s, text, true)
|
|
}
|
|
|
|
/// Insert pasted plain text like [`type_text`], except that its paragraph separators are text
|
|
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
|
|
/// one would end the list).
|
|
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
|
insert_text(s, text, false)
|
|
}
|
|
|
|
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> {
|
|
let mut props = s.typing_props();
|
|
// Content controls: a placeholder is replaced, locked ones refuse.
|
|
if let Some(p) = controls::prepare_edit(s)? {
|
|
props = p;
|
|
}
|
|
delete_selection(s)?;
|
|
// Typed text is new: never deleted, and not a formatting change of the text around it.
|
|
props.fmt_change = None;
|
|
if s.doc.settings.track_changes {
|
|
props.ins = Some(new_revision(s, RevisionKind::Insert));
|
|
props.del = None;
|
|
} else {
|
|
props.ins = None;
|
|
props.del = None;
|
|
}
|
|
let mut at = s.sel.focus.clone();
|
|
let mut first = true;
|
|
for part in text.split(['\r', '\u{2029}']) {
|
|
if !first {
|
|
at = split_para_as(s, &at, enter)?;
|
|
}
|
|
first = false;
|
|
let part = part.replace('\u{000B}', "\n");
|
|
if !part.is_empty() {
|
|
let starts_para = direction_unset(s, &at);
|
|
at = s.doc.insert_text(&at, &part, &props)?;
|
|
// Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing
|
|
// sets makes it a right-to-left paragraph, so the caret and text start at the right
|
|
// margin. Paragraphs that already have letters, or a direction, keep theirs.
|
|
if starts_para
|
|
&& let Ok(p) = s.doc.para_mut(at.story, &at.path)
|
|
&& wordcraft_doc::bidi::first_strong_is_rtl(&p.text)
|
|
{
|
|
p.props.bidi = Some(true);
|
|
p.touch();
|
|
}
|
|
}
|
|
}
|
|
s.sel = crate::Selection::caret(at);
|
|
s.goal_x = None;
|
|
Ok(())
|
|
}
|
|
|
|
/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and
|
|
/// punctuation), with no direction set on it, its style chain or the document defaults?
|
|
fn direction_unset(s: &Session, at: &Pos) -> bool {
|
|
let Some(p) = s.doc.para_at(at) else { return false };
|
|
if p.props.bidi.is_some() || p.text.chars().any(char::is_alphabetic) || s.doc.styles.default_para.bidi.is_some() {
|
|
return false;
|
|
}
|
|
let style = p.props.style.as_deref().unwrap_or("Normal");
|
|
!s.doc.styles.chain(style).iter().any(|st| st.para.bidi.is_some())
|
|
}
|
|
|
|
/// Take a paragraph out of its list like Word does when Enter or Backspace ends a list: a
|
|
/// "List Paragraph" goes back to Normal; any other style keeps itself with numbering switched off.
|
|
pub fn leave_list(para: &mut wordcraft_doc::Paragraph) {
|
|
if para.props.style.as_deref() == Some("ListParagraph") {
|
|
para.props.style = None;
|
|
para.props.numbering = None;
|
|
} else {
|
|
// `num: 0` overrides numbering a style may carry.
|
|
para.props.numbering = Some(wordcraft_doc::props::NumRef { num: 0, level: 0 });
|
|
}
|
|
para.props.indent_left = None;
|
|
para.props.indent_first = None;
|
|
para.props.indent_left_chars = None;
|
|
para.props.indent_first_chars = None;
|
|
para.touch();
|
|
}
|
|
|
|
/// Split the paragraph at `at` like Enter does: an empty list paragraph leaves the list, the
|
|
/// next paragraph gets the style's "next" style when Enter is at the end.
|
|
pub fn split_para(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
|
|
split_para_as(s, at, true)
|
|
}
|
|
|
|
/// [`split_para`]; without `enter` an empty list item is split like any other paragraph.
|
|
fn split_para_as(s: &mut Session, at: &Pos, enter: bool) -> Result<Pos, CmdError> {
|
|
// `num: 0` means "explicitly not in a list", so it isn't a list item.
|
|
let (at_end, style, empty_list) = match s.doc.para_at(at) {
|
|
Some(p) => (at.off >= p.len(), p.props.style.clone(), p.props.numbering.filter(|n| enter && n.num != 0 && p.is_empty())),
|
|
None => return Err(CmdError::Failed("no paragraph at caret".into())),
|
|
};
|
|
if let Some(n) = empty_list {
|
|
// Enter on an empty list item: a nested item moves up a level, a top-level one ends
|
|
// the list (Word behaviour).
|
|
let para = s.doc.para_mut(at.story, &at.path)?;
|
|
if n.level > 0 {
|
|
para.props.numbering = Some(wordcraft_doc::props::NumRef { num: n.num, level: n.level - 1 });
|
|
para.touch();
|
|
} else {
|
|
leave_list(para);
|
|
}
|
|
return Ok(at.clone());
|
|
}
|
|
let mark_revs = s.doc.para_at(at).map(|p| (p.mark.ins, p.mark.del)).unwrap_or_default();
|
|
let new = s.doc.split_paragraph(at)?;
|
|
if at_end && let Some(st) = style.as_deref() {
|
|
let next = s.doc.styles.get(st).and_then(|x| x.next.clone());
|
|
if let Some(n) = next
|
|
&& n != st
|
|
{
|
|
let p = s.doc.para_mut(new.story, &new.path)?;
|
|
p.props.style = Some(n);
|
|
p.props.numbering = None;
|
|
// A heading's character formatting doesn't carry into body text.
|
|
p.mark = CharProps::default();
|
|
}
|
|
}
|
|
if s.doc.settings.track_changes {
|
|
let rid = new_revision(s, RevisionKind::Insert);
|
|
let p = s.doc.para_mut(at.story, &at.path)?;
|
|
p.mark.ins = Some(rid);
|
|
// The paragraph after the split ends with the original mark: it keeps that mark's
|
|
// revisions, never those of the text at the split point (which would credit the split
|
|
// to the author who inserted that text).
|
|
let t = s.doc.para_mut(new.story, &new.path)?;
|
|
(t.mark.ins, t.mark.del) = mark_revs;
|
|
}
|
|
Ok(new)
|
|
}
|
|
|
|
/// ISO-8601 timestamp (UTC, seconds) for `secs` since the Unix epoch.
|
|
pub fn iso_from_unix_secs(secs: u64) -> String {
|
|
let (y, m, d) = civil_from_days((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)
|
|
}
|
|
|
|
/// Seconds since the Unix epoch (UTC): the system clock, or the browser's on the web.
|
|
pub fn now_unix() -> u64 {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
|
}
|
|
// `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock.
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let ms = js_sys::Date::now();
|
|
// Clamp a NaN or pre-epoch clock to 0 rather than fail.
|
|
if ms.is_finite() && ms > 0.0 { (ms / 1000.0) as u64 } else { 0 }
|
|
}
|
|
}
|
|
|
|
/// ISO-8601 timestamp (UTC, seconds).
|
|
pub fn now_iso() -> String {
|
|
iso_from_unix_secs(now_unix())
|
|
}
|
|
|
|
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
|
|
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
|
let z = z + 719_468;
|
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
let doe = z - era * 146_097;
|
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
let y = yoe + era * 400;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
|
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
|
(if m <= 2 { y + 1 } else { y }, m, d)
|
|
}
|
|
|
|
/// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's.
|
|
pub fn story_param(s: &Session, v: &Value) -> StoryRef {
|
|
v.get("story").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or(s.sel.focus.story)
|
|
}
|
|
|
|
/// The language (`w:lang`) of a paragraph's text: that of its first run with text (else of the
|
|
/// paragraph mark), through styles and the document defaults. Sorting and number parsing follow it.
|
|
pub fn para_lang(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Option<String> {
|
|
let run = p.runs.iter().find(|r| r.len > 0).map(|r| &r.props).unwrap_or(&p.mark);
|
|
doc.styles.resolve_char(p.props.style.as_deref(), run).lang
|
|
}
|
|
|
|
/// The document's default text language (`w:docDefaults`, the Normal style).
|
|
pub fn doc_lang(doc: &wordcraft_doc::Document) -> Option<String> {
|
|
doc.styles.resolve_char(None, &wordcraft_doc::CharProps::default()).lang
|
|
}
|
|
|
|
/// The language of text that names none: the document's default, else the editing language (a
|
|
/// Polish interface writes Polish), else none (English). As AutoCorrect decides it.
|
|
pub fn default_lang(s: &Session) -> Option<String> {
|
|
doc_lang(&s.doc).or_else(|| s.editing_lang.clone())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod iso_tests {
|
|
use super::iso_from_unix_secs;
|
|
|
|
#[test]
|
|
fn formats_epoch_and_known_timestamps() {
|
|
assert_eq!(iso_from_unix_secs(0), "1970-01-01T00:00:00Z");
|
|
assert_eq!(iso_from_unix_secs(1_760_082_785), "2025-10-10T07:53:05Z");
|
|
}
|
|
|
|
#[test]
|
|
fn formats_leap_day_and_year_end() {
|
|
assert_eq!(iso_from_unix_secs(1_709_164_800), "2024-02-29T00:00:00Z");
|
|
assert_eq!(iso_from_unix_secs(951_782_400), "2000-02-29T00:00:00Z");
|
|
assert_eq!(iso_from_unix_secs(1_735_689_599), "2024-12-31T23:59:59Z");
|
|
}
|
|
}
|