///|
/// Page-range and citation helpers used by numeric and author-date styles.
pub fn normalize_page_range(value : String) -> String {
let trimmed = value.trim().to_owned()
if trimmed.is_empty() {
return ""
}
let chunks = trimmed.split(",")
let out = []
for chunk in chunks {
let part = chunk.trim().to_owned()
if part.is_empty() {
continue
}
out.push(normalize_range_part(part))
}
join_nonempty(out, ", ")
}
///|
pub fn normalize_range_part(value : String) -> String {
let clean = value.replace(old="--", new="–").replace(old="-", new="–")
match clean.split_once("–") {
None => clean
Some((left, right)) =>
left.trim().to_owned() + "–" + right.trim().to_owned()
}
}
///|
pub fn page_range_start(value : String) -> String {
match normalize_range_part(value).split_once("–") {
Some((a, _)) => a.to_owned()
None => value
}
}
///|
pub fn page_range_end(value : String) -> String {
match normalize_range_part(value).split_once("–") {
Some((_, b)) => b.to_owned()
None => value
}
}
///|
pub fn collapse_numeric_ranges(values : Array[Int]) -> String {
if values.length() == 0 {
return ""
}
let sorted = values.copy()
sorted.sort_by(fn(a, b) { cmp_int(a, b) })
let out = []
let mut start = sorted[0]
let mut last = start
for i = 1; i < sorted.length(); i = i + 1 {
let current = sorted[i]
if current == last + 1 {
last = current
} else {
out.push(render_int_range(start, last))
start = current
last = current
}
}
out.push(render_int_range(start, last))
join_nonempty(out, ", ")
}
///|
pub fn render_int_range(start : Int, end : Int) -> String {
if start == end {
start.to_string()
} else if end == start + 1 {
start.to_string() + "–" + end.to_string()
} else {
start.to_string() + "–" + end.to_string()
}
}
///|
pub fn format_page_variable(item : Item, variable? : String = "page") -> String {
normalize_page_range(item.string(variable))
}
///|
pub fn number_label(
variable : String,
count : Int,
short? : Bool = false,
) -> String {
let form = if short { "short" } else { "long" }
let word = term_for(variable, form)
if count == 1 {
word
} else if short {
word
} else {
word + "s"
}
}
///|
pub fn date_sort_stamp(date : CslDate) -> Int64 {
date.year.to_int64() * 10000L +
date.month.to_int64() * 100L +
date.day.to_int64()
}
///|
pub fn date_is_range(date : CslDate) -> Bool {
date.end_year != 0 || date.end_month != 0 || date.end_day != 0
}
///|
pub fn date_range_text(date : CslDate, delimiter? : String = "–") -> String {
if !date_is_range(date) {
return date.year.to_string()
}
let left = date.year.to_string()
let right = if date.end_year == 0 { left } else { date.end_year.to_string() }
left + delimiter + right
}
///|
pub fn name_family_key(name : CslName) -> String {
(name.non_dropping_particle + " " + name.family).trim().to_owned().to_lower()
}
///|
pub fn name_given_key(name : CslName) -> String {
name.given.to_lower()
}
///|
pub fn names_equal(a : CslName, b : CslName) -> Bool {
name_family_key(a) == name_family_key(b) &&
name_given_key(a) == name_given_key(b)
}
///|
pub fn name_list_equal(a : Array[CslName], b : Array[CslName]) -> Bool {
if a.length() != b.length() {
return false
}
for i = 0; i < a.length(); i = i + 1 {
match (a.get(i), b.get(i)) {
(Some(x), Some(y)) => if !names_equal(x, y) { return false }
_ => return false
}
}
true
}
///|
pub fn item_author_key(item : Item) -> String {
let authors = item.name("author")
if authors.length() == 0 {
return item.string("title").to_lower()
}
let out = []
for name in authors {
out.push(name_family_key(name))
}
join_nonempty(out, ";")
}
///|
pub fn item_author_year_key(item : Item) -> String {
let year = match item.date("issued") {
Some(d) => d.year.to_string()
None => "n.d."
}
item_author_key(item) + ":" + year
}
///|
pub fn assign_year_suffixes(items : Array[Item]) -> Unit {
let groups : Map[String, Array[Item]] = Map([])
for item in items {
let key = item_author_year_key(item)
let group = groups.get(key).unwrap_or([])
group.push(item)
groups[key] = group
}
groups.each(fn(_, group) {
if group.length() > 1 {
let ordered = group.copy()
ordered.sort_by(fn(a, b) { cmp_str(a.id, b.id) })
for i = 0; i < ordered.length(); i = i + 1 {
ordered[i].strings["year-suffix"] = year_letter(i)
}
}
})
}
///|
pub fn with_citation_numbers(items : Array[Item]) -> Array[Item] {
let out = items.copy()
for i = 0; i < out.length(); i = i + 1 {
out[i].strings["citation-number"] = (i + 1).to_string()
}
out
}
///|
pub fn item_by_id(items : Array[Item], id : String) -> Item? {
for item in items {
if item.id == id {
return Some(item)
}
}
None
}
///|
pub fn cite_items(items : Array[Item], cites : Array[Cite]) -> Array[Item] {
let out = []
for cite in cites {
match item_by_id(items, cite.id) {
Some(item) => out.push(item)
None => ()
}
}
out
}
///|
pub fn render_cite_prefix(cite : Cite) -> String {
let mut out = cite.prefix
if !cite.locator.is_empty() {
out = out + ", " + cite.label + " " + cite.locator
}
if !cite.suffix.is_empty() {
out = out + cite.suffix
}
out
}
///|
pub fn merge_citation_parts(main : String, cites : Array[Cite]) -> String {
let suffixes = []
for cite in cites {
let value = render_cite_prefix(cite)
if !value.is_empty() {
suffixes.push(value)
}
}
if suffixes.length() == 0 {
main
} else {
main + " " + join_nonempty(suffixes, " ")
}
}
///|
pub fn citation_has_locator(cites : Array[Cite]) -> Bool {
for cite in cites {
if !cite.locator.is_empty() {
return true
}
}
false
}
///|
pub fn item_has_author(item : Item) -> Bool {
item.name("author").length() > 0
}
///|
pub fn item_has_editor(item : Item) -> Bool {
item.name("editor").length() > 0
}
///|
pub fn item_is_book(item : Item) -> Bool {
item.item_type == "book" || item.item_type == "chapter"
}
///|
pub fn item_is_periodical(item : Item) -> Bool {
item.item_type == "article-journal" ||
item.item_type == "article-magazine" ||
item.item_type == "article-newspaper"
}
///|
pub fn item_is_online(item : Item) -> Bool {
!item.url.is_empty() || item.item_type == "webpage"
}
///|
pub fn item_display_title(item : Item) -> String {
let title = strip_periods(item.title)
if title.is_empty() {
item.id
} else {
title
}
}
///|
pub fn item_container_label(item : Item) -> String {
if !item.container_title.is_empty() {
item.container_title
} else if item_is_book(item) {
"Book"
} else {
""
}
}
///|
pub fn format_edition(item : Item) -> String {
if item.edition.is_empty() {
return ""
}
match parse_int(item.edition) {
Some(n) => ordinal_en(n) + " ed."
None => item.edition
}
}
///|
pub fn format_volume_issue(item : Item) -> String {
if item.volume.is_empty() {
return ""
}
if item.issue.is_empty() {
item.volume
} else {
item.volume + "(" + item.issue + ")"
}
}
///|
pub fn format_identifier(item : Item, prefer_doi? : Bool = true) -> String {
if prefer_doi && !item.doi.is_empty() {
"https://doi.org/" + item.doi
} else {
item.url
}
}
///|
pub fn compact_whitespace(value : String) -> String {
join_nonempty(split_ws(value), " ")
}
///|
pub fn normalize_title(value : String) -> String {
apply_text_case(compact_whitespace(value), "sentence")
}
///|
pub fn sentence_title(value : String) -> String {
let text = compact_whitespace(value)
if text.is_empty() {
return ""
}
let chars = text.to_array()
let out = StringBuilder()
for i = 0; i < chars.length(); i = i + 1 {
if i == 0 {
out.write_char(ascii_upper(chars[i].to_string()).to_array()[0])
} else {
out.write_char(chars[i])
}
}
out.to_string()
}
///|
pub fn title_case_words(value : String) -> String {
let words = split_ws(value)
let out = []
for word in words {
if word.is_empty() {
continue
}
let chars = word.to_array()
let head = match chars.get(0) {
Some(c) => ascii_upper(c.to_string())
None => ""
}
let tail = StringBuilder()
for i = 1; i < chars.length(); i = i + 1 {
tail.write_char(chars[i])
}
out.push(head + tail.to_string())
}
join_nonempty(out, " ")
}
///|
pub fn invert_name(name : CslName) -> String {
let family = join_nonempty([name.non_dropping_particle, name.family], " ")
let given = join_nonempty([name.dropping_particle, name.given], " ")
if given.is_empty() {
family
} else {
family + ", " + given
}
}
///|
pub fn initials_for_name(name : CslName, marker? : String = ". ") -> String {
let initials = name_initial(name.given, marker)
join_nonempty(
[name.dropping_particle, initials, name.family, name.suffix],
" ",
)
}
///|
pub fn bibliography_key(item : Item) -> String {
item_author_key(item) +
"|" +
item_author_year_key(item) +
"|" +
normalize_title(item.title)
}
///|
pub fn compare_items_for_bibliography(a : Item, b : Item) -> Int {
cmp_str(bibliography_key(a), bibliography_key(b))
}
///|
pub fn deduplicate_items(items : Array[Item]) -> Array[Item] {
let seen : Map[String, Bool] = Map([])
let out = []
for item in items {
let key = bibliography_key(item)
if !seen.contains(key) {
seen[key] = true
out.push(item)
}
}
out
}
///|
pub fn validate_item(item : Item) -> Array[String] {
let errors = []
if item.id.is_empty() {
errors.push("id is empty")
}
if item.title.is_empty() && item.name("author").length() == 0 {
errors.push("title and author are both empty")
}
match item.date("issued") {
Some(date) =>
if date.year < 0 || date.year > 9999 {
errors.push("issued year is outside 0..9999")
}
None => ()
}
errors
}
///|
pub fn validate_items(items : Array[Item]) -> Map[String, Array[String]] {
let out : Map[String, Array[String]] = Map([])
for item in items {
let errors = validate_item(item)
if errors.length() > 0 {
out[item.id] = errors
}
}
out
}
///|
pub fn bibliography_statistics(items : Array[Item]) -> Map[String, Int] {
let out : Map[String, Int] = Map([])
out["items"] = items.length()
out["with-author"] = 0
out["with-issued"] = 0
out["with-doi"] = 0
for item in items {
if item_has_author(item) {
out["with-author"] = out["with-author"] + 1
}
if item.date("issued") is Some(_) {
out["with-issued"] = out["with-issued"] + 1
}
if !item.doi.is_empty() {
out["with-doi"] = out["with-doi"] + 1
}
}
out
}