wordcraft/crates/engine/src/cmd/citations.rs

1193 lines
54 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: citations and bibliography (APA, MLA, Chicago, IEEE, Harvard, ISO 690, Turabian,
//! GB/T 7714, GOST, SIST02), source manager, index, table of figures, cross-references, table
//! of authorities, footnote options.
use serde_json::{Value, json};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, Source, StoryRef};
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// How a style orders its bibliography (and, for numbered styles, numbers its citations).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Order {
/// Alphabetical by the first author's surname (the title when there is no author).
Author,
/// Alphabetical by title.
Title,
/// In order of first citation in the document.
Cited,
}
/// How one author's name is written in a bibliography entry.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Name {
/// Rivera, Alex
LastFirst,
/// Alex Rivera
FirstLast,
/// Rivera, A.
LastInitials,
/// A. Rivera
InitialsLast,
/// Rivera A.
LastSpaceInitials,
/// RIVERA, Alex
UpperLastFirst,
/// RIVERA A
UpperLastBareInitials,
}
/// The author list of a bibliography entry.
#[derive(Clone, Copy, Debug)]
pub struct Names {
/// The first author's form, and every later author's.
pub first: Name,
pub rest: Name,
/// Between names; between exactly two; before the last of three or more.
pub sep: &'static str,
pub two: &'static str,
pub last: &'static str,
/// List at most this many authors, then `et_al` (0: list them all).
pub max: usize,
pub et_al: &'static str,
}
/// The in-text citation.
#[derive(Clone, Copy, Debug)]
pub enum Cite {
/// `(Who<year_sep>Year<pages_sep>Pages)`.
AuthorDate { year_sep: &'static str, pages_sep: &'static str },
/// `(Who Pages)`.
AuthorPage,
/// `<open>n<close>`; pages follow `pages_sep` inside the brackets (left out when `None`).
Number { open: &'static str, close: &'static str, pages_sep: Option<&'static str> },
}
/// A citation and bibliography style, as data. Entry templates fill `{n}`, `{names}`, `{year}`,
/// `{title}`, `{city}`, `{publisher}`, `{pub}` (city: publisher), `{journal}`, `{volume}`,
/// `{pages}`, `{url}` and `{type}` (GB/T 7714 document type code); text between `⟦` and `⟧` is
/// left out when a field in it is empty, and a full stop never doubles up.
#[derive(Debug)]
pub struct CiteStyle {
/// The name shown in the style picker and stored in the session.
pub name: &'static str,
/// Lowercase letter/digit prefixes of Word's style names and style-sheet file names that mean
/// this style (`gostname` matches "GOST - Name Sort"); the longest match wins.
pub aliases: &'static [&'static str],
pub cite: Cite,
/// Joins two authors in a citation ("&" or "and").
pub and: &'static str,
/// Cite this many authors or more as "First et al.".
pub et_al_from: usize,
pub order: Order,
pub names: Names,
/// Entry template for a book (or anything without a journal), and for a journal article.
pub book: &'static str,
pub article: &'static str,
/// Default title of the bibliography.
pub bib_title: &'static str,
}
impl CiteStyle {
/// Numbered styles cite `[1]`/`(1)` and list entries without a hanging indent.
pub fn numbered(&self) -> bool {
matches!(self.cite, Cite::Number { .. })
}
}
const fn names(first: Name, rest: Name, sep: &'static str, two: &'static str, last: &'static str) -> Names {
Names { first, rest, sep, two, last, max: 0, et_al: "" }
}
const AUTHOR_DATE: Cite = Cite::AuthorDate { year_sep: " ", pages_sep: ", p. " };
/// Every style, in picker order (alphabetical). Formats follow the published style guides'
/// rules for the source types WordCraft records, written in our own words.
pub static STYLES: [CiteStyle; 12] = [
CiteStyle {
name: "APA",
aliases: &["apa"],
cite: Cite::AuthorDate { year_sep: ", ", pages_sep: ", p. " },
and: "&",
et_al_from: 3,
order: Order::Author,
names: names(Name::LastInitials, Name::LastInitials, ", ", ", & ", ", & "),
book: "⟦{names} ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
article: "⟦{names} ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
bib_title: "References",
},
CiteStyle {
name: "Chicago",
aliases: &["chicago"],
cite: Cite::AuthorDate { year_sep: " ", pages_sep: ", " },
and: "and",
et_al_from: 3,
order: Order::Author,
names: names(Name::LastFirst, Name::FirstLast, ", ", ", and ", ", and "),
book: "⟦{names}. ⟧{year}. {title}.⟦ {pub}.⟧⟦ {url}⟧",
article: "⟦{names}. ⟧{year}. {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
bib_title: "References",
},
// GB/T 7714 sequence-coding: numbered by first citation, surnames in capitals with bare
// initials, at most three authors, a document type code after the title.
CiteStyle {
name: "GB/T 7714",
aliases: &["gb"],
cite: Cite::Number { open: "[", close: "]", pages_sep: Some(", ") },
and: "and",
et_al_from: 3,
order: Order::Cited,
names: Names { max: 3, et_al: ", et al.", ..names(Name::UpperLastBareInitials, Name::UpperLastBareInitials, ", ", ", ", ", ") },
book: "[{n}] ⟦{names}. ⟧{title}[{type}].⟦ {pub},⟧ {year}.⟦ {url}.⟧",
article: "[{n}] ⟦{names}. ⟧{title}[{type}]. {journal}, {year}⟦, {volume}⟧⟦: {pages}⟧.⟦ {url}.⟧",
bib_title: "References",
},
// GOST 7.1: a numbered list in alphabetical order (by author or by title); areas separated
// by " – ", the container after "//".
CiteStyle {
name: "GOST (name sort)",
aliases: &["gost", "gostname"],
cite: Cite::Number { open: "[", close: "]", pages_sep: Some(", p. ") },
and: "and",
et_al_from: 3,
order: Order::Author,
names: names(Name::LastSpaceInitials, Name::LastSpaceInitials, ", ", ", ", ", "),
book: GOST_BOOK,
article: GOST_ARTICLE,
bib_title: "References",
},
CiteStyle {
name: "GOST (title sort)",
aliases: &["gosttitle"],
cite: Cite::Number { open: "[", close: "]", pages_sep: Some(", p. ") },
and: "and",
et_al_from: 3,
order: Order::Title,
names: names(Name::LastSpaceInitials, Name::LastSpaceInitials, ", ", ", ", ", "),
book: GOST_BOOK,
article: GOST_ARTICLE,
bib_title: "References",
},
// Harvard as taught by Anglia Ruskin: "Surname, I., Year. Title. Place: Publisher."
CiteStyle {
name: "Harvard (Anglia)",
aliases: &["harvard"],
cite: Cite::AuthorDate { year_sep: ", ", pages_sep: ", p. " },
and: "and",
et_al_from: 4,
order: Order::Author,
names: names(Name::LastInitials, Name::LastInitials, ", ", " and ", " and "),
book: "⟦{names}, ⟧{year}. {title}.⟦ {pub}.⟧⟦ Available at: <{url}>.⟧",
article: "⟦{names}, ⟧{year}. {title}. {journal}⟦, {volume}⟧⟦, pp. {pages}⟧.⟦ Available at: <{url}>.⟧",
bib_title: "References",
},
CiteStyle {
name: "IEEE",
aliases: &["ieee"],
cite: Cite::Number { open: "[", close: "]", pages_sep: None },
and: "and",
et_al_from: 3,
order: Order::Cited,
names: names(Name::InitialsLast, Name::InitialsLast, ", ", ", ", ", "),
book: "[{n}] ⟦{names}, ⟧“{title},”⟦ {pub},⟧ {year}.⟦ {url}⟧",
article: "[{n}] ⟦{names}, ⟧“{title},” {journal}⟦, {volume}⟧⟦, {pages}⟧, {year}.⟦ {url}⟧",
bib_title: "References",
},
// ISO 690 first element and date: surnames in capitals, the year after the names.
CiteStyle {
name: "ISO 690 (author-date)",
aliases: &["iso690"],
cite: AUTHOR_DATE,
and: "and",
et_al_from: 4,
order: Order::Author,
names: names(Name::UpperLastFirst, Name::UpperLastFirst, ", ", " and ", " and "),
book: "⟦{names}, ⟧{year}. {title}.⟦ {pub}.⟧⟦ Available from: {url}⟧",
article: "⟦{names}, ⟧{year}. {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ Available from: {url}⟧",
bib_title: "References",
},
// ISO 690 numeric reference: numbered by first citation, the year with the imprint.
CiteStyle {
name: "ISO 690 (numerical)",
aliases: &["iso690num", "iso690nmerical"],
cite: Cite::Number { open: "(", close: ")", pages_sep: Some(", p. ") },
and: "and",
et_al_from: 4,
order: Order::Cited,
names: names(Name::UpperLastFirst, Name::UpperLastFirst, ", ", " and ", " and "),
book: "{n}. ⟦{names}. ⟧{title}.⟦ {pub},⟧ {year}.⟦ Available from: {url}⟧",
article: "{n}. ⟦{names}. ⟧{title}. {journal}. {year}⟦, {volume}⟧⟦, {pages}⟧.⟦ Available from: {url}⟧",
bib_title: "References",
},
CiteStyle {
name: "MLA",
aliases: &["mla"],
cite: Cite::AuthorPage,
and: "and",
et_al_from: 3,
order: Order::Author,
names: Names { max: 1, et_al: ", et al.", ..names(Name::LastFirst, Name::LastFirst, ", ", ", ", ", ") },
book: "⟦{names}. ⟧“{title}.”⟦ {publisher},⟧ {year}.⟦ {url}⟧",
article: "⟦{names}. ⟧“{title}.” {journal}⟦, {volume}⟧⟦, {pages}⟧, {year}.⟦ {url}⟧",
bib_title: "Works Cited",
},
// SIST 02: numbered by first citation, full names separated by semicolons.
CiteStyle {
name: "SIST02",
aliases: &["sist"],
cite: Cite::Number { open: "(", close: ")", pages_sep: Some(", p. ") },
and: "and",
et_al_from: 3,
order: Order::Cited,
names: names(Name::LastFirst, Name::LastFirst, "; ", "; ", "; "),
book: "{n}) ⟦{names}. ⟧{title}.⟦ {city},⟧⟦ {publisher},⟧ {year}.⟦ {url}⟧",
article: "{n}) ⟦{names}. ⟧{title}. {journal}. {year}⟦, vol. {volume}⟧⟦, p. {pages}⟧.⟦ {url}⟧",
bib_title: "References",
},
// Turabian: author-date citations, a bibliography with the year in the imprint.
CiteStyle {
name: "Turabian",
aliases: &["turabian"],
cite: Cite::AuthorDate { year_sep: " ", pages_sep: ", " },
and: "and",
et_al_from: 4,
order: Order::Author,
names: names(Name::LastFirst, Name::FirstLast, ", ", ", and ", ", and "),
book: "⟦{names}. ⟧{title}.⟦ {pub},⟧ {year}.⟦ {url}.⟧",
article: "⟦{names}. ⟧“{title}.” {journal}⟦ {volume}⟧ ({year})⟦: {pages}⟧.⟦ {url}.⟧",
bib_title: "Bibliography",
},
];
const GOST_BOOK: &str = "{n}. ⟦{names} ⟧{title}. –⟦ {city} :⟧⟦ {publisher},⟧ {year}.⟦ – URL: {url}.⟧";
const GOST_ARTICLE: &str = "{n}. ⟦{names} ⟧{title} // {journal}. – {year}.⟦ – Vol. {volume}.⟧⟦ – P. {pages}.⟧⟦ – URL: {url}.⟧";
/// The style called `name`: one of ours (any case), or Word's style name or style-sheet file
/// name (`Harvard - Anglia`, `\GostTitle.XSL`).
pub fn find_style(name: &str) -> Option<&'static CiteStyle> {
if let Some(st) = STYLES.iter().find(|x| x.name.eq_ignore_ascii_case(name.trim())) {
return Some(st);
}
let key: String = name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect();
let key = key.strip_suffix("xsl").unwrap_or(&key);
STYLES
.iter()
.flat_map(|st| st.aliases.iter().map(move |a| (st, *a)))
.filter(|(_, a)| key.starts_with(a))
.max_by_key(|(_, a)| a.len())
.map(|(st, _)| st)
}
/// The session's style; APA when the name is unknown.
pub fn style(name: &str) -> &'static CiteStyle {
find_style(name).unwrap_or(&STYLES[0])
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("references.sources", "Manage Sources", "References › Citations & Bibliography", sources)
.params(r#"{"add"?: Source, "remove"?: tag} → list"#),
CommandSpec::new("references.citation", "Insert Citation", "References › Citations & Bibliography", citation).params(r#"{"tag"?: string, "source"?: Source (added if new), "pages"?: string}"#),
CommandSpec::new("references.citationStyle", "Style", "References › Citations & Bibliography", |s, v| {
let st = p::str(v, "style").unwrap_or("APA");
let st = find_style(st).ok_or_else(|| {
CmdError::Params(format!("style must be one of {:?}", STYLES.iter().map(|x| x.name).collect::<Vec<_>>()))
})?;
s.bib_style = st.name.to_string();
update_citations(s)?;
Ok(json!({"style": s.bib_style}))
})
.params(
r#"{"style": "APA|Chicago|GB/T 7714|GOST (name sort)|GOST (title sort)|Harvard (Anglia)|IEEE|ISO 690 (author-date)|ISO 690 (numerical)|MLA|SIST02|Turabian" (Word's style names work too)}"#,
),
CommandSpec::new("references.bibliography", "Bibliography", "References › Citations & Bibliography", |s, v| {
let title = p::str(v, "title").unwrap_or(style(&s.bib_style).bib_title).to_string();
generated_list(s, "BIBLIOGRAPHY", &title)?;
sel_result(s)
})
.params(r#"{"title"?: string}"#),
CommandSpec::new("references.tableOfFigures", "Insert Table of Figures", "References › Captions", |s, v| {
let label = p::str(v, "label").unwrap_or("Figure").to_string();
generated_list(s, &format!("TOC \\c \"{label}\""), "")?;
sel_result(s)
})
.params(r#"{"label"?: "Figure|Table|Equation"}"#),
CommandSpec::new("references.updateFigures", "Update Table", "References › Captions", |s, _| {
update_generated(s)?;
sel_result(s)
}),
CommandSpec::new("references.markCitation", "Mark Citation", "References › Table of Authorities", |s, v| {
let entry = p::str(v, "entry").map(str::to_string).unwrap_or_else(|| s.selected_text().trim().to_string());
if entry.is_empty() {
return Err(CmdError::Params("select a citation or give `entry`".into()));
}
let (_, b) = s.sel.ordered();
s.doc.insert_object(&b, InlineObject::Field { instr: format!("TA \\l \"{}\"", entry.replace('"', "")), result: String::new(), locked: false, code: false }, &CharProps { hidden: Some(true), ..Default::default() })?;
Ok(json!({"entry": entry}))
})
.params(r#"{"entry"?: string}"#)
.key(super::non_mac("Alt+Shift+I")),
CommandSpec::new("references.tableOfAuthorities", "Insert Table of Authorities", "References › Table of Authorities", |s, _| {
generated_list(s, "TOA", "Table of Authorities")?;
sel_result(s)
}),
CommandSpec::new("references.updateAuthorities", "Update Table", "References › Table of Authorities", |s, _| {
update_generated(s)?;
sel_result(s)
}),
CommandSpec::new("insert.crossReference", "Cross-reference", "Insert › Links", cross_ref)
.params(r#"{"to": "heading|bookmark|figure|table", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#),
CommandSpec::new("references.noteOptions", "Footnote and Endnote", "References › Footnotes", super::references::note_options).params(
r#"{"kind"?: "footnote|endnote", "pos"?: "pageBottom|beneathText" (footnotes) | "sectEnd|docEnd" (endnotes), "numFmt"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter|…", "numStart"?: 1-32767, "numRestart"?: "continuous|eachSect|eachPage" (each page: footnotes only), "scope"?: "document|section", "footnoteFormat"?: …, "endnoteFormat"?: …} — no settings: the options in effect at the caret"#,
),
CommandSpec::new("references.researcher", "Researcher", "References › Research", |s, _| {
s.status = "Researcher needs an online service; WordCraft keeps your documents offline.".into();
Ok(json!({"available": false}))
})
.pure(),
]
}
fn authors(a: &str) -> Vec<(String, String)> {
a.split(';')
.map(str::trim)
.filter(|x| !x.is_empty())
.map(|x| match x.split_once(',') {
Some((last, first)) => (last.trim().to_string(), first.trim().to_string()),
None => match x.rsplit_once(' ') {
Some((first, last)) => (last.trim().to_string(), first.trim().to_string()),
None => (x.to_string(), String::new()),
},
})
.collect()
}
/// "Alex Mei" → "A. M." (`dots`), or "A M" without them.
fn initials(first: &str, dots: bool) -> String {
let sep = if dots { ". " } else { " " };
let mut out = first.split_whitespace().filter_map(|w| w.chars().next()).map(String::from).collect::<Vec<_>>().join(sep);
if dots && !out.is_empty() {
out.push('.');
}
out
}
fn name(form: Name, last: &str, first: &str) -> String {
let join = |a: String, sep: &str, b: String| if a.is_empty() || b.is_empty() { format!("{a}{b}") } else { format!("{a}{sep}{b}") };
match form {
Name::LastFirst => join(last.to_string(), ", ", first.to_string()),
Name::FirstLast => join(first.to_string(), " ", last.to_string()),
Name::LastInitials => join(last.to_string(), ", ", initials(first, true)),
Name::InitialsLast => join(initials(first, true), " ", last.to_string()),
Name::LastSpaceInitials => join(last.to_string(), " ", initials(first, true)),
Name::UpperLastFirst => join(last.to_uppercase(), ", ", first.to_string()),
Name::UpperLastBareInitials => join(last.to_uppercase(), " ", initials(first, false)),
}
}
fn name_list(au: &[(String, String)], form: &Names) -> String {
let shown = if form.max > 0 { au.get(..form.max).unwrap_or(au) } else { au };
let parts: Vec<String> = shown.iter().enumerate().map(|(i, (l, f))| name(if i == 0 { form.first } else { form.rest }, l, f)).collect();
let mut out = match parts.as_slice() {
[] => String::new(),
[one] => one.clone(),
[a, b] => format!("{a}{}{b}", form.two),
[init @ .., z] => format!("{}{}{z}", init.join(form.sep), form.last),
};
if shown.len() < au.len() {
out.push_str(form.et_al);
}
out
}
/// Append `s`, dropping its leading full stop when `out` already ends with one ("M." + ". ").
fn push_text(out: &mut String, s: &str) {
match s.strip_prefix('.') {
Some(rest) if out.ends_with('.') => out.push_str(rest),
_ => out.push_str(s),
}
}
/// Fill one template segment; `None` when `optional` and a field in it is empty.
fn fill_segment(seg: &str, field: &dyn Fn(&str) -> String, optional: bool) -> Option<String> {
let mut out = String::new();
let mut rest = seg;
while let Some((before, after)) = rest.split_once('{') {
push_text(&mut out, before);
let Some((key, after)) = after.split_once('}') else {
push_text(&mut out, after);
return Some(out);
};
let value = field(key);
if value.is_empty() && optional {
return None;
}
push_text(&mut out, &value);
rest = after;
}
push_text(&mut out, rest);
Some(out)
}
/// Fill an entry template (see [`CiteStyle`]).
fn render(template: &str, field: &dyn Fn(&str) -> String) -> String {
let mut out = String::new();
let mut rest = template;
while !rest.is_empty() {
let (seg, optional, after) = match rest.strip_prefix('⟦') {
Some(group) => match group.split_once('⟧') {
Some((g, after)) => (g, true, after),
None => (group, true, ""),
},
None => match rest.split_once('⟦') {
Some((plain, _)) => (plain, false, rest.get(plain.len()..).unwrap_or("")),
None => (rest, false, ""),
},
};
if let Some(text) = fill_segment(seg, field, optional) {
push_text(&mut out, &text);
}
rest = after;
}
out
}
/// In-text citation for a style; `n` is the source's number in numbered styles.
pub fn cite(src: &Source, style_name: &str, n: usize, pages: &str) -> String {
let st = style(style_name);
let au = authors(&src.author);
let lasts: Vec<&str> = au.iter().map(|a| a.0.as_str()).collect();
let who = match lasts.as_slice() {
[] => src.title.clone(),
[one] => one.to_string(),
[first, ..] if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
[a, b] => format!("{a} {} {b}", st.and),
[init @ .., z] => format!("{} {} {z}", init.join(", "), st.and),
};
match st.cite {
Cite::AuthorPage if pages.is_empty() => format!("({who})"),
Cite::AuthorPage => format!("({who} {pages})"),
Cite::AuthorDate { year_sep, .. } if pages.is_empty() => format!("({who}{year_sep}{})", src.year),
Cite::AuthorDate { year_sep, pages_sep } => format!("({who}{year_sep}{}{pages_sep}{pages})", src.year),
Cite::Number { open, close, pages_sep: Some(sep) } if !pages.is_empty() => format!("{open}{n}{sep}{pages}{close}"),
Cite::Number { open, close, .. } => format!("{open}{n}{close}"),
}
}
/// GB/T 7714 document type code: M book, J journal article, R report, EB/OL web page, Z other;
/// "/OL" marks a source read online.
fn gb_type(src: &Source) -> String {
let code = match src.kind.to_ascii_lowercase().as_str() {
"website" | "web" => return "EB/OL".into(),
_ if !src.journal.is_empty() => "J",
"book" | "" => "M",
"article" => "J",
"report" => "R",
_ => "Z",
};
if src.url.is_empty() { code.into() } else { format!("{code}/OL") }
}
/// Bibliography entry for a style; `n` is the source's number in numbered styles.
pub fn entry(src: &Source, style_name: &str, n: usize) -> String {
let st = style(style_name);
let au = authors(&src.author);
let field = |key: &str| -> String {
match key {
"n" => n.to_string(),
"names" => name_list(&au, &st.names),
"year" if src.year.is_empty() => "n.d.".into(),
"year" => src.year.clone(),
"title" => src.title.clone(),
"city" => src.city.clone(),
"publisher" => src.publisher.clone(),
"pub" => [src.city.as_str(), src.publisher.as_str()].into_iter().filter(|x| !x.is_empty()).collect::<Vec<_>>().join(": "),
"journal" => src.journal.clone(),
"volume" => src.volume.clone(),
"pages" => src.pages.clone(),
"url" => src.url.clone(),
"type" => gb_type(src),
_ => String::new(),
}
};
render(if src.journal.is_empty() { st.book } else { st.article }, &field)
}
fn sources(s: &mut Session, v: &Value) -> CmdResult {
if let Some(a) = v.get("add") {
let mut src: Source = serde_json::from_value(a.clone()).map_err(|e| CmdError::Params(e.to_string()))?;
if src.tag.is_empty() {
let base: String = authors(&src.author)
.first()
.map(|x| x.0.clone())
.unwrap_or_else(|| "Source".into())
.chars()
.filter(|c| c.is_alphanumeric())
.collect();
src.tag = format!("{base}{}", src.year);
}
// Edit Source keeps the source's place in the list and refreshes its citations.
match s.doc.sources.iter().position(|x| x.tag == src.tag) {
Some(i) => {
if let Some(x) = s.doc.sources.get_mut(i) {
*x = src;
}
update_citations(s)?;
}
None => s.doc.sources.push(src),
}
s.touch();
}
if let Some(t) = p::str(v, "remove") {
s.doc.sources.retain(|x| x.tag != t);
}
Ok(serde_json::to_value(&s.doc.sources).unwrap_or(Value::Null))
}
fn citation(s: &mut Session, v: &Value) -> CmdResult {
if v.get("source").is_some() {
sources(s, &json!({"add": v.get("source")}))?;
}
let tag = match p::str(v, "tag") {
Some(t) => t.to_string(),
None => match v.get("source") {
Some(_) => s.doc.sources.last().map(|x| x.tag.clone()).unwrap_or_default(),
None => return Err(CmdError::Params("give `tag` or `source`".into())),
},
};
let src = s.doc.sources.iter().find(|x| x.tag == tag).cloned().ok_or_else(|| CmdError::Params(format!("no source `{tag}`")))?;
let pages = p::str(v, "pages").unwrap_or("").to_string();
let st = style(&s.bib_style);
let n = number_of(&s.doc, &bib_order(&s.doc, st), &tag);
let props = s.typing_props();
let at = delete_selection(s)?;
let instr = if pages.is_empty() { format!("CITATION {tag}") } else { format!("CITATION {tag} \\p {pages}") };
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: cite(&src, st.name, n, &pages), locked: false, code: false }, &props)?;
s.sel = Selection::caret(end);
// A new citation can renumber the others (numbered styles count in order of first citation).
if st.numbered() {
refresh_citations(s)?;
}
Ok(json!({"tag": tag}))
}
/// The tag and `\p` pages of a CITATION field instruction.
fn citation_field(instr: &str) -> Option<(&str, &str)> {
let mut words = instr.split_whitespace();
if words.next()? != "CITATION" {
return None;
}
let tag = words.next()?;
let pages = instr.split("\\p").nth(1).map(str::trim).unwrap_or("");
Some((tag, pages))
}
/// Tags cited in the body, in order of first citation.
fn cited_tags(doc: &Document) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for o in &p.objects {
if let InlineObject::Field { instr, .. } = o
&& let Some((tag, _)) = citation_field(instr)
&& seen.insert(tag.to_string())
{
out.push(tag.to_string());
}
}
}
out
}
fn author_key(src: &Source) -> String {
authors(&src.author).first().map(|a| a.0.to_lowercase()).unwrap_or_else(|| src.title.to_lowercase())
}
/// Indices into `doc.sources` in bibliography order: the cited sources (every source while
/// nothing is cited), sorted or in order of first citation as the style asks.
fn bib_order(doc: &Document, st: &CiteStyle) -> Vec<usize> {
let cited = cited_tags(doc);
let mut idx: Vec<usize> = if cited.is_empty() {
(0..doc.sources.len()).collect()
} else if st.order == Order::Cited {
cited.iter().filter_map(|t| doc.sources.iter().position(|x| &x.tag == t)).collect()
} else {
let cited: std::collections::HashSet<&str> = cited.iter().map(String::as_str).collect();
doc.sources.iter().enumerate().filter(|(_, x)| cited.contains(x.tag.as_str())).map(|(i, _)| i).collect()
};
let key =
|i: &usize, by_title: bool| doc.sources.get(*i).map(|x| if by_title { x.title.to_lowercase() } else { author_key(x) }).unwrap_or_default();
match st.order {
Order::Author => idx.sort_by_cached_key(|i| key(i, false)),
Order::Title => idx.sort_by_cached_key(|i| key(i, true)),
Order::Cited => {}
}
idx
}
/// A source's number in a numbered style: its place in the bibliography order.
fn number_of(doc: &Document, order: &[usize], tag: &str) -> usize {
order.iter().position(|&i| doc.sources.get(i).is_some_and(|x| x.tag == tag)).unwrap_or(order.len()).saturating_add(1)
}
/// Refresh CITATION results and generated lists.
pub fn update_citations(s: &mut Session) -> Result<(), CmdError> {
refresh_citations(s)?;
update_generated(s)
}
/// Refresh the results of the CITATION fields in the body.
fn refresh_citations(s: &mut Session) -> Result<(), CmdError> {
let st = style(&s.bib_style);
let order = bib_order(&s.doc, st);
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
let updates: Vec<(usize, String)> = p
.objects
.iter()
.enumerate()
.filter_map(|(k, o)| match o {
InlineObject::Field { instr, .. } => {
let (tag, pages) = citation_field(instr)?;
let src = s.doc.sources.iter().find(|x| x.tag == tag)?;
Some((k, cite(src, st.name, number_of(&s.doc, &order, tag), pages)))
}
_ => None,
})
.collect();
if updates.is_empty() {
continue;
}
let para = s.doc.para_mut(StoryRef::Body, &path)?;
for (k, r) in updates {
if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) {
*result = r;
}
}
para.touch();
}
Ok(())
}
/// Generated lists (BIBLIOGRAPHY, INDEX, table of figures, TOA): a heading paragraph holding
/// the field, then entry paragraphs marked with the "GeneratedEntry" style family.
pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Result<(), CmdError> {
let at = delete_selection(s)?;
if at.story != StoryRef::Body || at.path.depth() > 0 {
return Err(CmdError::Failed("lists go in the main text".into()));
}
let mut head = if title.is_empty() { Paragraph::new() } else { Paragraph::with_text(title, CharProps::default()).styled("Heading1") };
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: instr.to_string(), result: String::new(), locked: false, code: false },
&CharProps::default(),
)?;
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() };
s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?;
update_generated(s)
}
fn list_kind(p: &Paragraph) -> Option<String> {
p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => {
let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || t.starts_with("TOC \\c")).then(|| t.to_string())
}
_ => None,
})
}
const ENTRY_STYLE: &str = "ListEntry";
/// Rebuild every generated list.
pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
if s.doc.styles.get(ENTRY_STYLE).is_none() {
s.doc.styles.upsert(wordcraft_doc::Style {
id: ENTRY_STYLE.into(),
name: "List Entry".into(),
kind: wordcraft_doc::StyleKind::Paragraph,
based_on: Some("Normal".into()),
para: wordcraft_doc::ParaProps { space_after: Some(4.0), ..Default::default() },
hidden: true,
..Default::default()
});
}
let mut i = 0;
while i < s.doc.body.len() {
let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind);
let Some(kind) = kind else {
i += 1;
continue;
};
if kind.starts_with("INDEX") {
super::index::rebuild(s, i, None)?;
i += 1;
continue;
}
// Remove old entries.
while s.doc.body.get(i + 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)) {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
}
let lines = entries_for(s, &kind);
let width = super::page::sect(s).text_width();
for (k, (text, hanging)) in lines.into_iter().enumerate() {
let mut para = Paragraph::with_text(&text, CharProps::default()).styled(ENTRY_STYLE);
if hanging {
para.props.indent_left = Some(36.0);
para.props.indent_first = Some(-36.0);
}
if text.contains('\t') {
para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader: TabLeader::Dot }]);
}
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1 + k), Block::Para(para))?;
}
i += 1;
}
s.touch();
Ok(())
}
fn page_of(s: &mut Session, pos: &Pos) -> u32 {
let l = s.layout();
l.caret(pos).and_then(|c| l.pages.get(c.page)).map(|p| p.number).unwrap_or(1)
}
/// Entry lines (text, hanging indent) for a generated list.
fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
if kind.starts_with("BIBLIOGRAPHY") {
let st = style(&s.bib_style);
return bib_order(&s.doc, st)
.into_iter()
.enumerate()
.filter_map(|(k, i)| s.doc.sources.get(i).map(|x| (entry(x, st.name, k.saturating_add(1)), !st.numbered())))
.collect();
}
// Collect marks with positions.
let mut marks: Vec<(String, Pos)> = Vec::new();
let caption_label = kind.split('"').nth(1).unwrap_or("Figure").to_string();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if kind.starts_with("TOC \\c") {
if p.props.style.as_deref() == Some("Caption") && p.plain_text().starts_with(&caption_label) {
marks.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
}
continue;
}
for off in p.object_offsets() {
if let Some(InlineObject::Field { instr, .. }) = p.object_at(off) {
let t = instr.trim_start();
let want = if kind.starts_with("INDEX") { "XE" } else { "TA" };
if t.starts_with(want) {
let e = t.split('"').nth(1).unwrap_or("").to_string();
marks.push((e, Pos { story: StoryRef::Body, path: path.clone(), off }));
}
}
}
}
let mut with_pages: Vec<(String, u32)> = marks.into_iter().map(|(e, _)| (e, 0)).collect();
// Pages (layout once).
let positions: Vec<Pos> = {
let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if kind.starts_with("TOC \\c") {
if p.props.style.as_deref() == Some("Caption") && p.plain_text().starts_with(&caption_label) {
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
}
continue;
}
for off in p.object_offsets() {
if let Some(InlineObject::Field { instr, .. }) = p.object_at(off) {
let want = if kind.starts_with("INDEX") { "XE" } else { "TA" };
if instr.trim_start().starts_with(want) {
v.push(Pos { story: StoryRef::Body, path: path.clone(), off });
}
}
}
}
v
};
for (i, pos) in positions.iter().enumerate() {
let pg = page_of(s, pos);
if let Some(x) = with_pages.get_mut(i) {
x.1 = pg;
}
}
if kind.starts_with("TOC \\c") {
return with_pages.into_iter().map(|(t, p)| (format!("{t}\t{p}"), false)).collect();
}
// Index / TOA: group by entry, sort, list pages.
let mut grouped: std::collections::BTreeMap<String, Vec<u32>> = Default::default();
for (e, p) in with_pages {
let pages = grouped.entry(e).or_default();
if !pages.contains(&p) {
pages.push(p);
}
}
let mut out = Vec::new();
let mut letter = None;
for (e, pages) in grouped {
let first = e.chars().next().map(|c| c.to_ascii_uppercase());
if kind.starts_with("INDEX") && first != letter {
letter = first;
if let Some(l) = first {
out.push((l.to_string(), false));
}
}
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
out.push(if kind.starts_with("INDEX") { (format!("{e}, {list}"), true) } else { (format!("{e}\t{list}"), false) });
}
out
}
fn cross_ref(s: &mut Session, v: &Value) -> CmdResult {
let to = p::str(v, "to").unwrap_or("heading");
let targets = ref_targets(s, to);
let Some(target) = p::str(v, "target") else {
return Ok(Value::Array(targets.iter().map(|(t, _)| json!(t)).collect()));
};
let find = |ts: Vec<(String, Pos)>| ts.into_iter().find(|(t, _)| t == target || t.starts_with(target));
let (text, pos) = find(targets).ok_or_else(|| CmdError::Params(format!("no {to} `{target}`")))?;
let show = p::str(v, "show").unwrap_or("text");
let page = page_of(s, &pos);
let result = match show {
"page" => page.to_string(),
"number" => text.split(':').next().unwrap_or(&text).trim().to_string(),
"aboveBelow" => {
if pos < s.sel.focus {
"above".into()
} else {
"below".into()
}
}
_ if to == "bookmark" => super::references::bookmark_text(&s.doc, &text).unwrap_or_default(),
_ => text.clone(),
};
let props = s.typing_props();
let at = delete_selection(s)?;
// The field points at a bookmark: the named one, or a hidden `_Ref` bookmark on the target
// paragraph (as Word does), so Update Field and Word can resolve it.
let (name, at) = if to == "bookmark" {
(text, at)
} else {
// Deleting the selection can move paragraphs: look the target up again.
let (_, pos) = find(ref_targets(s, to)).ok_or_else(|| CmdError::Params(format!("no {to} `{target}`")))?;
ref_bookmark(s, &pos, show == "number", at)?
};
let instr = format!("{} {name} \\h", if show == "page" { "PAGEREF" } else { "REF" });
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: result.clone(), locked: false, code: false }, &props)?;
s.sel = Selection::caret(end);
Ok(json!({"inserted": result}))
}
/// Cross-reference targets of a kind: (shown text or bookmark name, position).
fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> {
let mut targets: Vec<(String, Pos)> = Vec::new();
match to {
"bookmark" => targets = s.doc.bookmarks(),
"figure" | "table" | "equation" => {
let label = match to {
"table" => "Table",
"equation" => "Equation",
_ => "Figure",
};
for path in s.doc.para_paths(StoryRef::Body) {
if let Some(p) = s.doc.para(StoryRef::Body, &path)
&& p.props.style.as_deref() == Some("Caption")
&& p.plain_text().starts_with(label)
{
targets.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
}
}
}
_ => {
for path in s.doc.para_paths(StoryRef::Body) {
if let Some(p) = s.doc.para(StoryRef::Body, &path)
&& s.doc.styles.resolve_para(&p.props).outline_level.is_some()
&& !p.plain_text().trim().is_empty()
{
targets.push((p.plain_text().trim().to_string(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
}
}
}
}
targets
}
/// The hidden `_Ref` bookmark around the target paragraph at `pos` (its label and number only
/// when `number_only`), reusing one already there. Returns its name and `at` moved past the
/// bookmark marks inserted before it.
fn ref_bookmark(s: &mut Session, pos: &Pos, number_only: bool, mut at: Pos) -> Result<(String, Pos), CmdError> {
const MARK: usize = wordcraft_doc::para::OBJ.len_utf8();
let para = s.doc.para(pos.story, &pos.path).ok_or_else(|| CmdError::Params("cross-reference target is gone".into()))?;
let end = if number_only { para.text.find(':').unwrap_or(para.len()) } else { para.len() };
if let Some(InlineObject::BookmarkStart { name }) = para.object_at(0)
&& name.starts_with("_Ref")
{
// Reuse it when it covers the same extent: up to the colon, or the whole paragraph.
let same = para.object_offsets().into_iter().any(|o| {
matches!(para.object_at(o), Some(InlineObject::BookmarkEnd { name: n }) if n == name)
&& para.text.get(o.saturating_add(MARK)..).is_some_and(|rest| if number_only { rest.starts_with(':') } else { rest.is_empty() })
});
if same {
return Ok((name.clone(), at));
}
}
let next =
s.doc.bookmarks().iter().filter_map(|(n, _)| n.strip_prefix("_Ref").and_then(|d| d.parse::<u64>().ok())).max().unwrap_or(0).saturating_add(1);
let name = format!("_Ref{next:09}");
let props = CharProps::default();
s.doc.insert_object(&Pos { off: end, ..pos.clone() }, InlineObject::BookmarkEnd { name: name.clone() }, &props)?;
s.doc.insert_object(&Pos { off: 0, ..pos.clone() }, InlineObject::BookmarkStart { name: name.clone() }, &props)?;
if at.story == pos.story && at.path == pos.path {
if at.off >= end {
at.off = at.off.saturating_add(2 * MARK);
} else if at.off > 0 {
at.off = at.off.saturating_add(MARK);
}
}
Ok((name, at))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
#[test]
fn citations_and_bibliography() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "Studios matter "})).unwrap();
s.run("references.citation", &json!({"source": {"author": "Rivera, Alex; Chen, Mei", "title": "Shared Spaces", "year": "2021", "publisher": "Harbor Books", "city": "Portland"}})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).contains("(Rivera & Chen, 2021)"), "{}", s.doc.plain_text(StoryRef::Body));
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.bibliography", &json!({})).unwrap();
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.contains("Rivera, A., & Chen, M. (2021). Shared Spaces. Harbor Books."), "{t}");
s.run("references.citationStyle", &json!({"style": "MLA"})).unwrap();
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.contains("(Rivera and Chen)"), "{t}");
s.run("references.citationStyle", &json!({"style": "IEEE"})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).contains("[1]"));
}
#[test]
fn index_and_figures() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Kilns are hot.\nPresses are heavy.\nMore kilns."));
s.run("select.text", &json!({"text": "Kilns"})).unwrap();
s.run("references.markEntry", &json!({})).unwrap();
s.run("select.text", &json!({"text": "Presses"})).unwrap();
s.run("references.markEntry", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.index", &json!({})).unwrap();
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.contains("Kilns, 1") && t.contains("Presses, 1"), "{t}");
s.run("caret.docStart", &json!({})).unwrap();
s.run("references.caption", &json!({"label": "Figure", "text": "The kiln"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.tableOfFigures", &json!({})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).contains("Figure 1: The kiln\t1"));
let list = s.run("insert.crossReference", &json!({"to": "figure"})).unwrap();
assert_eq!(list.as_array().unwrap().len(), 1);
s.run("insert.crossReference", &json!({"to": "figure", "target": "Figure 1", "show": "number"})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).contains("Figure 1"));
// The number-only reference marks just "Figure 1" of the caption.
let (name, _) = s.doc.bookmarks().into_iter().find(|(n, _)| n.starts_with("_Ref")).unwrap();
assert_eq!(crate::cmd::references::bookmark_text(&s.doc, &name).as_deref(), Some("Figure 1"));
assert!(fields(&s).contains(&(format!("REF {name} \\h"), "Figure 1".to_string())), "{:?}", fields(&s));
}
/// #661: References › Table of Authorities › Update Table picks up citations marked after the table.
#[test]
fn table_of_authorities_updates() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Smith v. Jones held it.\nDoe v. Roe agreed."));
s.run("select.text", &json!({"text": "Smith v. Jones"})).unwrap();
s.run("references.markCitation", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.tableOfAuthorities", &json!({})).unwrap();
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.contains("Smith v. Jones") && !t.contains("Doe v. Roe, "), "{t}");
s.run("select.text", &json!({"text": "Doe v. Roe"})).unwrap();
s.run("references.markCitation", &json!({})).unwrap();
s.run("references.updateAuthorities", &json!({})).unwrap();
let after = s.doc.plain_text(StoryRef::Body);
assert!(after.matches("Doe v. Roe").count() > t.matches("Doe v. Roe").count(), "{after}");
}
/// Field instructions and results in the body, in order.
fn fields(s: &Session) -> Vec<(String, String)> {
let mut out = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
for o in &s.doc.para(StoryRef::Body, &path).unwrap().objects {
if let InlineObject::Field { instr, result, .. } = o {
out.push((instr.clone(), result.clone()));
}
}
}
out
}
#[test]
fn cross_references_point_at_real_bookmarks() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Kilns\nSee here."));
s.run("caret.docStart", &json!({})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.crossReference", &json!({"to": "heading", "target": "Kilns"})).unwrap();
// The heading gets a hidden bookmark, and the REF field names it (not a literal `_Ref`).
let marks = s.doc.bookmarks();
assert_eq!(marks.len(), 1, "{marks:?}");
let name = marks[0].0.clone();
assert!(name.starts_with("_Ref") && name.len() > 4, "{name}");
assert_eq!(marks[0].1.path, s.doc.para_paths(StoryRef::Body)[0]);
assert_eq!(fields(&s), vec![(format!("REF {name} \\h"), "Kilns".to_string())]);
// A second reference to the same heading reuses the bookmark.
s.run("insert.crossReference", &json!({"to": "heading", "target": "Kilns", "show": "page"})).unwrap();
assert_eq!(s.doc.bookmarks().len(), 1);
assert_eq!(fields(&s)[1], (format!("PAGEREF {name} \\h"), "1".to_string()));
// Update Field follows the heading's new text.
s.run("select.text", &json!({"text": "ilns"})).unwrap();
s.run("text.insert", &json!({"text": "ILNS"})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
assert_eq!(fields(&s)[0].1, "KILNS");
assert!(s.doc.plain_text(StoryRef::Body).ends_with("See here.KILNS1"), "{}", s.doc.plain_text(StoryRef::Body));
}
#[test]
fn cross_reference_to_a_bookmark_shows_its_text() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Kilns are hot.\nSee "));
s.run("select.text", &json!({"text": "Kilns"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Spot"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.crossReference", &json!({"to": "bookmark", "target": "Spot"})).unwrap();
assert_eq!(s.doc.bookmarks().len(), 1, "no extra bookmark for a named one");
assert_eq!(fields(&s), vec![("REF Spot \\h".to_string(), "Kilns".to_string())]);
}
#[test]
fn cross_reference_inside_its_own_target_paragraph() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Kilns"));
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.crossReference", &json!({"to": "heading", "target": "Kilns"})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "KilnsKilns");
// The field sits after the bookmark, so it doesn't mark itself.
let name = s.doc.bookmarks()[0].0.clone();
assert_eq!(crate::cmd::references::bookmark_text(&s.doc, &name).as_deref(), Some("Kilns"));
}
fn two_sources(s: &mut Session, a_title: &str) {
let a = json!({"tag": "A", "author": "Rivera, Alex; Chen, Mei", "title": a_title, "year": "2021", "publisher": "Harbor Books", "city": "Portland"});
let b = json!({"tag": "B", "author": "Okafor, Ngozi", "title": "Kilns and Clay", "year": "2019", "journal": "Studio Quarterly", "volume": "4", "pages": "10-20"});
s.run("references.sources", &json!({"add": a})).unwrap();
s.run("references.sources", &json!({"add": b})).unwrap();
}
#[test]
fn author_date_styles_cite_and_list() {
let mut s = Session::new(wordcraft_doc::Document::new());
two_sources(&mut s, "Shared Spaces");
s.run("references.citation", &json!({"tag": "A", "pages": "12"})).unwrap();
s.run("references.citation", &json!({"tag": "B"})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.bibliography", &json!({})).unwrap();
let cases = [
(
"Harvard (Anglia)",
"(Rivera and Chen, 2021, p. 12)(Okafor, 2019)",
"Okafor, N., 2019. Kilns and Clay. Studio Quarterly, 4, pp. 10-20.\nRivera, A. and Chen, M., 2021. Shared Spaces. Portland: Harbor Books.",
),
(
"ISO 690 - First Element and Date",
"(Rivera and Chen 2021, p. 12)(Okafor 2019)",
"OKAFOR, Ngozi, 2019. Kilns and Clay. Studio Quarterly, 4, 10-20.\nRIVERA, Alex and CHEN, Mei, 2021. Shared Spaces. Portland: Harbor Books.",
),
(
"turabian",
"(Rivera and Chen 2021, 12)(Okafor 2019)",
"Okafor, Ngozi. “Kilns and Clay.” Studio Quarterly 4 (2019): 10-20.\nRivera, Alex, and Mei Chen. Shared Spaces. Portland: Harbor Books, 2021.",
),
];
for (style, cites, list) in cases {
s.run("references.citationStyle", &json!({"style": style})).unwrap();
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.starts_with(cites) && t.contains(list), "{style}: {t}");
}
assert!(s.run("references.citationStyle", &json!({"style": "Vancouver"})).unwrap_err().to_string().contains("Turabian"));
assert_eq!(s.bib_style, "Turabian", "an unknown style keeps the current one");
}
#[test]
fn numbered_styles_count_in_order_of_first_citation() {
let mut s = Session::new(wordcraft_doc::Document::new());
two_sources(&mut s, "Shared Spaces");
s.run("references.citationStyle", &json!({"style": "ISO 690 (numerical)"})).unwrap();
s.run("text.insert", &json!({"text": "Later "})).unwrap();
s.run("references.citation", &json!({"tag": "A", "pages": "12"})).unwrap();
assert_eq!(fields(&s)[0].1, "(1, p. 12)");
// Citing B earlier in the text makes it number 1 and renumbers A.
s.run("caret.docStart", &json!({})).unwrap();
s.run("references.citation", &json!({"tag": "B"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.bibliography", &json!({})).unwrap();
let cases = [
(
"ISO 690 (numerical)",
"(1)Later (2, p. 12)",
"1. OKAFOR, Ngozi. Kilns and Clay. Studio Quarterly. 2019, 4, 10-20.\n2. RIVERA, Alex and CHEN, Mei. Shared Spaces. Portland: Harbor Books, 2021.",
),
(
"GB/T 7714",
"[1]Later [2, 12]",
"[1] OKAFOR N. Kilns and Clay[J]. Studio Quarterly, 2019, 4: 10-20.\n[2] RIVERA A, CHEN M. Shared Spaces[M]. Portland: Harbor Books, 2021.",
),
(
"SIST02",
"(1)Later (2, p. 12)",
"1) Okafor, Ngozi. Kilns and Clay. Studio Quarterly. 2019, vol. 4, p. 10-20.\n2) Rivera, Alex; Chen, Mei. Shared Spaces. Portland, Harbor Books, 2021.",
),
(
"IEEE",
"[1]Later [2]",
"[1] N. Okafor, “Kilns and Clay,” Studio Quarterly, 4, 10-20, 2019.\n[2] A. Rivera, M. Chen, “Shared Spaces,” Portland: Harbor Books, 2021.",
),
];
for (style, cites, list) in cases {
s.run("references.citationStyle", &json!({"style": style})).unwrap();
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.starts_with(cites) && t.contains(list), "{style}: {t}");
}
}
#[test]
fn gost_numbers_follow_its_name_or_title_sort() {
let mut s = Session::new(wordcraft_doc::Document::new());
two_sources(&mut s, "Atlas of Studios");
s.run("references.citation", &json!({"tag": "A"})).unwrap();
s.run("references.citation", &json!({"tag": "B", "pages": "15"})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.bibliography", &json!({})).unwrap();
let okafor = "Okafor N. Kilns and Clay // Studio Quarterly. – 2019. – Vol. 4. – P. 10-20.";
let rivera = "Rivera A., Chen M. Atlas of Studios. – Portland : Harbor Books, 2021.";
// Word's style and style-sheet names pick the same styles.
s.run("references.citationStyle", &json!({"style": "\\GostName.XSL"})).unwrap();
assert_eq!(s.bib_style, "GOST (name sort)");
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.starts_with("[2][1, p. 15]") && t.contains(&format!("1. {okafor}\n2. {rivera}")), "{t}");
s.run("references.citationStyle", &json!({"style": "GOST - Title Sort"})).unwrap();
let t = s.doc.plain_text(StoryRef::Body);
assert!(t.starts_with("[1][2, p. 15]") && t.contains(&format!("1. {rivera}\n2. {okafor}")), "{t}");
assert_eq!(find_style("ISO 690 - Numerical Reference").map(|x| x.name), Some("ISO 690 (numerical)"));
assert_eq!(find_style("Harvard - Anglia 2008").map(|x| x.name), Some("Harvard (Anglia)"));
assert!(find_style("").is_none() && find_style("Vancouver").is_none());
}
}