mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 12:27:11 -06:00
* References: Word sources file import in Manage Sources (#2640) Manage Sources gains Import… and references.sources an "import": path parameter: it reads a b:Sources document (Word's own Sources.xml) into the document's sources with the same reader as a document's own bibliography part. A tag already here is left alone, an untagged entry is matched by title and author or gets a tag like a new source, the whole import is one undo step, and a file that isn't b:Sources is refused. Desktop only; the web build hides the button. The docx reader now shares the b:Sources parser (crates/docx/src/ bibliography.rs) with the import, with the same dedupe and 10,000-source cap; the hostile-input cap test still passes. Tests: docx unit tests (names, kinds, accessed/DOI fallbacks, repeated tags, empty entries, wrong root), engine import test (add, undo, second import, untagged matching, refused file), ui tests (the dialog button asks for a "sources" file; finish_pick imports), wordcraft-cli end to end (import, cite, bibliography, save, reopen; the package's customXml carries the sources). The button's label is in every interface catalogue. ROADMAP progress log and docs/roadmap.md, gaps.md, file-format-parity.md and target-app-parity.md get their rows. * References: review fixes for the sources import (#2640) - read_sources validates the root namespace instead of the local name alone: an un-namespaced or unrelated <Sources> is refused, and both bibliography namespaces read — OOXML's and Word's own 2004/10 (the VBA Source.XML format), the latter mapped onto the canonical b prefix with parse_ns. Tests: the 2004/10 namespace is accepted; a no-namespace or unrelated root is refused. - The import loop builds the tag and (title, author-or-editor) sets once and keeps a per-stem counter, with the stem rule shared with auto_tag (auto_tag_base). Importing thousands of untagged sources no longer costs O(n^3). Test: untagged sources sharing a stem get Riv21, Riv211, Src, and a second import adds none. - docs/target-app-parity.md and docs/file-format-parity.md no longer claim bibliography sources aren't saved in DOCX: #169 and #532 are closed and the round trip exists (#790). * References: doc comments for the sources import helpers (#2640) Hoist the untagged-source matching helper (by_author) to module level and document sources, auto_tag_base and sources_ui, so the functions this PR touches meet CodeRabbit's docstring-coverage pre-merge check. * References: re-apply the sources import's progress notes (#2640) The rebase onto current main took upstream's copies of ROADMAP.md, docs/file-format-parity.md and docs/gaps.md, because the rows this PR adds conflicted with upstream's own appends. Re-apply them on the current text: the progress log and revision rows, the bibliography row's source-format note, and #15's evidence, with each status line's change note. The other two documents (docs/roadmap.md, docs/target-app-parity.md) merged cleanly and keep their rows. --------- Co-authored-by: Brandon Thomas <echelon@gmail.com>
281 lines
12 KiB
Rust
281 lines
12 KiB
Rust
//! Word bibliography sources: a `b:Sources` document — the custom XML part of a document, or
|
|
//! Word's own `Sources.xml` master list, imported through Manage Sources (#2640).
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use wordcraft_doc::Source;
|
|
|
|
use crate::DocxError;
|
|
use crate::xml::El;
|
|
|
|
/// Bibliography sources kept from one file (Word's own source manager holds far fewer).
|
|
pub const MAX_SOURCES: usize = 10_000;
|
|
/// Characters kept of a bibliography's citation style name.
|
|
pub const MAX_STYLE_NAME: usize = 64;
|
|
|
|
/// A `b:Sources` document read into the document model: its sources in file order, and the
|
|
/// citation style it names (`b:Sources/@StyleName`), when it has one.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Sources {
|
|
pub sources: Vec<Source>,
|
|
pub style: Option<String>,
|
|
}
|
|
|
|
/// The bibliography namespace Word's own `Sources.xml` files may carry instead of OOXML's (the
|
|
/// VBA `Source.XML` format, `schemas.microsoft.com/office/word/2004/10/bibliography`).
|
|
const WORD_2004_BIBLIOGRAPHY: &str = "http://schemas.microsoft.com/office/word/2004/10/bibliography";
|
|
|
|
/// Read a Word bibliography sources file — Word's `Sources.xml`, or any `b:Sources` document in
|
|
/// one of the bibliography namespaces (ECMA-376's, or Word's own 2004/10) — into [`Sources`].
|
|
pub fn read_sources(bytes: &[u8]) -> Result<Sources, DocxError> {
|
|
// `parse_ns` maps Word's 2004/10 namespace onto the canonical `b` prefix as well, so both
|
|
// formats read; the root check then refuses an un-namespaced or unrelated `<Sources>`.
|
|
let root = crate::xml::parse_ns(bytes, &[("b", WORD_2004_BIBLIOGRAPHY)])?;
|
|
if root.name != "b:Sources" {
|
|
return Err(DocxError::NotWord(format!("root element is {}", root.name)));
|
|
}
|
|
Ok(parse(&root, &mut Seen::default(), MAX_SOURCES))
|
|
}
|
|
|
|
/// Bibliography sources already read: tags, and (title, author) of untagged ones.
|
|
#[derive(Default)]
|
|
pub(crate) struct Seen {
|
|
tags: HashSet<String>,
|
|
untagged: HashSet<(String, String)>,
|
|
}
|
|
|
|
/// The fresh sources and style of a parsed `b:Sources` root element: `seen` carries the tags and
|
|
/// untagged (title, author) keys already taken, so repeated parts collapse into one source, and
|
|
/// at most `room` fresh sources are built (later ones are left unread, bounding hostile files).
|
|
pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
|
let style = root
|
|
.attrs
|
|
.iter()
|
|
.find(|(k, _)| k.rsplit(':').next() == Some("StyleName"))
|
|
.map(|(_, v)| v.trim())
|
|
.filter(|name| !name.is_empty())
|
|
.map(|name| name.chars().take(MAX_STYLE_NAME).collect());
|
|
let child_text = |el: &El, local: &str| el.els().find(|e| e.local() == local).map(|e| e.text()).unwrap_or_default();
|
|
let parse_persons = |parent: &El| -> String {
|
|
let mut names = Vec::new();
|
|
let person_els: Vec<&El> = {
|
|
let in_lists: Vec<&El> =
|
|
parent.els().filter(|e| e.local() == "NameList").flat_map(|n| n.els().filter(|e| e.local() == "Person")).collect();
|
|
if in_lists.is_empty() { parent.els().filter(|e| e.local() == "Person").collect() } else { in_lists }
|
|
};
|
|
for name_el in person_els {
|
|
let last = child_text(name_el, "Last");
|
|
let first = child_text(name_el, "First");
|
|
let middle = child_text(name_el, "Middle");
|
|
let name = if !first.is_empty() && !middle.is_empty() {
|
|
format!("{last}, {first} {middle}")
|
|
} else if !first.is_empty() {
|
|
format!("{last}, {first}")
|
|
} else {
|
|
last
|
|
};
|
|
if !name.is_empty() {
|
|
names.push(name);
|
|
}
|
|
}
|
|
names.join("; ")
|
|
};
|
|
|
|
let mut out = Sources { sources: Vec::new(), style };
|
|
for src_el in root.els().filter(|e| e.local() == "Source") {
|
|
if out.sources.len() >= room {
|
|
log::warn!("docx: more than {MAX_SOURCES} bibliography sources; the rest are dropped");
|
|
break;
|
|
}
|
|
let tag = child_text(src_el, "Tag");
|
|
let raw_kind = child_text(src_el, "SourceType");
|
|
let kind = crate::write::from_source_type(&raw_kind);
|
|
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
|
Some(a) => {
|
|
let auth = a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
|
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
|
|
(auth, ed)
|
|
}
|
|
None => (String::new(), String::new()),
|
|
};
|
|
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
|
|
let title = child_text(src_el, "Title");
|
|
let year = child_text(src_el, "Year");
|
|
let month = child_text(src_el, "Month");
|
|
let day = child_text(src_el, "Day");
|
|
let city = child_text(src_el, "City");
|
|
let publisher = child_text(src_el, "Publisher");
|
|
let journal = child_text(src_el, "JournalName");
|
|
let edition = child_text(src_el, "Edition");
|
|
let volume = child_text(src_el, "Volume");
|
|
let issue = child_text(src_el, "Issue");
|
|
let pages = child_text(src_el, "Pages");
|
|
let url = child_text(src_el, "URL");
|
|
let accessed = match child_text(src_el, "Accessed") {
|
|
a if !a.is_empty() => a,
|
|
_ => {
|
|
let y = child_text(src_el, "YearAccessed");
|
|
let m = child_text(src_el, "MonthAccessed");
|
|
let d = child_text(src_el, "DayAccessed");
|
|
if !y.is_empty() && !m.is_empty() && !d.is_empty() {
|
|
format!("{y}-{m}-{d}")
|
|
} else if !y.is_empty() && !m.is_empty() {
|
|
format!("{y}-{m}")
|
|
} else {
|
|
y
|
|
}
|
|
}
|
|
};
|
|
let doi = match child_text(src_el, "DOI") {
|
|
d if !d.is_empty() => d,
|
|
_ => child_text(src_el, "StandardNumber"),
|
|
};
|
|
|
|
// Keep sources with some content, once each (by tag, or title and author untagged).
|
|
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
|
|
continue;
|
|
}
|
|
let fresh = if tag.is_empty() {
|
|
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
|
|
} else {
|
|
seen.tags.insert(tag.clone())
|
|
};
|
|
if !fresh {
|
|
continue;
|
|
}
|
|
out.sources.push(Source {
|
|
tag,
|
|
kind,
|
|
author,
|
|
title,
|
|
year,
|
|
city,
|
|
publisher,
|
|
journal,
|
|
volume,
|
|
pages,
|
|
url,
|
|
editor,
|
|
edition,
|
|
issue,
|
|
month,
|
|
day,
|
|
accessed,
|
|
doi,
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const WORD_SOURCES: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography"
|
|
xmlns="http://schemas.openxmlformats.org/officeDocument/2006/bibliography"
|
|
SelectedStyle="\MLA.XSL" StyleName="MLA">
|
|
<b:Source>
|
|
<b:Tag>Riv21</b:Tag>
|
|
<b:SourceType>Book</b:SourceType>
|
|
<b:Author>
|
|
<b:Author>
|
|
<b:NameList>
|
|
<b:Person><b:Last>Rivera</b:Last><b:First>Alex</b:First></b:Person>
|
|
<b:Person><b:Last>Chen</b:Last><b:First>Mei</b:First><b:Middle>L.</b:Middle></b:Person>
|
|
</b:NameList>
|
|
</b:Author>
|
|
</b:Author>
|
|
<b:Title>Shared Spaces</b:Title>
|
|
<b:Year>2021</b:Year>
|
|
<b:City>Portland</b:City>
|
|
<b:Publisher>Harbor Books</b:Publisher>
|
|
</b:Source>
|
|
<b:Source>
|
|
<b:Tag>Web19</b:Tag>
|
|
<b:SourceType>InternetSite</b:SourceType>
|
|
<b:Author>
|
|
<b:Author>
|
|
<b:NameList>
|
|
<b:Person><b:Last>Okafor</b:Last><b:First>Ada</b:First></b:Person>
|
|
</b:NameList>
|
|
</b:Author>
|
|
</b:Author>
|
|
<b:Title>Dockyards of the north</b:Title>
|
|
<b:Year>2019</b:Year>
|
|
<b:URL>https://example.org/docks</b:URL>
|
|
<b:YearAccessed>2024</b:YearAccessed>
|
|
<b:MonthAccessed>May</b:MonthAccessed>
|
|
<b:DayAccessed>17</b:DayAccessed>
|
|
<b:StandardNumber>10.1234/x</b:StandardNumber>
|
|
</b:Source>
|
|
<b:Source>
|
|
<b:Tag>NoDate</b:Tag>
|
|
<b:SourceType>JournalArticle</b:SourceType>
|
|
<b:Title>Untitled notes</b:Title>
|
|
<b:JournalName>Field Notes</b:JournalName>
|
|
<b:Volume>4</b:Volume>
|
|
</b:Source>
|
|
<b:Source>
|
|
<b:Tag>Riv21</b:Tag>
|
|
<b:SourceType>Book</b:SourceType>
|
|
<b:Title>A duplicate of Riv21</b:Title>
|
|
<b:Year>2021</b:Year>
|
|
</b:Source>
|
|
<b:Source>
|
|
<b:SourceType>Book</b:SourceType>
|
|
</b:Source>
|
|
</b:Sources>"#;
|
|
|
|
/// A Word `Sources.xml` (`b:Sources`): its style, the `b:Source` elements with their names,
|
|
/// kinds and fallback fields; a repeated tag and an entry with no content at all are dropped.
|
|
#[test]
|
|
fn a_word_sources_file_reads_into_sources() {
|
|
let f = read_sources(WORD_SOURCES.as_bytes()).unwrap();
|
|
assert_eq!(f.style.as_deref(), Some("MLA"), "the citation style comes from StyleName");
|
|
assert_eq!(
|
|
f.sources.len(),
|
|
3,
|
|
"the repeated tag and the empty b:Source are dropped: {:?}",
|
|
f.sources.iter().map(|s| &s.tag).collect::<Vec<_>>()
|
|
);
|
|
let book = &f.sources[0];
|
|
assert_eq!((book.tag.as_str(), book.kind.as_str()), ("Riv21", "book"));
|
|
assert_eq!(book.author, "Rivera, Alex; Chen, Mei L.");
|
|
assert_eq!(book.title, "Shared Spaces", "the first source of a repeated tag wins");
|
|
assert_eq!(
|
|
(book.title.as_str(), book.year.as_str(), book.city.as_str(), book.publisher.as_str()),
|
|
("Shared Spaces", "2021", "Portland", "Harbor Books")
|
|
);
|
|
let web = &f.sources[1];
|
|
assert_eq!(web.kind, "website");
|
|
assert_eq!(web.author, "Okafor, Ada");
|
|
assert_eq!(web.accessed, "2024-May-17", "assemble the accessed date from its parts");
|
|
assert_eq!(web.doi, "10.1234/x", "StandardNumber stands in for a missing DOI");
|
|
let notes = &f.sources[2];
|
|
assert_eq!((notes.kind.as_str(), notes.journal.as_str(), notes.volume.as_str()), ("article", "Field Notes", "4"));
|
|
}
|
|
|
|
/// Anything whose root isn't `b:Sources` is refused, not read as an empty bibliography.
|
|
#[test]
|
|
fn a_file_that_is_not_b_sources_is_an_error() {
|
|
assert!(read_sources(b"<html><body>hi</body></html>").is_err());
|
|
assert!(matches!(read_sources(b"not xml at all"), Err(DocxError::Xml(_))));
|
|
assert!(read_sources(b"<Sources><Source><Tag>A</Tag></Source></Sources>").is_err(), "no namespace");
|
|
assert!(read_sources(br#"<o:Sources xmlns:o="urn:example:other"><o:Source/></o:Sources>"#).is_err(), "an unrelated namespace");
|
|
}
|
|
|
|
/// Word's own 2004/10 bibliography namespace (`Sources.xml` written by scripts and older
|
|
/// builds, the VBA `Source.XML` format) reads as well as OOXML's.
|
|
#[test]
|
|
fn word_2004_bibliography_namespace_is_accepted() {
|
|
let xml = br#"<b:Sources xmlns:b="http://schemas.microsoft.com/office/word/2004/10/bibliography" StyleName="MLA">
|
|
<b:Source><b:Tag>Riv21</b:Tag><b:SourceType>Book</b:SourceType><b:Title>Shared Spaces</b:Title><b:Year>2021</b:Year></b:Source>
|
|
</b:Sources>"#;
|
|
let f = read_sources(xml).unwrap();
|
|
assert_eq!(f.style.as_deref(), Some("MLA"));
|
|
assert_eq!(f.sources.len(), 1);
|
|
assert_eq!(f.sources[0].tag, "Riv21");
|
|
}
|
|
}
|