mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 11:50:20 -06:00
179 lines
8.4 KiB
Rust
179 lines
8.4 KiB
Rust
//! 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;
|