mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
citefind: Chicago, which is what a history or a theology book sets its notes in
Measured against the religious books in the corpus, where the style is
Chicago throughout. Eight concrete forms it could not read:
* `Cheyne, T. K., Founders of Old Testament Criticism, London, 1893.` —
commas all the way, an imprint with no colon in it, the place last but
one and the year last.
* `Erich Caspar, Geschichte des Papsttums, Vol. 2 (Tübingen, 1933)` — the
volume was being read as a page reference and the title thrown away with
it. A volume is where in the work, not what the work is called.
* `St Valerian, Homilies (Washington D.C.: Catholic University of America
Press).` — a bracketed imprint with no year in it.
* `BØRRESEN, K. E. (1981), Subordination and Equivalence: The Nature and
Role of Woman, Washington: University Press of America.` — a title with
a colon looks exactly like an imprint when the pattern may match at the
start of one.
* `Krochmal, N., Moreh Nebukhe ha-Zeman, p. 208.` — the initials sit in a
comma-part of their own and belong to the name.
* `PG 4: 264, 337; PG 91: 1312-13` — Migne, and CSEL, NPNF, SC and the
rest: shelves, not works, and the work is not named. Refused like
chapter and verse.
* `Lowrie and Fletcher, p. 133; Anderson, p. 400.` — a name and a page
with nothing between them points at a work it does not name.
* `(Tübingen, 1933)` is a place and a year, and a place is not a publisher
wherever it is found.
This commit is contained in:
+186
-15
@@ -84,6 +84,13 @@ static IMPRINT: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
.expect("a valid pattern")
|
.expect("a valid pattern")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The same, but only where it follows something. A title with a colon in
|
||||||
|
/// it — `Subordination and Equivalence: The Nature and Role of Woman` — is
|
||||||
|
/// an imprint by the pattern above when the pattern may match at the start.
|
||||||
|
static IMPRINT_AFTER: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"[(.,;]\s*[\p{Lu}][^:;()\d]{1,40}:\s*[^;()]{2,80}").expect("a valid pattern")
|
||||||
|
});
|
||||||
|
|
||||||
/// Words that name a publisher rather than a journal.
|
/// Words that name a publisher rather than a journal.
|
||||||
static PUBLISHER_WORD: LazyLock<Regex> = LazyLock::new(|| {
|
static PUBLISHER_WORD: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r"(?i)\b(?:press|publisher|publishing|books|verlag|editions?|university|imprint|&\s*co\b|ltd\b|inc\b)")
|
Regex::new(r"(?i)\b(?:press|publisher|publishing|books|verlag|editions?|university|imprint|&\s*co\b|ltd\b|inc\b)")
|
||||||
@@ -117,7 +124,6 @@ static NOT_A_TITLE: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
^[\d\p{Pd}] # opens on a page or a volume number
|
^[\d\p{Pd}] # opens on a page or a volume number
|
||||||
| \bpp?\.\s*\d # carries a page reference
|
| \bpp?\.\s*\d # carries a page reference
|
||||||
| ; # two entries run together
|
| ; # two entries run together
|
||||||
| \bvol\.\s*\d
|
|
||||||
| \bibid\b
|
| \bibid\b
|
||||||
| \\ # an escape conversion left behind
|
| \\ # an escape conversion left behind
|
||||||
| ,\s+(?:eds?|trans)\.\s+\p{Lu} # ran past the title into a credit
|
| ,\s+(?:eds?|trans)\.\s+\p{Lu} # ran past the title into a credit
|
||||||
@@ -189,6 +195,32 @@ static SHORT_CITE: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
.expect("a valid pattern")
|
.expect("a valid pattern")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// A name and a locator with nothing between them: `Lowrie and Fletcher,
|
||||||
|
/// p. 133`, `Anderson, p. 400`. Like a short cite with a year, this points
|
||||||
|
/// at a work it does not name.
|
||||||
|
static BARE_CITE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"(?ix) ^ (?:(?:see|cf\.?|and|also)\s+)*
|
||||||
|
\p{Lu}[\p{L}'’\-]{1,24}
|
||||||
|
(?:\s+(?:and|&)\s+\p{Lu}[\p{L}'’\-]{1,24})?
|
||||||
|
\s*,\s* (?:pp?\.|ff?\.|vol\.|no\.)\s*
|
||||||
|
\d{1,5}(?:[-–—]\d{1,5})? \.? $",
|
||||||
|
)
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A text cited by the critical series it is printed in rather than by its
|
||||||
|
/// own name: `PG 4: 264`, `CSEL 34.2, 12`, `NPNF 2/5: 430`. Migne and the
|
||||||
|
/// rest are shelves, not works, and the work is not named here.
|
||||||
|
static SERIES_CITE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"(?x) ^ (?:PG|PL|CSEL|CCSL|CCSG|SC|NPNF|ANF|GCS|PO|CSCO|ACO|ACW|FC|LCL|SVS)
|
||||||
|
\s* \d{0,3} (?:[/.]\d{1,3})* \s* :? \s*
|
||||||
|
[\d\s,:.–-]* \.? $",
|
||||||
|
)
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
|
||||||
/// A page reference or a bracketed imprint: what a citation has and a remark
|
/// A page reference or a bracketed imprint: what a citation has and a remark
|
||||||
/// about the argument does not.
|
/// about the argument does not.
|
||||||
static CITATION_ANCHOR: LazyLock<Regex> = LazyLock::new(|| {
|
static CITATION_ANCHOR: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
@@ -223,10 +255,23 @@ static MARKUP_TAG: LazyLock<Regex> =
|
|||||||
/// Nothing in it is marked up, so the commas and the brackets are all there
|
/// Nothing in it is marked up, so the commas and the brackets are all there
|
||||||
/// is to divide it by.
|
/// is to divide it by.
|
||||||
static NOTE_FORM: LazyLock<Regex> = LazyLock::new(|| {
|
static NOTE_FORM: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(r"^([^,()]{2,60}),\s+([^()]{4,300}?)\s*\(([^()]*(?:1[4-9]|20)\d{2}[a-z]?)\)")
|
Regex::new(
|
||||||
|
r"(?x) ^([^,()]{2,60}),\s+([^()]{4,300}?)\s*
|
||||||
|
\( ( [^()]* (?: (?:1[4-9]|20)\d{2}[a-z]? | :\s*\p{Lu} ) [^()]* ) \)",
|
||||||
|
)
|
||||||
.expect("a valid pattern")
|
.expect("a valid pattern")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// A volume or part trailing a title: `…, Vol. 2`, `…, bk. iv`.
|
||||||
|
static TRAILING_VOLUME: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i),\s*(?:vol|bk|pt|sect?)\.?\s*[\divxlcdm]+\s*$").expect("a valid pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The word that introduces a locator, which is where the name stops.
|
||||||
|
static LOCATOR_WORD: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)^(?:pp?|ff?|vol|bk|ch|no|n|sec)\.?,?$").expect("a valid pattern")
|
||||||
|
});
|
||||||
|
|
||||||
/// A locator: where in the work, rather than what the work is. A page, a
|
/// A locator: where in the work, rather than what the work is. A page, a
|
||||||
/// range, a volume in roman numerals, a folio. These trail a short note and
|
/// range, a volume in roman numerals, a folio. These trail a short note and
|
||||||
/// are not part of its title.
|
/// are not part of its title.
|
||||||
@@ -363,7 +408,15 @@ pub fn one(text: &str, marker: Option<&str>) -> Option<Reference> {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
let parts = sentences(tail);
|
let parts = sentences(tail);
|
||||||
out.title = parts.first().map(|first| tidy_title(&strip_markup(first)));
|
// `…, Subordination and Equivalence: …, Washington: University
|
||||||
|
// Press of America.` has no stop between the title and the
|
||||||
|
// imprint, so the imprint is where the title ends.
|
||||||
|
out.title = parts.first().map(|first| {
|
||||||
|
let cut = IMPRINT_AFTER
|
||||||
|
.find(first)
|
||||||
|
.map_or(first.len(), |found| found.start());
|
||||||
|
tidy_title(&strip_markup(&first[..cut]))
|
||||||
|
});
|
||||||
out.container = match (emphasised, emphasised_at) {
|
out.container = match (emphasised, emphasised_at) {
|
||||||
(Some(journal), Some(at)) if after_year(at) => tidy_container(&journal),
|
(Some(journal), Some(at)) if after_year(at) => tidy_container(&journal),
|
||||||
_ => parts.get(1).and_then(|second| tidy_container(second)),
|
_ => parts.get(1).and_then(|second| tidy_container(second)),
|
||||||
@@ -479,7 +532,14 @@ fn note_form(text: &str) -> Option<(Vec<String>, Option<String>, Option<String>)
|
|||||||
if title.chars().count() < 4 {
|
if title.chars().count() < 4 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some((names, Some(title), tidy_container(&found[3])))
|
// `(Tübingen, 1933)` names a place and a year; only a bracket with a
|
||||||
|
// colon or a publisher's name in it names a publisher.
|
||||||
|
let imprint = found[3].trim();
|
||||||
|
let publisher = match imprint.contains(':') || PUBLISHER_WORD.is_match(imprint) {
|
||||||
|
true => tidy_container(imprint),
|
||||||
|
false => None,
|
||||||
|
};
|
||||||
|
Some((names, Some(title), publisher))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read an entry whose only punctuation is commas.
|
/// Read an entry whose only punctuation is commas.
|
||||||
@@ -504,7 +564,22 @@ fn comma_form(text: &str) -> Option<(Vec<String>, Option<String>, Option<String>
|
|||||||
// The last part with a colon, not the first: `Theories of Nationalism: A
|
// The last part with a colon, not the first: `Theories of Nationalism: A
|
||||||
// Critical Introduction, Basingstoke: Palgrave` has two, and the imprint
|
// Critical Introduction, Basingstoke: Palgrave` has two, and the imprint
|
||||||
// is the one at the end.
|
// is the one at the end.
|
||||||
let at = parts.iter().rposition(|part| part.contains(':'))?;
|
// The imprint is whichever comma-part carries the colon — the last one,
|
||||||
|
// because a title has a colon in it as often as an imprint does. Failing
|
||||||
|
// that, an entry ending on a year has its imprint at the end: `Cheyne,
|
||||||
|
// T. K., Founders of Old Testament Criticism, London, 1893.`
|
||||||
|
let at = match parts.iter().rposition(|part| part.contains(':')) {
|
||||||
|
Some(at) => at,
|
||||||
|
None => {
|
||||||
|
let last = parts.last()?.trim().trim_end_matches('.');
|
||||||
|
if !looks_like_a_year(last) || parts.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// The part before the year is where it was published, which is
|
||||||
|
// not a publisher and not kept.
|
||||||
|
parts.len() - 2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// `Miller, David` is the usual opening, and `Procopius` on its own is the
|
// `Miller, David` is the usual opening, and `Procopius` on its own is the
|
||||||
// other one. Whichever reads as a name, the title starts after it.
|
// other one. Whichever reads as a name, the title starts after it.
|
||||||
@@ -522,7 +597,13 @@ fn comma_form(text: &str) -> Option<(Vec<String>, Option<String>, Option<String>
|
|||||||
if title.chars().count() < 4 {
|
if title.chars().count() < 4 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some((names, Some(title), tidy_container(parts[at])))
|
// Where the colon was is a publisher; where the year was is a place, and
|
||||||
|
// a place is not kept.
|
||||||
|
let publisher = match parts[at].contains(':') {
|
||||||
|
true => tidy_container(parts[at]),
|
||||||
|
false => None,
|
||||||
|
};
|
||||||
|
Some((names, Some(title), publisher))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a short note: a surname, a short title, and where in it.
|
/// Read a short note: a surname, a short title, and where in it.
|
||||||
@@ -552,27 +633,44 @@ fn short_note(text: &str) -> Option<(Vec<String>, Option<String>, Option<String>
|
|||||||
if !LOCATOR.is_match(last) || looks_like_a_year(last) {
|
if !LOCATOR.is_match(last) || looks_like_a_year(last) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let names = authors(parts[0]);
|
// `Krochmal, N., Moreh Nebukhe ha-Zeman` puts the initials in a part of
|
||||||
|
// their own; they belong to the name, not to the title.
|
||||||
|
let named = match parts.get(1).is_some_and(|part| is_initials(part)) {
|
||||||
|
true => 2,
|
||||||
|
false => 1,
|
||||||
|
};
|
||||||
|
let names = authors(&parts[..named].join(", "));
|
||||||
if names.is_empty() {
|
if names.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// The title runs from after the name to the first locator.
|
// The title runs from after the name to the first locator.
|
||||||
let end = parts
|
let end = parts
|
||||||
.iter()
|
.iter()
|
||||||
.skip(1)
|
.skip(named)
|
||||||
.position(|part| LOCATOR.is_match(part.trim_end_matches('.')))
|
.position(|part| LOCATOR.is_match(part.trim_end_matches('.')))
|
||||||
.map(|at| at + 1)
|
.map(|at| at + named)
|
||||||
.unwrap_or(parts.len());
|
.unwrap_or(parts.len());
|
||||||
if end <= 1 {
|
if end <= named {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let title = tidy_title(&strip_markup(&parts[1..end].join(", ")));
|
let title = tidy_title(&strip_markup(&parts[named..end].join(", ")));
|
||||||
match title.chars().count() >= 4 {
|
match title.chars().count() >= 4 {
|
||||||
true => Some((names, Some(title), None)),
|
true => Some((names, Some(title), None)),
|
||||||
false => None,
|
false => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a run is nothing but initials: `N.`, `T. K.`, `J. R. R.`
|
||||||
|
fn is_initials(text: &str) -> bool {
|
||||||
|
let words: Vec<&str> = text.split_whitespace().collect();
|
||||||
|
!words.is_empty()
|
||||||
|
&& words.len() <= 4
|
||||||
|
&& words.iter().all(|word| {
|
||||||
|
let core = word.trim_end_matches('.');
|
||||||
|
core.chars().count() == 1 && core.chars().all(char::is_uppercase)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a run of text is names and nothing else.
|
/// Whether a run of text is names and nothing else.
|
||||||
///
|
///
|
||||||
/// Stricter than asking whether it *contains* a name. `Doe, Jane, and Peter
|
/// Stricter than asking whether it *contains* a name. `Doe, Jane, and Peter
|
||||||
@@ -602,7 +700,10 @@ fn is_short_cite(text: &str) -> bool {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|part| !part.is_empty())
|
.filter(|part| !part.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
!parts.is_empty() && parts.iter().all(|part| SHORT_CITE.is_match(part))
|
!parts.is_empty()
|
||||||
|
&& parts
|
||||||
|
.iter()
|
||||||
|
.all(|part| SHORT_CITE.is_match(part) || BARE_CITE.is_match(part))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The surnames a short cite names, in the order it names them.
|
/// The surnames a short cite names, in the order it names them.
|
||||||
@@ -610,13 +711,19 @@ fn short_cite_authors(text: &str) -> Vec<String> {
|
|||||||
text.split(';')
|
text.split(';')
|
||||||
.filter_map(|part| {
|
.filter_map(|part| {
|
||||||
let part = LEAD_IN.replace(part.trim(), "").trim().to_string();
|
let part = LEAD_IN.replace(part.trim(), "").trim().to_string();
|
||||||
|
// Up to the year or the locator, whichever the cite carries.
|
||||||
let name: Vec<&str> = part
|
let name: Vec<&str> = part
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.take_while(|word| !word.chars().next().is_some_and(|c| c.is_ascii_digit()))
|
.take_while(|word| {
|
||||||
|
!word.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||||
|
&& !LOCATOR_WORD.is_match(word)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let name = name.join(" ");
|
||||||
|
let name = name.trim_end_matches([',', '.', ';', ' ']);
|
||||||
match name.is_empty() {
|
match name.is_empty() {
|
||||||
true => None,
|
true => None,
|
||||||
false => Some(name.join(" ")),
|
false => Some(restore_initial(name)),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -634,7 +741,11 @@ fn is_scripture(text: &str) -> bool {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|part| !part.is_empty())
|
.filter(|part| !part.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
if parts.is_empty() || !parts.iter().all(|part| VERSE.is_match(part)) {
|
if parts.is_empty()
|
||||||
|
|| !parts
|
||||||
|
.iter()
|
||||||
|
.all(|part| VERSE.is_match(part) || SERIES_CITE.is_match(part))
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// A run of bare numbers is not scripture just because it could follow
|
// A run of bare numbers is not scripture just because it could follow
|
||||||
@@ -738,6 +849,10 @@ fn tidy_title(raw: &str) -> String {
|
|||||||
// title's, and only the outermost pair is trimmed.
|
// title's, and only the outermost pair is trimmed.
|
||||||
let text =
|
let text =
|
||||||
text.trim_matches(|c: char| ",.;:'\u{2018}\u{2019}".contains(c) || c.is_whitespace());
|
text.trim_matches(|c: char| ",.;:'\u{2018}\u{2019}".contains(c) || c.is_whitespace());
|
||||||
|
// `Geschichte des Papsttums, Vol. 2` names the work and then which volume
|
||||||
|
// of it. The volume is where in the work, not what the work is called.
|
||||||
|
let text = TRAILING_VOLUME.replace(text, "");
|
||||||
|
let text = text.trim_end_matches(|c: char| ",.;: ".contains(c));
|
||||||
// A run that opens on a page number, or carries one, is two entries run
|
// A run that opens on a page number, or carries one, is two entries run
|
||||||
// together or a reference to a work rather than the name of one. Better
|
// together or a reference to a work rather than the name of one. Better
|
||||||
// to have no title and say so than to report that as one.
|
// to have no title and say so than to report that as one.
|
||||||
@@ -1316,6 +1431,62 @@ mod tests {
|
|||||||
assert_eq!(found.year, Some(1995));
|
assert_eq!(found.year, Some(1995));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_chicago_forms_a_history_or_a_theology_book_sets_its_notes_in() {
|
||||||
|
// A place and a year, no colon and no publisher word anywhere.
|
||||||
|
let place = read("Cheyne, T. K., Founders of Old Testament Criticism, London, 1893.");
|
||||||
|
assert_eq!(place.authors, ["Cheyne, T. K."]);
|
||||||
|
assert_eq!(
|
||||||
|
place.title.as_deref(),
|
||||||
|
Some("Founders of Old Testament Criticism")
|
||||||
|
);
|
||||||
|
assert_eq!(place.year, Some(1893));
|
||||||
|
// London is where it was published, not who published it.
|
||||||
|
assert_eq!(place.container, None);
|
||||||
|
|
||||||
|
// A volume is where in the work, not what the work is called.
|
||||||
|
let volume =
|
||||||
|
read("Erich Caspar, Geschichte des Papsttums, Vol. 2 (Tubingen, 1933), pp. 587-89");
|
||||||
|
assert_eq!(volume.title.as_deref(), Some("Geschichte des Papsttums"));
|
||||||
|
assert_eq!(volume.container, None);
|
||||||
|
assert_eq!(volume.pages.as_deref(), Some("587-89"));
|
||||||
|
|
||||||
|
// A bracketed imprint need not carry a year.
|
||||||
|
let undated =
|
||||||
|
read("St Valerian, Homilies (Washington D.C.: Catholic University of America Press).");
|
||||||
|
assert_eq!(undated.title.as_deref(), Some("Homilies"));
|
||||||
|
assert_eq!(
|
||||||
|
undated.container.as_deref(),
|
||||||
|
Some("Catholic University of America Press")
|
||||||
|
);
|
||||||
|
|
||||||
|
// A title with a colon in it is not an imprint.
|
||||||
|
let colon = read(
|
||||||
|
"BORRESEN, K. E. (1981), Subordination and Equivalence: The Nature \
|
||||||
|
and Role of Woman, Washington: University Press of America.",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
colon.title.as_deref(),
|
||||||
|
Some("Subordination and Equivalence: The Nature and Role of Woman")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_work_cited_by_the_shelf_it_sits_on_is_not_named() {
|
||||||
|
// Migne and the rest are series, not works.
|
||||||
|
for series in [
|
||||||
|
"PG 4: 264, 337, 360, 393, 405; PG 91: 1312-13",
|
||||||
|
"NPNF 2/5: 430",
|
||||||
|
] {
|
||||||
|
assert!(one(series, None).is_none(), "{series}");
|
||||||
|
}
|
||||||
|
// A name and a page with nothing between them names no work either.
|
||||||
|
let bare = read("Lowrie and Fletcher, p. 133; Anderson, p. 400.");
|
||||||
|
assert!(bare.continuation);
|
||||||
|
assert_eq!(bare.authors, ["Lowrie and Fletcher", "Anderson"]);
|
||||||
|
assert!(bare.missing().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_continuation_is_flagged_and_not_resolved() {
|
fn a_continuation_is_flagged_and_not_resolved() {
|
||||||
let found = read("Ibid., pp. 23-24, 39.");
|
let found = read("Ibid., pp. 23-24, 39.");
|
||||||
|
|||||||
Reference in New Issue
Block a user