Files
wordcraft/crates/engine/src/cmd/references.rs
T

179 lines
8.4 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.
//! References tab: table of contents, footnotes/endnotes, captions; field updates.
use serde_json::{Value, json};
use wordcraft_doc::numbering::{Counters, LevelSuffix};
use wordcraft_doc::para::{InlineObject, NoteKind, OBJ};
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, para_block};
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
mod fields;
mod notes;
mod properties;
mod toc;
use fields::caption;
pub use fields::{CHAPTER_SEPARATORS, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq};
pub(super) use notes::note_options;
use notes::{note, note_refs};
pub use properties::{bookmark_text, date_picture};
#[cfg(test)]
use toc::cached_toc_tabs;
pub use toc::{caret_in_toc, leader, level_range, toc_instr, update_toc};
pub(crate) use toc::{headings, toc_bookmark};
use toc::{insert_toc, remove_toc, toc_range};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("references.toc", "Table of Contents", "References › Table of Contents", insert_toc).params(
r#"{"levels"?: 1-9, "title"?: string, "pageNumbers"?: bool, "rightAlign"?: bool, "tabLeader"?: "dot|hyphen|underscore|none", "hyperlinks"?: bool, "headingStyles"?: bool, "outlineLevels"?: bool, "tcFields"?: bool, "styleLevels"?: {style name: 1-9}} (replaces an existing table; {} updates it)"#,
),
CommandSpec::new("references.updateToc", "Update Table", "References › Table of Contents", |s, _| {
update_toc(s)?;
sel_result(s)
}),
CommandSpec::new("references.removeToc", "Remove Table of Contents", "References › Table of Contents", |s, _| {
if let Some((start, end)) = toc_range(s) {
remove_toc(s, start, end)?;
s.clamp_selection();
}
sel_result(s)
}),
CommandSpec::new("references.addText", "Add Text", "References › Table of Contents", |s, v| {
let level = p::u64(v, "level").unwrap_or(0).min(9) as u8;
super::para::fmt(s, &|pp| pp.outline_level = if level == 0 { Some(9) } else { Some(level - 1) })
})
.params(r#"{"level": 0 (do not show) | 1-9}"#),
CommandSpec::new("references.footnote", "Insert Footnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Footnote))
.key("Mod+Alt+F")
.params(r#"{"text"?: string, "mark"?: string (a custom mark instead of the number)}"#),
CommandSpec::new("references.endnote", "Insert Endnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Endnote))
// macOS takes ⌘⌥D to show and hide the Dock; Word for Mac uses ⌘⌥E.
.key(super::mac_or("Mod+Alt+E", "Mod+Alt+D"))
.params(r#"{"text"?: string, "mark"?: string (a custom mark instead of the number)}"#),
CommandSpec::new("references.convertNotes", "Convert Notes", "References › Footnotes", |s, v| {
let (foot, end) = match p::req_str(v, "to")? {
"endnote" | "endnotes" => (true, false),
"footnote" | "footnotes" => (false, true),
"swap" => (true, true),
t => return Err(CmdError::Params(format!("unknown `to` {t:?}; use endnote, footnote or swap"))),
};
let n = s.doc.convert_notes(foot, end);
Ok(json!({"converted": n}))
})
.params(r#"{"to": "endnote" (footnotes become endnotes) | "footnote" (endnotes become footnotes) | "swap"}"#),
CommandSpec::new("references.nextFootnote", "Next Footnote", "References › Footnotes", |s, _| {
let caret = s.sel.focus.clone();
let refs = note_refs(s, Some(NoteKind::Footnote));
if let Some(p) = refs.iter().find(|p| **p > caret).or(refs.first()) {
s.sel = Selection::caret(p.clone());
}
sel_result(s)
})
.key(super::non_mac("Alt+Shift+> / Alt+Shift+."))
.pure(),
CommandSpec::new("references.notes", "Show Notes", "References › Footnotes", |s, _| {
let mut out = Vec::new();
for (id, part) in &s.doc.parts {
if matches!(part.kind, PartKind::Footnote | PartKind::Endnote) {
out.push(json!({"id": id, "kind": part.kind, "text": s.doc.plain_text(StoryRef::Part(*id))}));
}
}
Ok(Value::Array(out))
})
.pure(),
CommandSpec::new("references.caption", "Insert Caption", "References › Captions", caption)
.params(r#"{"label"?: "Figure|Table|Equation|any label", "text"?: string, "position"?: "below|above", "excludeLabel"?: bool, "format"?: "ARABIC|alphabetic|ALPHABETIC|roman|ROMAN", "chapter"?: heading level 1-9, "separator"?: "-|.|:|—|–"}"#),
// F9: update fields; the selected ones that showed their code show their result again.
CommandSpec::new("references.updateFields", "Update Field", "References", |s, v| {
update_fields_in(s, v.get("all").and_then(Value::as_bool).unwrap_or(false))?;
super::fields::show_results(s)?;
sel_result(s)
})
.params(r#"{"all"?: bool (the body and every header, footer, note and text box, wherever the caret is; default: the story the caret is in)}"#)
.key("F9"),
]
}
/// A field instruction split into its name, positional arguments and switches (ECMA-376
/// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays
/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch
/// takes the word after it as its argument unless that word is another switch.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FieldCode {
pub name: String,
pub args: Vec<String>,
/// Switch letter (lower-cased; `*`, `@`, `#` for the general switches) and argument.
pub switches: Vec<(char, Option<String>)>,
}
impl FieldCode {
pub fn parse(instr: &str) -> FieldCode {
let mut words: Vec<(String, bool)> = Vec::new();
let mut chars = instr.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else if c == '"' {
chars.next();
let mut w = String::new();
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
c => w.push(c),
}
}
words.push((w, true));
} else {
let mut w = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
break;
}
w.push(c);
chars.next();
}
words.push((w, false));
}
}
let mut words = words.into_iter().peekable();
let mut f =
FieldCode { name: words.next().map(|(w, _)| w.trim_start_matches('=').to_ascii_uppercase()).unwrap_or_default(), ..Default::default() };
while let Some((w, quoted)) = words.next() {
let sw = if quoted { None } else { w.strip_prefix('\\').and_then(|r| r.chars().next()) };
match sw {
Some(c) => {
let arg = match words.peek() {
Some((n, q)) if *q || !n.starts_with('\\') => words.next().map(|(n, _)| n),
_ => None,
};
f.switches.push((c.to_ascii_lowercase(), arg));
}
None => f.args.push(w),
}
}
f
}
pub fn has(&self, c: char) -> bool {
self.switches.iter().any(|(k, _)| *k == c)
}
/// A table of figures (`TOC \c` or `TOC \a`, ECMA-376 §17.16.5.68), not a table of contents.
pub fn is_table_of_figures(&self) -> bool {
self.name == "TOC" && (self.has('c') || self.has('a'))
}
/// The argument of switch `c`, if the switch is there with one.
pub fn arg(&self, c: char) -> Option<&str> {
self.switches.iter().find(|(k, _)| *k == c).and_then(|(_, a)| a.as_deref())
}
}
#[cfg(test)]
mod tests;