//! 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 { /// `(WhoYearPages)`. AuthorDate { year_sep: &'static str, pages_sep: &'static str }, /// `(Who Pages)`. AuthorPage, /// `n`; 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 { 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::>())) })?; 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::>().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 = 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 { 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::>().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 { 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 { let cited = cited_tags(doc); let mut idx: Vec = 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 { 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 = { 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> = 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::>().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::().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()); } }