///|
/// A literal section comparison; renamed titles are added/removed, not guessed.
pub(all) struct SectionChange {
id : String
title : String
kind : String
before : Evidence?
after : Evidence?
before_text : String
after_text : String
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) struct SectionComparison {
changes : Array[SectionChange]
issues : Array[Issue]
notes : Array[String]
} derive(Eq, Debug, ToJson, FromJson)
///|
priv struct ComparisonPart {
key : String
title : String
evidence : Evidence
text : String
}
///|
fn comparison_parts(doc : Document, page : Page) -> Array[ComparisonPart] {
if doc.content == "" {
return []
}
let lines = doc.content.split("\n").map(s => s.to_owned()).to_array()
let parts : Array[ComparisonPart] = []
let intro_end = if page.sections.is_empty() {
lines.length()
} else {
page.sections[0].evidence.start_line - 1
}
if intro_end > 0 {
let text = lines[:intro_end].to_owned().join("\n")
parts.push({
key: "intro",
title: if page.sections.is_empty() {
"全文 / 无标题内容"
} else {
"文首 / 元数据"
},
text,
evidence: {
path: doc.path,
start_line: 1,
end_line: intro_end,
quote: clip_text(text, 1200),
},
})
}
let occurrences : Map[String, Int32] = Map([])
for s in page.sections {
let name = normalize_name(s.title)
let n = occurrences.get(name).unwrap_or(0)
occurrences[name] = n + 1
parts.push({
key: "heading:" + Json([name, n.to_string()]).stringify(),
title: s.title,
evidence: s.evidence,
text: lines[s.evidence.start_line - 1:s.evidence.end_line]
.to_owned()
.join("\n"),
})
}
parts
}
///|
/// Compare complete section text, never the clipped evidence excerpt.
/// Identical content moving to different line numbers is not an edit.
pub fn compare_sections(
before : Document,
after : Document,
) -> SectionComparison {
let issues = validate_documents([before])
issues.append(validate_documents([after]))
if !issues.is_empty() {
return { changes: [], issues, notes: [], }
}
let old = parse_document(before, [])
let fresh = parse_document(after, [])
if old.sections.length() > 3000 || fresh.sections.length() > 3000 {
return {
changes: [],
issues: [
problem("limit", after.path, "每个快照最多 3,000 个标题"),
],
notes: [],
}
}
let notes : Array[String] = []
for page in [old, fresh] {
let seen : Map[String, Bool] = Map([])
for s in page.sections {
let key = normalize_name(s.title)
if seen.contains(key) {
if notes.is_empty() {
notes.push(
"存在同名标题:按出现顺序配对;插入、删除或调整重复标题的顺序可能导致额外变化,请核对两侧原文。",
)
}
}
seen[key] = true
}
}
let old_parts = comparison_parts(before, old)
let new_parts = comparison_parts(after, fresh)
let old_map : Map[String, ComparisonPart] = Map([])
let new_map : Map[String, ComparisonPart] = Map([])
for p in old_parts {
old_map[p.key] = p
}
for p in new_parts {
new_map[p.key] = p
}
let changes : Array[SectionChange] = []
for p in new_parts {
match old_map.get(p.key) {
Some(previous) =>
if previous.text != p.text {
changes.push({
id: p.key,
title: p.title,
kind: "updated",
before: Some(previous.evidence),
after: Some(p.evidence),
before_text: previous.text,
after_text: p.text,
})
}
None =>
changes.push({
id: p.key,
title: p.title,
kind: "added",
before: None,
after: Some(p.evidence),
before_text: "",
after_text: p.text,
})
}
}
for p in old_parts {
if !new_map.contains(p.key) {
changes.push({
id: p.key,
title: p.title,
kind: "removed",
before: Some(p.evidence),
after: None,
before_text: p.text,
after_text: "",
})
}
}
{ changes, issues, notes, }
}