Skip to main content

mbr_core/
contact.rs

1//! Contact model for `type: person` and `type: organization` notes.
2//!
3//! Pure: no I/O, no clock. [`Contact::from_yaml`] reads the **raw** frontmatter
4//! rather than the simplified map every other page feature uses, because the
5//! simplifier is lossy in exactly the places contacts live — it drops arrays of
6//! maps (`phones: [{mobile: …}]`, labeled `aliases`) and flattens nested maps
7//! into dot keys. The relationship parser takes the same raw path for the same
8//! reason.
9//!
10//! # Shapes accepted
11//!
12//! The frontmatter schema is meant to round-trip through vCard 3/4, Apple
13//! `CNContact` and the Google People API, all of which model *labeled
14//! multi-values*. A YAML author can spell one three ways, and all three read
15//! identically for unique labels (pinned by a proptest):
16//!
17//! ```yaml
18//! emails: {work: jane@abc.com, home: jane@gmail.com}   # map: label → value
19//! emails: [{work: jane@abc.com}, jane@gmail.com]        # list: order = preference, bare = no label
20//! emails: jane@abc.com                                  # single bare value
21//! ```
22//!
23//! `{label: work, value: jane@abc.com}` list items are tolerated too
24//! (undocumented). Anything else — a multi-key map, a nested structure where a
25//! scalar belongs — is skipped and recorded in [`Contact::problems`], which the
26//! page-problems panel shows; a contact card never fails to render over bad data.
27//!
28//! # Shared helpers
29//!
30//! [`alias_names`] is the **one** definition of "the names in an `aliases`
31//! field". The frontmatter simplifier flattens `aliases` through it, so every
32//! consumer of the simplified map (name index, wikilink index, link grep,
33//! rename rewriting, search) sees the labeled names too without each re-parsing
34//! the mixed list. [`normalize_simplified`] is that simplifier hook.
35
36use serde::Serialize;
37use serde_json::{Map, Value};
38use yaml_rust2::Yaml;
39
40use crate::markdown::SimpleMetadata;
41
42/// The frontmatter key holding a note's alternate names.
43pub const ALIASES_KEY: &str = "aliases";
44
45/// Contact-detail keys that only ever appear on the page itself.
46///
47/// They are kept out of `site.json` (see [`is_public_frontmatter_key`]): that file is
48/// fetched by every page view, often published with a static site, and a phone
49/// number has no business in a sidebar index.
50pub const PRIVATE_DETAIL_KEYS: &[&str] = &["emails", "phones", "urls", "social", "im", "addresses"];
51
52/// Well-known date labels, in display order. Others follow in authored order.
53const WELL_KNOWN_DATES: &[&str] = &["birthday", "death", "anniversary"];
54
55/// Field names that mark a list item as an *address object* rather than a
56/// one-key `label: value` pair (`- street: "1 Main St"` is an address with no
57/// label, not an address labeled "street").
58const ADDRESS_FIELDS: &[&str] = &[
59    "street",
60    "city",
61    "region",
62    "postcode",
63    "country",
64    "country_code",
65];
66
67/// Which kind of contact a note is.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
69#[serde(rename_all = "lowercase")]
70pub enum ContactKind {
71    Person,
72    Organization,
73}
74
75impl ContactKind {
76    /// Reads a frontmatter `type`. Exact (trimmed) match, the same test the
77    /// templates and the frontend apply, so the three can never disagree about
78    /// which notes are contacts.
79    pub fn from_type(value: &str) -> Option<Self> {
80        match value.trim() {
81            "person" => Some(Self::Person),
82            "organization" => Some(Self::Organization),
83            _ => None,
84        }
85    }
86}
87
88/// A date that may be missing its year, month or day.
89///
90/// Genealogy and address-book data are full of partial dates — a birthday
91/// without a year is the *normal* case in a phone's contacts — so the year is
92/// as optional as the day.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum PartialDate {
95    Full { year: u16, month: u8, day: u8 },
96    YearMonth { year: u16, month: u8 },
97    Year { year: u16 },
98    MonthDay { month: u8, day: u8 },
99}
100
101const MONTH_NAMES: [&str; 12] = [
102    "January",
103    "February",
104    "March",
105    "April",
106    "May",
107    "June",
108    "July",
109    "August",
110    "September",
111    "October",
112    "November",
113    "December",
114];
115
116impl PartialDate {
117    /// Parses `YYYY-MM-DD`, `YYYY-MM`, `YYYY`, `MM-DD` and `--MM-DD` (the vCard
118    /// spelling of a yearless date). Components must be zero-padded and in
119    /// range; February allows the 29th since the year may be unknown. Anything
120    /// else — prose like "circa 1855", a two-digit year — is `None`, and the
121    /// caller shows the authored text unchanged.
122    pub fn parse(input: &str) -> Option<Self> {
123        let s = input.trim();
124        if let Some(md) = s.strip_prefix("--") {
125            return Self::parse_month_day(md);
126        }
127        let parts: Vec<&str> = s.split('-').collect();
128        match parts.as_slice() {
129            [y, m, d] => {
130                let (year, month) = (year4(y)?, month2(m)?);
131                let day = day2(d, month)?;
132                Some(Self::Full { year, month, day })
133            }
134            [y, m] if y.len() == 4 => Some(Self::YearMonth {
135                year: year4(y)?,
136                month: month2(m)?,
137            }),
138            [_, _] => Self::parse_month_day(s),
139            [y] => Some(Self::Year { year: year4(y)? }),
140            _ => None,
141        }
142    }
143
144    fn parse_month_day(s: &str) -> Option<Self> {
145        let (m, d) = s.split_once('-')?;
146        let month = month2(m)?;
147        Some(Self::MonthDay {
148            month,
149            day: day2(d, month)?,
150        })
151    }
152
153    /// Reader-facing form: "March 19, 1927", "March 1927", "1927", "March 19".
154    pub fn display(&self) -> String {
155        let month_name = |m: u8| MONTH_NAMES[usize::from(m) - 1];
156        match *self {
157            Self::Full { year, month, day } => format!("{} {day}, {year}", month_name(month)),
158            Self::YearMonth { year, month } => format!("{} {year}", month_name(month)),
159            Self::Year { year } => year.to_string(),
160            Self::MonthDay { month, day } => format!("{} {day}", month_name(month)),
161        }
162    }
163
164    /// The HTML `<time datetime>` spelling: like [`Self::iso`], except that a
165    /// yearless date is `MM-DD` (HTML's "yearless date string"), not vCard's
166    /// `--MM-DD`.
167    pub fn html_datetime(&self) -> String {
168        match *self {
169            Self::MonthDay { month, day } => format!("{month:02}-{day:02}"),
170            other => other.iso(),
171        }
172    }
173
174    /// Canonical machine form: ISO 8601 for dated forms, `--MM-DD` (vCard) for
175    /// a yearless one. This is what `site.json` carries.
176    pub fn iso(&self) -> String {
177        match *self {
178            Self::Full { year, month, day } => format!("{year:04}-{month:02}-{day:02}"),
179            Self::YearMonth { year, month } => format!("{year:04}-{month:02}"),
180            Self::Year { year } => format!("{year:04}"),
181            Self::MonthDay { month, day } => format!("--{month:02}-{day:02}"),
182        }
183    }
184}
185
186fn all_digits(s: &str) -> bool {
187    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
188}
189
190fn year4(s: &str) -> Option<u16> {
191    (s.len() == 4 && all_digits(s))
192        .then(|| s.parse().ok())
193        .flatten()
194}
195
196fn month2(s: &str) -> Option<u8> {
197    (s.len() == 2 && all_digits(s))
198        .then(|| s.parse().ok())
199        .flatten()
200        .filter(|m| (1..=12).contains(m))
201}
202
203fn day2(s: &str, month: u8) -> Option<u8> {
204    const DAYS: [u8; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
205    (s.len() == 2 && all_digits(s))
206        .then(|| s.parse().ok())
207        .flatten()
208        .filter(|d| (1..=DAYS[usize::from(month) - 1]).contains(d))
209}
210
211/// Humanizes a date string for display, passing anything unparseable through
212/// unchanged. Backs the `humandate` Tera filter.
213pub fn humanize_date(input: &str) -> String {
214    PartialDate::parse(input)
215        .map(|d| d.display())
216        .unwrap_or_else(|| input.to_string())
217}
218
219/// The parts of a person's name. All optional.
220#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
221pub struct NameParts {
222    pub prefix: Option<String>,
223    pub first: Option<String>,
224    pub middle: Option<String>,
225    pub last: Option<String>,
226    pub suffix: Option<String>,
227}
228
229impl NameParts {
230    /// "Dr. Jane Q. Doe Jr." — the parts present, space-joined.
231    fn full(&self) -> Option<String> {
232        let joined = [
233            &self.prefix,
234            &self.first,
235            &self.middle,
236            &self.last,
237            &self.suffix,
238        ]
239        .into_iter()
240        .flatten()
241        .map(String::as_str)
242        .collect::<Vec<_>>()
243        .join(" ");
244        (!joined.is_empty()).then_some(joined)
245    }
246}
247
248/// A `company` value: plain text, or a wikilink to an organization note.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
250pub struct Company {
251    /// Display text: the wikilink's display/target, or the plain text.
252    pub name: String,
253    /// The value exactly as authored (e.g. `[[Acme Corp]]`). This is the
254    /// `neighbor_raw` of the implied `employer` relationship, which is how
255    /// [`Contact::resolve_company`] finds it.
256    pub raw: String,
257    /// Whether the value was a wikilink (and so may resolve to a note).
258    pub is_link: bool,
259    /// The resolved note URL, filled in by [`Contact::resolve_company`].
260    pub url: Option<String>,
261}
262
263/// One alternate name, optionally labeled (`maiden_name: Mary Smith`).
264#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
265pub struct Alias {
266    pub label: Option<String>,
267    pub name: String,
268}
269
270/// One labeled value of a multi-value field (an email, a phone, a URL…).
271#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
272pub struct Labeled {
273    /// The label as authored (`home_fax`), if any.
274    pub label: Option<String>,
275    /// The label for display (`Home fax`).
276    pub label_display: Option<String>,
277    pub value: String,
278    /// A link target, only ever `mailto:`, `tel:`, `http:` or `https:`. `None`
279    /// when the value does not make a safe link of its field's kind — the card
280    /// then shows it as text. Never derived from anything but the value, so a
281    /// `javascript:` URL in frontmatter cannot become a link.
282    pub href: Option<String>,
283}
284
285/// One postal address.
286#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
287pub struct Address {
288    pub label: Option<String>,
289    pub label_display: Option<String>,
290    pub street: Option<String>,
291    pub city: Option<String>,
292    pub region: Option<String>,
293    pub postcode: Option<String>,
294    pub country: Option<String>,
295    pub country_code: Option<String>,
296    /// Display lines, street first. A plain-string address is one or more
297    /// lines of preformatted text; a structured one is assembled as
298    /// street / "city, region postcode" / country.
299    pub lines: Vec<String>,
300}
301
302/// One labeled date (`birthday`, `first_met`, …).
303#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
304pub struct LabeledDate {
305    pub label: String,
306    pub label_display: String,
307    /// The value as authored (a YAML integer year is stringified).
308    pub raw: String,
309    /// `PartialDate::iso` when parseable, else `raw`.
310    pub value: String,
311    /// `PartialDate::display` when parseable, else `raw`.
312    pub display: String,
313    /// A valid HTML `<time datetime>` value (`PartialDate::html_datetime`), or
314    /// `None` for unparseable text.
315    pub datetime: Option<String>,
316    /// `born_place` on the birthday, `died_place` on the death date.
317    pub place: Option<String>,
318    #[serde(skip)]
319    pub date: Option<PartialDate>,
320}
321
322/// A malformed contact field, reported in the page-problems panel.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
324pub struct ContactProblem {
325    /// The frontmatter key at fault (`phones`, `aliases`, …).
326    pub field: String,
327    pub message: String,
328}
329
330/// Everything the contact card shows, parsed from one note's frontmatter.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
332pub struct Contact {
333    pub kind: ContactKind,
334    /// The headline: `title` when set, else the name parts (person) or the
335    /// company (organization). `None` when there is nothing to show.
336    pub display_name: Option<String>,
337    pub name: NameParts,
338    pub company: Option<Company>,
339    pub department: Option<String>,
340    pub job_title: Option<String>,
341    /// `image` as authored; the renderer rewrites relative paths for the
342    /// page's own URL before the card sees it.
343    pub image: Option<String>,
344    pub gender: Option<String>,
345    pub aliases: Vec<Alias>,
346    /// Ready-to-show alias phrases ("aka Mare, Bob", "née Mary Smith", …).
347    pub alias_phrases: Vec<String>,
348    pub emails: Vec<Labeled>,
349    pub phones: Vec<Labeled>,
350    pub urls: Vec<Labeled>,
351    pub social: Vec<Labeled>,
352    pub im: Vec<Labeled>,
353    pub addresses: Vec<Address>,
354    /// Well-known labels first (birthday, death, anniversary), then authored order.
355    pub dates: Vec<LabeledDate>,
356    pub born_place: Option<String>,
357    /// Places whose date is unknown (`born_place` with no birthday), shown as
358    /// their own lines in the Dates section.
359    pub places: Vec<Labeled>,
360    pub died_place: Option<String>,
361    pub problems: Vec<ContactProblem>,
362}
363
364/// How a field's values become links.
365#[derive(Clone, Copy)]
366enum HrefKind {
367    Email,
368    Phone,
369    Web,
370}
371
372impl Contact {
373    /// Parses a contact from raw YAML frontmatter, or `None` when the note is
374    /// not `type: person` / `type: organization`.
375    ///
376    /// Checks the type on the YAML before converting anything, so the cost for
377    /// every other note is one hash lookup.
378    pub fn from_yaml(yaml: &Yaml) -> Option<Self> {
379        let hash = yaml.as_hash()?;
380        let kind = hash
381            .get(&Yaml::String("type".to_string()))
382            .and_then(Yaml::as_str)
383            .and_then(ContactKind::from_type)?;
384        match crate::relationships::yaml_to_json(yaml) {
385            Value::Object(fm) => Some(Self::from_map(kind, &fm)),
386            _ => None,
387        }
388    }
389
390    /// Parses a contact from frontmatter already converted to JSON.
391    pub fn from_frontmatter(fm: &Map<String, Value>) -> Option<Self> {
392        let kind = fm
393            .get("type")
394            .and_then(Value::as_str)
395            .and_then(ContactKind::from_type)?;
396        Some(Self::from_map(kind, fm))
397    }
398
399    fn from_map(kind: ContactKind, fm: &Map<String, Value>) -> Self {
400        let mut problems = Vec::new();
401        let text = |key: &str| fm.get(key).and_then(scalar_text);
402
403        let name = NameParts {
404            prefix: text("prefix"),
405            first: text("first_name"),
406            middle: text("middle_name"),
407            last: text("last_name"),
408            suffix: text("suffix"),
409        };
410        let company = text("company").map(|raw| parse_company(&raw));
411        let display_name = text("title").or_else(|| match kind {
412            ContactKind::Person => name.full(),
413            ContactKind::Organization => company.as_ref().map(|c| c.name.clone()),
414        });
415
416        let aliases = parse_aliases(fm.get(ALIASES_KEY), Some(&mut problems));
417        let alias_phrases = alias_phrases(&aliases);
418
419        let mut labeled = |key: &str, kind: HrefKind| {
420            labeled_values(key, fm.get(key), &mut problems)
421                .into_iter()
422                .filter_map(|(label, value)| {
423                    let value = scalar_text(&value)?;
424                    Some(Labeled {
425                        label_display: label.as_deref().map(humanize_label),
426                        href: href_for(kind, &value),
427                        label,
428                        value,
429                    })
430                })
431                .collect::<Vec<_>>()
432        };
433        let emails = labeled("emails", HrefKind::Email);
434        let phones = labeled("phones", HrefKind::Phone);
435        let urls = labeled("urls", HrefKind::Web);
436        let social = labeled("social", HrefKind::Web);
437        let im = labeled("im", HrefKind::Web);
438
439        let addresses = labeled_values("addresses", fm.get("addresses"), &mut problems)
440            .into_iter()
441            .filter_map(|(label, value)| parse_address(label, &value, &mut problems))
442            .collect();
443        let mut dates = parse_dates(fm, Some(&mut problems));
444        let (born_place, died_place) = (text("born_place"), text("died_place"));
445        let places = attach_places(&mut dates, born_place.as_ref(), died_place.as_ref());
446
447        Self {
448            kind,
449            display_name,
450            name,
451            company,
452            department: text("department"),
453            job_title: text("job_title"),
454            image: text("image"),
455            gender: text("gender"),
456            aliases,
457            alias_phrases,
458            emails,
459            phones,
460            urls,
461            social,
462            im,
463            addresses,
464            dates,
465            born_place,
466            died_place,
467            places,
468            problems,
469        }
470    }
471
472    /// Fills [`Company::url`] from the note's resolved relationships: the
473    /// `employer` edge this note's `company` wikilink implied. Reading it back
474    /// from the index (rather than resolving the wikilink again here)
475    /// guarantees the card links to the same note the relationship graph does.
476    ///
477    /// Matches on the [`ResolvedRelationship::implied`] flag and the predicate
478    /// as seen *from this note*, never on how the edge was stored: when the
479    /// organization's `employee` declaration (or an explicit `employer` with
480    /// other casing or an alias) is aggregated first, the shared edge keeps
481    /// that declaration's direction, type and endpoint text, none of which
482    /// spell the authored `company`.
483    ///
484    /// [`ResolvedRelationship::implied`]: crate::relationships::ResolvedRelationship::implied
485    pub fn resolve_company(
486        &mut self,
487        relationships: &[crate::relationships::ResolvedRelationship],
488    ) {
489        let Some(company) = self.company.as_mut().filter(|c| c.is_link) else {
490            return;
491        };
492        company.url = relationships
493            .iter()
494            .find(|r| {
495                r.resolved
496                    && r.implied
497                    && r.predicate
498                        .eq_ignore_ascii_case(crate::relationships::COMPANY_RELATION_TYPE)
499            })
500            .map(|r| r.neighbor.clone());
501    }
502}
503
504/// The text of a scalar YAML/JSON value, trimmed; `None` for empty strings,
505/// nulls and structures. Numbers are kept (a postcode or a year often arrives
506/// as one).
507fn scalar_text(v: &Value) -> Option<String> {
508    let s = match v {
509        Value::String(s) => s.trim().to_string(),
510        Value::Number(n) => n.to_string(),
511        Value::Bool(b) => b.to_string(),
512        _ => return None,
513    };
514    (!s.is_empty()).then_some(s)
515}
516
517fn parse_company(raw: &str) -> Company {
518    let link = raw
519        .strip_prefix("[[")
520        .and_then(|r| r.strip_suffix("]]"))
521        .map(str::trim)
522        .filter(|inner| !inner.is_empty());
523    match link {
524        Some(inner) => {
525            let (target, shown) = inner.split_once('|').unwrap_or((inner, inner));
526            let name = Some(shown.trim())
527                .filter(|s| !s.is_empty())
528                .unwrap_or(target.trim());
529            Company {
530                name: name.to_string(),
531                raw: raw.to_string(),
532                is_link: true,
533                url: None,
534            }
535        }
536        None => Company {
537            name: raw.to_string(),
538            raw: raw.to_string(),
539            is_link: false,
540            url: None,
541        },
542    }
543}
544
545/// Splits a labeled multi-value field into `(label, value)` pairs, in authored
546/// order. See the module docs for the accepted shapes.
547///
548/// `value` is returned unconverted so addresses can be structured; callers
549/// that want a scalar reject anything else themselves.
550fn labeled_values(
551    field: &str,
552    v: Option<&Value>,
553    problems: &mut Vec<ContactProblem>,
554) -> Vec<(Option<String>, Value)> {
555    let mut report = |message: String| {
556        problems.push(ContactProblem {
557            field: field.to_string(),
558            message,
559        })
560    };
561    match v {
562        None | Some(Value::Null) => Vec::new(),
563        Some(Value::Array(items)) => items
564            .iter()
565            .enumerate()
566            .filter_map(|(i, item)| match labeled_item(field, item) {
567                Some(pair) => Some(pair),
568                None => {
569                    report(format!(
570                        "entry {} is not a value, a one-key `label: value` map or \
571                         `{{label, value}}`; ignored",
572                        i + 1
573                    ));
574                    None
575                }
576            })
577            .collect(),
578        Some(Value::Object(map)) => {
579            if let Some(pair) = label_value_object(map) {
580                return vec![pair];
581            }
582            if field == "addresses" && is_address_object(map) {
583                return vec![(None, Value::Object(map.clone()))];
584            }
585            map.iter().map(|(k, v)| (non_empty(k), v.clone())).collect()
586        }
587        Some(scalar) => vec![(None, scalar.clone())],
588    }
589}
590
591/// One list item of a labeled field, or `None` when it has an unusable shape.
592fn labeled_item(field: &str, item: &Value) -> Option<(Option<String>, Value)> {
593    match item {
594        Value::Object(map) => label_value_object(map)
595            .or_else(|| {
596                (field == "addresses" && is_address_object(map)).then(|| {
597                    let label = map.get("label").and_then(scalar_text);
598                    let mut rest = map.clone();
599                    rest.remove("label");
600                    (label, Value::Object(rest))
601                })
602            })
603            .or_else(|| match map.len() {
604                1 => map.iter().next().map(|(k, v)| (non_empty(k), v.clone())),
605                _ => None,
606            }),
607        Value::Array(_) | Value::Null => None,
608        scalar => Some((None, scalar.clone())),
609    }
610}
611
612/// The tolerated `{label: work, value: x}` (or `{label, name}`) object form.
613fn label_value_object(map: &Map<String, Value>) -> Option<(Option<String>, Value)> {
614    if map.len() != 2 {
615        return None;
616    }
617    let label = map.get("label")?;
618    let value = map.get("value").or_else(|| map.get("name"))?;
619    Some((scalar_text(label), value.clone()))
620}
621
622fn is_address_object(map: &Map<String, Value>) -> bool {
623    map.keys().any(|k| ADDRESS_FIELDS.contains(&k.as_str()))
624}
625
626fn non_empty(s: &str) -> Option<String> {
627    let t = s.trim();
628    (!t.is_empty()).then(|| t.to_string())
629}
630
631fn parse_address(
632    label: Option<String>,
633    value: &Value,
634    problems: &mut Vec<ContactProblem>,
635) -> Option<Address> {
636    let label_display = label.as_deref().map(humanize_label);
637    match value {
638        Value::Object(map) => {
639            let field = |k: &str| map.get(k).and_then(scalar_text);
640            let street = field("street");
641            let (city, region, postcode) = (field("city"), field("region"), field("postcode"));
642            let country = field("country");
643            // "City, Region Postcode" — the common Western single-line form;
644            // anything locale-specific belongs in a preformatted string address.
645            let tail = [&region, &postcode]
646                .into_iter()
647                .flatten()
648                .map(String::as_str)
649                .collect::<Vec<_>>()
650                .join(" ");
651            let locality = match (&city, tail.is_empty()) {
652                (Some(c), true) => Some(c.clone()),
653                (Some(c), false) => Some(format!("{c}, {tail}")),
654                (None, false) => Some(tail),
655                (None, true) => None,
656            };
657            let lines: Vec<String> = street
658                .iter()
659                .flat_map(|s| s.lines().map(str::trim).filter(|l| !l.is_empty()))
660                .map(str::to_string)
661                .chain(locality)
662                .chain(country.clone())
663                .collect();
664            if lines.is_empty() {
665                problems.push(ContactProblem {
666                    field: "addresses".to_string(),
667                    message: format!(
668                        "address{} has none of street, city, region, postcode or country; ignored",
669                        label
670                            .as_deref()
671                            .map(|l| format!(" `{l}`"))
672                            .unwrap_or_default()
673                    ),
674                });
675                return None;
676            }
677            Some(Address {
678                label,
679                label_display,
680                street,
681                city,
682                region,
683                postcode,
684                country,
685                country_code: field("country_code"),
686                lines,
687            })
688        }
689        other => {
690            let text = scalar_text(other)?;
691            Some(Address {
692                label,
693                label_display,
694                lines: text
695                    .lines()
696                    .map(str::trim)
697                    .filter(|l| !l.is_empty())
698                    .map(str::to_string)
699                    .collect(),
700                ..Address::default()
701            })
702        }
703    }
704}
705
706/// Parses an `aliases` value: a bare string, a list of strings and one-key
707/// `label: name` maps (`{label, name}` objects tolerated), or a map of
708/// label → name. Malformed entries are skipped and, when `problems` is given,
709/// reported.
710fn parse_aliases(v: Option<&Value>, mut problems: Option<&mut Vec<ContactProblem>>) -> Vec<Alias> {
711    let mut sink = Vec::new();
712    let items = labeled_values(ALIASES_KEY, v, &mut sink);
713    if let Some(p) = problems.as_deref_mut() {
714        p.extend(sink);
715    }
716    items
717        .into_iter()
718        .filter_map(|(label, value)| match scalar_text(&value) {
719            Some(name) => Some(Alias { label, name }),
720            None => {
721                if let Some(p) = problems.as_deref_mut() {
722                    p.push(ContactProblem {
723                        field: ALIASES_KEY.to_string(),
724                        message: format!(
725                            "alias{} is not a plain name; ignored",
726                            label.map(|l| format!(" `{l}`")).unwrap_or_default()
727                        ),
728                    });
729                }
730                None
731            }
732        })
733        .collect()
734}
735
736/// Every name in an `aliases` value, labels dropped, authored order, deduped.
737///
738/// The single definition shared by the frontmatter simplifier and therefore by
739/// every name-resolution consumer. Accepts `aliases: Bob` as `["Bob"]`.
740pub fn alias_names(v: Option<&Value>) -> Vec<String> {
741    let mut seen = std::collections::HashSet::new();
742    parse_aliases(v, None)
743        .into_iter()
744        .map(|a| a.name)
745        .filter(|n| seen.insert(n.clone()))
746        .collect()
747}
748
749/// Alias names read from a (simplified) frontmatter map.
750pub fn alias_names_in(frontmatter: &SimpleMetadata) -> Vec<String> {
751    alias_names(frontmatter.get(ALIASES_KEY))
752}
753
754/// Short, readable phrases for the card: all unlabeled names as one "aka"
755/// phrase, then one phrase per labeled alias.
756fn alias_phrases(aliases: &[Alias]) -> Vec<String> {
757    let unlabeled: Vec<&str> = aliases
758        .iter()
759        .filter(|a| a.label.is_none())
760        .map(|a| a.name.as_str())
761        .collect();
762    let aka = (!unlabeled.is_empty()).then(|| format!("aka {}", unlabeled.join(", ")));
763    let labeled = aliases.iter().filter_map(|a| {
764        let label = a.label.as_deref()?;
765        Some(match label.to_lowercase().replace('-', "_").as_str() {
766            "maiden_name" | "birth_name" | "nee" | "née" => format!("née {}", a.name),
767            "nickname" => format!("Nickname: {}", a.name),
768            "formerly" | "former_name" | "previous_name" => format!("formerly {}", a.name),
769            _ => format!("{}: {}", humanize_label(label), a.name),
770        })
771    });
772    aka.into_iter().chain(labeled).collect()
773}
774
775/// Parses `dates` plus the legacy `born`/`died`, in display order.
776///
777/// `dates.birthday` wins over `born` (and `death` over `died`) when both are
778/// present; the legacy keys are otherwise read as those labels.
779fn parse_dates(
780    fm: &Map<String, Value>,
781    problems: Option<&mut Vec<ContactProblem>>,
782) -> Vec<LabeledDate> {
783    let mut sink = Vec::new();
784    let authored: Vec<LabeledDate> = labeled_values("dates", fm.get("dates"), &mut sink)
785        .into_iter()
786        .filter_map(|(label, value)| {
787            let raw = scalar_text(&value);
788            match (label, raw) {
789                (Some(label), Some(raw)) => Some(labeled_date(label, raw)),
790                (label, _) => {
791                    sink.push(ContactProblem {
792                        field: "dates".to_string(),
793                        message: match label {
794                            Some(l) => format!("date `{l}` is not a single value; ignored"),
795                            None => {
796                                "a date needs a label (`birthday: 1927-03-19`); ignored".to_string()
797                            }
798                        },
799                    });
800                    None
801                }
802            }
803        })
804        .collect();
805    if let Some(p) = problems {
806        p.extend(sink);
807    }
808
809    let has = |label: &str| authored.iter().any(|d| d.label.eq_ignore_ascii_case(label));
810    let legacy = [("born", "birthday"), ("died", "death")]
811        .into_iter()
812        .filter(|(_, label)| !has(label))
813        .filter_map(|(key, label)| {
814            let raw = fm.get(key).and_then(scalar_text)?;
815            Some(labeled_date(label.to_string(), raw))
816        })
817        .collect::<Vec<_>>();
818
819    let rank = |d: &LabeledDate| {
820        WELL_KNOWN_DATES
821            .iter()
822            .position(|w| d.label.eq_ignore_ascii_case(w))
823            .unwrap_or(WELL_KNOWN_DATES.len())
824    };
825    let mut all: Vec<LabeledDate> = authored.into_iter().chain(legacy).collect();
826    // Stable: authored order survives within the "other labels" bucket.
827    all.sort_by_key(rank);
828    all
829}
830
831fn labeled_date(label: String, raw: String) -> LabeledDate {
832    let date = PartialDate::parse(&raw);
833    LabeledDate {
834        label_display: humanize_label(&label),
835        value: date.map(|d| d.iso()).unwrap_or_else(|| raw.clone()),
836        display: date.map(|d| d.display()).unwrap_or_else(|| raw.clone()),
837        datetime: date.map(|d| d.html_datetime()),
838        place: None,
839        label,
840        raw,
841        date,
842    }
843}
844
845/// Hangs `born_place`/`died_place` on the birthday/death dates. A place with
846/// no matching date is returned as a standalone entry ("Born: Boulder, CO").
847fn attach_places(
848    dates: &mut [LabeledDate],
849    born_place: Option<&String>,
850    died_place: Option<&String>,
851) -> Vec<Labeled> {
852    [
853        ("birthday", born_place, "Born"),
854        ("death", died_place, "Died"),
855    ]
856    .into_iter()
857    .filter_map(|(label, place, shown)| {
858        let place = place?;
859        match dates
860            .iter_mut()
861            .find(|d| d.label.eq_ignore_ascii_case(label))
862        {
863            Some(date) => {
864                date.place = Some(place.clone());
865                None
866            }
867            None => Some(Labeled {
868                label: Some(shown.to_lowercase()),
869                label_display: Some(shown.to_string()),
870                value: place.clone(),
871                href: None,
872            }),
873        }
874    })
875    .collect()
876}
877
878/// Display form of a free-text label: `home_fax` → "Home fax", well-known
879/// service names in their usual spelling (`linkedin` → "LinkedIn"). A label
880/// that already has capitals is the author's chosen spelling and only has its
881/// separators replaced.
882pub fn humanize_label(label: &str) -> String {
883    const SERVICES: &[(&str, &str)] = &[
884        ("linkedin", "LinkedIn"),
885        ("github", "GitHub"),
886        ("gitlab", "GitLab"),
887        ("youtube", "YouTube"),
888        ("tiktok", "TikTok"),
889        ("whatsapp", "WhatsApp"),
890        ("bluesky", "Bluesky"),
891        ("x", "X"),
892        ("imessage", "iMessage"),
893        ("facetime", "FaceTime"),
894    ];
895    let spaced = label.trim().replace(['_', '-'], " ");
896    let lower = spaced.to_lowercase();
897    if let Some((_, proper)) = SERVICES.iter().find(|(k, _)| *k == lower) {
898        return (*proper).to_string();
899    }
900    if spaced.chars().any(char::is_uppercase) {
901        return spaced;
902    }
903    let mut chars = spaced.chars();
904    match chars.next() {
905        Some(first) => first.to_uppercase().chain(chars).collect(),
906        None => String::new(),
907    }
908}
909
910/// A safe link for a value of the given kind, or `None`.
911///
912/// The scheme is always chosen *here*, never taken from the value — except for
913/// web values, which are accepted only when they already start with
914/// `http://`/`https://` (or `www.`, which gets `https://`). That is the whole
915/// defence against `javascript:` and friends, so keep it an allowlist.
916fn href_for(kind: HrefKind, value: &str) -> Option<String> {
917    let v = value.trim();
918    match kind {
919        HrefKind::Email => (v.contains('@')
920            && !v.contains(char::is_whitespace)
921            && !v.contains([':', '/', '?', '#', '<', '>', '"']))
922        .then(|| format!("mailto:{v}")),
923        HrefKind::Phone => tel_href(v),
924        HrefKind::Web => {
925            let lower = v.to_ascii_lowercase();
926            if v.contains(char::is_whitespace) {
927                None
928            } else if lower.starts_with("https://") || lower.starts_with("http://") {
929                Some(v.to_string())
930            } else if lower.starts_with("www.") {
931                Some(format!("https://{v}"))
932            } else {
933                None
934            }
935        }
936    }
937}
938
939/// A `tel:` URI for a phone number as people write one, or `None` when the
940/// value is not a number.
941///
942/// Spaces, dots, parentheses and dashes are layout and are dropped; a leading
943/// `+`, the digits, and the dial-string pause/wait characters `,` `;` `p` `w`
944/// are kept. A trailing extension written `ext. 12`, `extension 12` or `x12`
945/// becomes the RFC 3966 `;ext=12` parameter rather than being glued onto the
946/// number. Any other letter means this is not a dialable number ("ask for
947/// Pam"), so no link is made — the card still shows the text as authored.
948fn tel_href(value: &str) -> Option<String> {
949    let lower = value.trim().to_ascii_lowercase();
950    let (number, extension) = split_extension(&lower);
951    let mut out = String::with_capacity(number.len());
952    let mut digits = 0usize;
953    for (i, c) in number.chars().enumerate() {
954        match c {
955            '+' if i == 0 => out.push('+'),
956            '0'..='9' => {
957                digits += 1;
958                out.push(c);
959            }
960            ',' | ';' | 'p' | 'w' if digits > 0 => out.push(c),
961            ' ' | '.' | '(' | ')' | '-' | '\u{a0}' => {}
962            _ => return None,
963        }
964    }
965    let ext = match extension {
966        Some(ext) if !ext.is_empty() && ext.bytes().all(|b| b.is_ascii_digit()) => {
967            format!(";ext={ext}")
968        }
969        Some(_) => return None,
970        None => String::new(),
971    };
972    (digits >= 3).then(|| format!("tel:{out}{ext}"))
973}
974
975/// Splits `"303 555 0100 ext. 12"` into the number and `"12"`. Recognises
976/// `extension`, `ext`, `ext.` and `x`, each optionally followed by spaces.
977fn split_extension(lower: &str) -> (&str, Option<String>) {
978    ["extension", "ext.", "ext", "x"]
979        .iter()
980        .find_map(|marker| {
981            let at = lower.rfind(marker)?;
982            let rest: String = lower[at + marker.len()..]
983                .chars()
984                .filter(|c| !c.is_whitespace())
985                .collect();
986            Some((lower[..at].trim_end(), Some(rest)))
987        })
988        .unwrap_or((lower, None))
989}
990
991/// The frontmatter simplifier's contact hook. Called once per note on the
992/// simplified map, with the raw YAML it came from:
993///
994/// - A string `type` is trimmed for **every** note (and dropped when blank),
995///   because it gates features by exact comparison in places that cannot all
996///   be made to trim — `{% if type == "person" %}` in templates (including a
997///   repository's own overrides), `site.json` readers in the browser, the
998///   search `type:` facet. `type: " person"` would otherwise get the contact
999///   card (Rust trims) but not the charts (templates do not).
1000/// - `aliases` becomes a flat array of names (labels dropped) for **every**
1001///   note, so labeled aliases resolve wikilinks and relationship endpoints.
1002/// - On person/organization notes, every date — `dates` in any accepted shape,
1003///   plus legacy `born`/`died` — is written as a `dates.<label>` string
1004///   ([`PartialDate::iso`] when parseable), the label **lowercased**. This is
1005///   the `site.json` contract the charts read; `born`/`died` stay as authored
1006///   for existing templates.
1007///
1008/// Costs nothing on a note with neither key.
1009pub fn normalize_simplified(hm: &mut SimpleMetadata, hash: &yaml_rust2::yaml::Hash) {
1010    let key = |k: &str| Yaml::String(k.to_string());
1011    normalize_type(hm);
1012    if let Some(aliases) = hash.get(&key(ALIASES_KEY)) {
1013        let json = crate::relationships::yaml_to_json(aliases);
1014        let names = alias_names(Some(&json))
1015            .into_iter()
1016            .map(Value::String)
1017            .collect();
1018        hm.insert(ALIASES_KEY.to_string(), Value::Array(names));
1019    }
1020
1021    let is_contact = hash
1022        .get(&key("type"))
1023        .and_then(Yaml::as_str)
1024        .and_then(ContactKind::from_type)
1025        .is_some();
1026    if !is_contact {
1027        return;
1028    }
1029    let date_keys = ["dates", "born", "died"];
1030    if !date_keys.iter().any(|k| hash.contains_key(&key(k))) {
1031        return;
1032    }
1033    let fm: Map<String, Value> = date_keys
1034        .iter()
1035        .filter_map(|k| {
1036            hash.get(&key(k))
1037                .map(|v| (k.to_string(), crate::relationships::yaml_to_json(v)))
1038        })
1039        .collect();
1040    for date in parse_dates(&fm, None) {
1041        // Lowercase, because every reader of a label matches it
1042        // case-insensitively (`has` and `rank` above) except a key lookup: the
1043        // charts read exactly `dates.birthday`. The generic simplifier has
1044        // already flattened a `dates:` map under the authored case, so that
1045        // copy goes, leaving one canonical key per date.
1046        let canonical = date.label.to_lowercase();
1047        if canonical != date.label {
1048            hm.remove(&format!("dates.{}", date.label));
1049        }
1050        hm.insert(format!("dates.{canonical}"), Value::String(date.value));
1051    }
1052}
1053
1054/// Trims a string `type` in place, removing it when nothing is left. Any other
1055/// shape (a list, a number) is left as authored.
1056fn normalize_type(hm: &mut SimpleMetadata) {
1057    let Some(Value::String(raw)) = hm.get("type") else {
1058        return;
1059    };
1060    let trimmed = raw.trim();
1061    if trimmed.len() == raw.len() {
1062        return;
1063    }
1064    if trimmed.is_empty() {
1065        hm.remove("type");
1066    } else {
1067        let trimmed = Value::String(trimmed.to_string());
1068        hm.insert("type".to_string(), trimmed);
1069    }
1070}
1071
1072/// The frontmatter as published in `site.json`: everything except the
1073/// [`PRIVATE_DETAIL_KEYS`], including their flattened `emails.work`-style
1074/// variants.
1075///
1076/// Applied at serialization time rather than in the simplifier on purpose: the
1077/// in-memory map still holds them, so server-side search can match a phone
1078/// number, while the file every page view downloads does not.
1079///
1080/// The root is compared **case-insensitively**: YAML keys are case-sensitive,
1081/// so `Emails:` or `PHONES.work` is a different key to the card reader, but
1082/// it is the same private data to a reader of `site.json`. Erring towards
1083/// withholding is the only safe direction for a privacy filter.
1084pub fn is_public_frontmatter_key(key: &str) -> bool {
1085    let root = key.split_once('.').map_or(key, |(root, _)| root);
1086    !PRIVATE_DETAIL_KEYS
1087        .iter()
1088        .any(|private| private.eq_ignore_ascii_case(root))
1089}
1090
1091/// `serialize_with` adapter for `MarkdownInfo::frontmatter`: see
1092/// [`is_public_frontmatter_key`].
1093pub fn serialize_public_frontmatter<S: serde::Serializer>(
1094    frontmatter: &Option<SimpleMetadata>,
1095    serializer: S,
1096) -> Result<S::Ok, S::Error> {
1097    use serde::ser::SerializeMap;
1098    match frontmatter {
1099        None => serializer.serialize_none(),
1100        Some(fm) => {
1101            let public = fm.iter().filter(|(k, _)| is_public_frontmatter_key(k));
1102            let mut map = serializer.serialize_map(None)?;
1103            for (k, v) in public {
1104                map.serialize_entry(k, v)?;
1105            }
1106            map.end()
1107        }
1108    }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use proptest::prelude::*;
1115    use serde_json::json;
1116    use yaml_rust2::YamlLoader;
1117
1118    fn yaml(s: &str) -> Yaml {
1119        YamlLoader::load_from_str(s)
1120            .unwrap()
1121            .into_iter()
1122            .next()
1123            .unwrap()
1124    }
1125
1126    fn contact(s: &str) -> Contact {
1127        Contact::from_yaml(&yaml(s)).expect("a contact")
1128    }
1129
1130    // ----- PartialDate -----
1131
1132    #[test]
1133    fn partial_date_forms() {
1134        use PartialDate::*;
1135        assert_eq!(
1136            PartialDate::parse("1927-03-19"),
1137            Some(Full {
1138                year: 1927,
1139                month: 3,
1140                day: 19
1141            })
1142        );
1143        assert_eq!(
1144            PartialDate::parse("1927-03"),
1145            Some(YearMonth {
1146                year: 1927,
1147                month: 3
1148            })
1149        );
1150        assert_eq!(PartialDate::parse("1898"), Some(Year { year: 1898 }));
1151        assert_eq!(
1152            PartialDate::parse("03-19"),
1153            Some(MonthDay { month: 3, day: 19 })
1154        );
1155        assert_eq!(
1156            PartialDate::parse("--03-19"),
1157            Some(MonthDay { month: 3, day: 19 })
1158        );
1159        assert_eq!(
1160            PartialDate::parse("--02-29"),
1161            Some(MonthDay { month: 2, day: 29 })
1162        );
1163    }
1164
1165    #[test]
1166    fn partial_date_rejects_garbage() {
1167        for bad in [
1168            "",
1169            "circa 1855",
1170            "2020-13-40",
1171            "2020-00-10",
1172            "2020-02-32",
1173            "2020-02-30",
1174            "04-31",
1175            "1855-1-1",
1176            "55-10-30",
1177            "--3-19",
1178            "13-01",
1179            "1927-03-19-01",
1180        ] {
1181            assert_eq!(PartialDate::parse(bad), None, "{bad:?}");
1182        }
1183    }
1184
1185    #[test]
1186    fn partial_date_display_and_iso() {
1187        let show = |s: &str| {
1188            let d = PartialDate::parse(s).unwrap();
1189            (d.display(), d.iso())
1190        };
1191        assert_eq!(
1192            show("1927-03-09"),
1193            ("March 9, 1927".into(), "1927-03-09".into())
1194        );
1195        assert_eq!(show("1927-03"), ("March 1927".into(), "1927-03".into()));
1196        assert_eq!(show("1898"), ("1898".into(), "1898".into()));
1197        assert_eq!(show("03-19"), ("March 19".into(), "--03-19".into()));
1198        assert_eq!(humanize_date("circa 1855"), "circa 1855");
1199        assert_eq!(humanize_date("--12-25"), "December 25");
1200    }
1201
1202    // ----- headline -----
1203
1204    #[test]
1205    fn display_name_prefers_title_then_parts_then_company() {
1206        assert_eq!(
1207            contact("type: person\ntitle: Jane\nfirst_name: J\n").display_name,
1208            Some("Jane".into())
1209        );
1210        let parts = contact(
1211            "type: person\nprefix: Dr.\nfirst_name: Jane\nmiddle_name: Q.\nlast_name: Doe\nsuffix: Jr.\n",
1212        );
1213        assert_eq!(parts.display_name.as_deref(), Some("Dr. Jane Q. Doe Jr."));
1214        assert_eq!(
1215            contact("type: organization\ncompany: Acme Corp\n").display_name,
1216            Some("Acme Corp".into())
1217        );
1218        assert_eq!(contact("type: person\n").display_name, None);
1219    }
1220
1221    #[test]
1222    fn non_contacts_are_none() {
1223        assert!(Contact::from_yaml(&yaml("type: character\ntitle: Gandalf\n")).is_none());
1224        assert!(Contact::from_yaml(&yaml("title: x\n")).is_none());
1225        assert!(Contact::from_yaml(&yaml("- a\n")).is_none());
1226        assert!(Contact::from_yaml(&yaml("type: Person\n")).is_none());
1227    }
1228
1229    // ----- company -----
1230
1231    #[test]
1232    fn company_plain_and_wikilink() {
1233        let plain = contact("type: person\ncompany: Acme\n").company.unwrap();
1234        assert!(!plain.is_link);
1235        assert_eq!(plain.name, "Acme");
1236
1237        let link = contact("type: person\ncompany: \"[[Acme Corp|Acme]]\"\n")
1238            .company
1239            .unwrap();
1240        assert!(link.is_link);
1241        assert_eq!(link.name, "Acme");
1242        assert_eq!(link.raw, "[[Acme Corp|Acme]]");
1243    }
1244
1245    #[test]
1246    fn resolve_company_reads_the_implied_employer_edge() {
1247        use crate::relationships::{Direction, ResolvedRelationship};
1248        let mut c = contact("type: person\ncompany: \"[[Acme Corp]]\"\n");
1249        let edge = |rel_type: &str, implied: bool, resolved: bool| ResolvedRelationship {
1250            rel_type: rel_type.into(),
1251            predicate: rel_type.into(),
1252            neighbor: if resolved {
1253                "/orgs/acme/".into()
1254            } else {
1255                String::new()
1256            },
1257            neighbor_title: "Acme Corp".into(),
1258            neighbor_raw: "[[Acme Corp]]".into(),
1259            resolved,
1260            direction: Direction::Outgoing,
1261            label: None,
1262            attributes: Default::default(),
1263            derived: true,
1264            implied,
1265        };
1266        c.resolve_company(&[edge("spouse", true, true)]);
1267        assert_eq!(c.company.as_ref().unwrap().url, None);
1268        c.resolve_company(&[edge("employer", true, false)]);
1269        assert_eq!(c.company.as_ref().unwrap().url, None);
1270        // An `employer` edge the note declared itself, not via `company`.
1271        c.resolve_company(&[edge("employer", false, true)]);
1272        assert_eq!(c.company.as_ref().unwrap().url, None);
1273        c.resolve_company(&[edge("employer", true, true)]);
1274        assert_eq!(
1275            c.company.as_ref().unwrap().url.as_deref(),
1276            Some("/orgs/acme/")
1277        );
1278    }
1279
1280    // ----- aliases -----
1281
1282    #[test]
1283    fn mixed_aliases_list() {
1284        let c = contact(
1285            "type: person\naliases:\n  - Mare\n  - maiden_name: Mary Smith\n  - nickname: Mimi\n  - {label: married_name, name: Mary Doe}\n  - {a: 1, b: 2}\n  - [nested]\n",
1286        );
1287        let names: Vec<_> = c.aliases.iter().map(|a| a.name.as_str()).collect();
1288        assert_eq!(names, ["Mare", "Mary Smith", "Mimi", "Mary Doe"]);
1289        assert_eq!(c.aliases[1].label.as_deref(), Some("maiden_name"));
1290        assert_eq!(
1291            c.alias_phrases,
1292            [
1293                "aka Mare",
1294                "née Mary Smith",
1295                "Nickname: Mimi",
1296                "Married name: Mary Doe"
1297            ]
1298        );
1299        assert_eq!(c.problems.len(), 2, "{:?}", c.problems);
1300        assert!(c.problems.iter().all(|p| p.field == "aliases"));
1301    }
1302
1303    #[test]
1304    fn alias_names_shapes() {
1305        assert_eq!(alias_names(Some(&json!("Bob"))), ["Bob"]);
1306        assert_eq!(alias_names(Some(&json!(["A", "A", " ", "B"]))), ["A", "B"]);
1307        assert_eq!(
1308            alias_names(Some(&json!(["A", {"nickname": "N"}, {"x": 1, "y": 2}]))),
1309            ["A", "N"]
1310        );
1311        assert_eq!(alias_names(Some(&json!({"nickname": "N"}))), ["N"]);
1312        assert!(alias_names(None).is_empty());
1313        assert!(alias_names(Some(&json!(null))).is_empty());
1314    }
1315
1316    // ----- labeled values -----
1317
1318    #[test]
1319    fn labeled_map_list_and_bare_forms() {
1320        let c = contact(
1321            "type: person\nemails:\n  work: jane@abc.com\n  home: jane@gmail.com\nphones:\n  - mobile: \"+1 303 555 0100\"\n  - home: \"+1 303 555 0101\"\n  - \"+1 303 555 0199\"\nurls: https://example.com\n",
1322        );
1323        let emails: Vec<_> = c
1324            .emails
1325            .iter()
1326            .map(|e| (e.label.as_deref(), e.value.as_str(), e.href.as_deref()))
1327            .collect();
1328        assert_eq!(
1329            emails,
1330            [
1331                (Some("work"), "jane@abc.com", Some("mailto:jane@abc.com")),
1332                (
1333                    Some("home"),
1334                    "jane@gmail.com",
1335                    Some("mailto:jane@gmail.com")
1336                ),
1337            ]
1338        );
1339        assert_eq!(c.phones.len(), 3);
1340        assert_eq!(c.phones[0].href.as_deref(), Some("tel:+13035550100"));
1341        assert_eq!(c.phones[2].label, None);
1342        assert_eq!(c.urls[0].href.as_deref(), Some("https://example.com"));
1343        assert!(c.problems.is_empty(), "{:?}", c.problems);
1344    }
1345
1346    #[test]
1347    fn labeled_tolerated_object_form_and_duplicates() {
1348        let c = contact(
1349            "type: person\nphones:\n  - {label: work, value: '555 0100'}\n  - work: '555 0101'\n",
1350        );
1351        assert_eq!(c.phones.len(), 2);
1352        assert!(c.phones.iter().all(|p| p.label.as_deref() == Some("work")));
1353    }
1354
1355    #[test]
1356    fn labeled_bad_entries_are_reported_not_fatal() {
1357        let c = contact("type: person\nemails:\n  - {a: x, b: y}\n  - ok@example.com\n");
1358        assert_eq!(c.emails.len(), 1);
1359        assert_eq!(c.problems.len(), 1);
1360        assert_eq!(c.problems[0].field, "emails");
1361    }
1362
1363    #[test]
1364    fn label_display() {
1365        assert_eq!(humanize_label("home_fax"), "Home fax");
1366        assert_eq!(humanize_label("linkedin"), "LinkedIn");
1367        assert_eq!(humanize_label("first-met"), "First met");
1368        assert_eq!(humanize_label("My Label"), "My Label");
1369    }
1370
1371    #[test]
1372    fn hrefs_are_an_allowlist() {
1373        let c = contact(
1374            "type: person\nurls:\n  evil: \"javascript:alert(1)\"\n  data: \"data:text/html,x\"\n  ok: \"HTTPS://Example.com/x\"\n  www: www.example.com\n  bare: example.com\nemails:\n  bad: \"javascript:alert(1)@x\"\n  worse: \"a@b?subject=x\"\nphones:\n  - \"call me\"\n",
1375        );
1376        let hrefs: Vec<_> = c.urls.iter().map(|u| u.href.as_deref()).collect();
1377        assert_eq!(
1378            hrefs,
1379            [
1380                None,
1381                None,
1382                Some("HTTPS://Example.com/x"),
1383                Some("https://www.example.com"),
1384                None
1385            ]
1386        );
1387        assert!(c.emails.iter().all(|e| e.href.is_none()));
1388        assert_eq!(c.phones[0].href, None);
1389    }
1390
1391    #[test]
1392    fn tel_hrefs_strip_layout_and_keep_dial_characters() {
1393        let cases = [
1394            ("+1 (303) 555-0100 ext. 12", Some("tel:+13035550100;ext=12")),
1395            ("+1 303 555 0100", Some("tel:+13035550100")),
1396            ("303.555.0100", Some("tel:3035550100")),
1397            ("303-555-0100,,42", Some("tel:3035550100,,42")),
1398            ("555 0100;123", Some("tel:5550100;123")),
1399            ("555 0100p12", Some("tel:5550100p12")),
1400            ("555 0100 W 9", Some("tel:5550100w9")),
1401            ("555 0100 x7", Some("tel:5550100;ext=7")),
1402            ("555 0100 Extension 7", Some("tel:5550100;ext=7")),
1403            ("Ask for Pam 555 0100", None),
1404            ("call me", None),
1405            ("12", None),
1406            ("555 0100 ext. twelve", None),
1407            ("javascript:alert(1)", None),
1408        ];
1409        for (input, want) in cases {
1410            assert_eq!(tel_href(input).as_deref(), want, "{input:?}");
1411        }
1412        // The display text is never rewritten.
1413        let c = contact("type: person\nphones:\n  work: \"+1 (303) 555-0100 ext. 12\"\n");
1414        assert_eq!(c.phones[0].value, "+1 (303) 555-0100 ext. 12");
1415        assert_eq!(c.phones[0].href.as_deref(), Some("tel:+13035550100;ext=12"));
1416    }
1417
1418    #[test]
1419    fn social_handles_stay_text_and_urls_link() {
1420        let c = contact(
1421            "type: person\nsocial:\n  twitter: \"@jdoe\"\n  mastodon: https://example.social/@jd\nurls:\n  homepage: \"javascript:alert(document.cookie)\"\nemails:\n  work: jane@abc.example\n",
1422        );
1423        assert_eq!(c.social[0].value, "@jdoe");
1424        assert_eq!(c.social[0].href, None, "no guessed URL templates");
1425        assert_eq!(
1426            c.social[1].href.as_deref(),
1427            Some("https://example.social/@jd")
1428        );
1429        assert_eq!(c.urls[0].href, None);
1430        assert_eq!(c.emails[0].href.as_deref(), Some("mailto:jane@abc.example"));
1431    }
1432
1433    // ----- addresses -----
1434
1435    #[test]
1436    fn addresses_structured_string_and_list() {
1437        let c = contact(
1438            "type: person\naddresses:\n  home:\n    street: \"52 Rue Bonsergent\\nBâtiment B\"\n    city: Paris\n    region: Île-de-France\n    postcode: \"75010\"\n    country: France\n    country_code: FR\n  work: \"1 Main St\\nSpringfield\"\n",
1439        );
1440        assert_eq!(c.addresses.len(), 2);
1441        assert_eq!(
1442            c.addresses[0].lines,
1443            [
1444                "52 Rue Bonsergent",
1445                "Bâtiment B",
1446                "Paris, Île-de-France 75010",
1447                "France"
1448            ]
1449        );
1450        assert_eq!(c.addresses[0].country_code.as_deref(), Some("FR"));
1451        assert_eq!(c.addresses[1].lines, ["1 Main St", "Springfield"]);
1452        assert_eq!(c.addresses[1].label_display.as_deref(), Some("Work"));
1453
1454        let listed = contact(
1455            "type: person\naddresses:\n  - label: home\n    city: Paris\n  - street: 9 Elm\n",
1456        );
1457        assert_eq!(listed.addresses[0].label.as_deref(), Some("home"));
1458        assert_eq!(listed.addresses[0].lines, ["Paris"]);
1459        assert_eq!(listed.addresses[1].label, None);
1460        assert_eq!(listed.addresses[1].lines, ["9 Elm"]);
1461
1462        let single = contact("type: person\naddresses:\n  city: Paris\n");
1463        assert_eq!(single.addresses.len(), 1);
1464        assert_eq!(single.addresses[0].label, None);
1465    }
1466
1467    // ----- dates -----
1468
1469    #[test]
1470    fn dates_order_and_legacy_merge() {
1471        let c = contact(
1472            "type: person\nborn: 1900-01-01\ndied: 2010-08-05\ndates:\n  first_met: 2019-04-02\n  anniversary: 1950-06-10\n  birthday: 1927-03-19\n",
1473        );
1474        let got: Vec<_> = c
1475            .dates
1476            .iter()
1477            .map(|d| (d.label.as_str(), d.value.as_str()))
1478            .collect();
1479        assert_eq!(
1480            got,
1481            [
1482                ("birthday", "1927-03-19"),
1483                ("death", "2010-08-05"),
1484                ("anniversary", "1950-06-10"),
1485                ("first_met", "2019-04-02"),
1486            ]
1487        );
1488        assert_eq!(c.dates[3].label_display, "First met");
1489    }
1490
1491    #[test]
1492    fn dates_integer_year_and_prose() {
1493        let c = contact("type: person\nborn: 1898\ndates:\n  - death: circa 1960\n");
1494        let birthday = c.dates.iter().find(|d| d.label == "birthday").unwrap();
1495        assert_eq!(birthday.display, "1898");
1496        let death = c.dates.iter().find(|d| d.label == "death").unwrap();
1497        assert_eq!(death.display, "circa 1960");
1498        assert_eq!(death.date, None);
1499    }
1500
1501    #[test]
1502    fn dates_without_label_is_a_problem() {
1503        let c = contact("type: person\ndates:\n  - 1927-03-19\n");
1504        assert!(c.dates.is_empty());
1505        assert_eq!(c.problems[0].field, "dates");
1506    }
1507
1508    // ----- simplifier hook + site.json filter -----
1509
1510    fn simplified(src: &str) -> SimpleMetadata {
1511        let y = yaml(src);
1512        let mut hm = SimpleMetadata::new();
1513        normalize_simplified(&mut hm, y.as_hash().unwrap());
1514        hm
1515    }
1516
1517    #[test]
1518    fn normalize_trims_type_for_every_note() {
1519        let run = |v: Value| {
1520            let mut hm = SimpleMetadata::from([("type".to_string(), v)]);
1521            normalize_simplified(&mut hm, yaml("title: x\n").as_hash().unwrap());
1522            hm.get("type").cloned()
1523        };
1524        assert_eq!(run(json!(" person ")), Some(json!("person")));
1525        assert_eq!(run(json!("Meeting Notes\t")), Some(json!("Meeting Notes")));
1526        assert_eq!(run(json!("flashcard")), Some(json!("flashcard")));
1527        assert_eq!(run(json!("  ")), None, "blank type is no type");
1528        assert_eq!(run(json!(["a ", "b"])), Some(json!(["a ", "b"])));
1529    }
1530
1531    #[test]
1532    fn normalize_flattens_aliases_for_every_note() {
1533        let hm = simplified("aliases:\n  - A\n  - nickname: N\n");
1534        assert_eq!(hm["aliases"], json!(["A", "N"]));
1535        let scalar = simplified("aliases: Bob\n");
1536        assert_eq!(scalar["aliases"], json!(["Bob"]));
1537    }
1538
1539    #[test]
1540    fn normalize_dates_only_for_contacts() {
1541        let hm = simplified("type: person\nborn: 1898\ndates:\n  - anniversary: 03-19\n");
1542        assert_eq!(hm["dates.birthday"], json!("1898"));
1543        assert_eq!(hm["dates.anniversary"], json!("--03-19"));
1544        let other = simplified("type: event\nborn: 1898\n");
1545        assert!(other.is_empty());
1546    }
1547
1548    /// Labels match case-insensitively (`has`, `rank`), so the published key
1549    /// must not keep the authored case: the charts look up `dates.birthday`.
1550    #[test]
1551    fn normalize_writes_lowercase_date_keys() {
1552        let hm = simplified("type: person\nborn: 1950\ndates:\n  Birthday: 1960-01-02\n");
1553        assert_eq!(hm["dates.birthday"], json!("1960-01-02"));
1554        assert!(!hm.contains_key("dates.Birthday"), "{hm:?}");
1555
1556        let mut flattened = SimpleMetadata::new();
1557        // What the generic simplifier has already written by the time the hook
1558        // runs: the nested map flattened with the authored case.
1559        flattened.insert("dates.Birthday".to_string(), json!("1960-01-02"));
1560        let y = yaml("type: person\ndates:\n  Birthday: 1960-01-02\n");
1561        normalize_simplified(&mut flattened, y.as_hash().unwrap());
1562        assert_eq!(flattened.len(), 1, "{flattened:?}");
1563        assert_eq!(flattened["dates.birthday"], json!("1960-01-02"));
1564    }
1565
1566    #[test]
1567    fn mixed_case_birthday_beats_legacy_born_on_the_card() {
1568        let c = contact("type: person\nborn: 1950\ndates:\n  Birthday: 1960-01-02\n");
1569        let birthdays: Vec<_> = c
1570            .dates
1571            .iter()
1572            .filter(|d| d.label.eq_ignore_ascii_case("birthday"))
1573            .map(|d| d.value.as_str())
1574            .collect();
1575        assert_eq!(birthdays, ["1960-01-02"]);
1576    }
1577
1578    #[test]
1579    fn public_keys_drop_contact_details_and_their_dot_variants() {
1580        for private in [
1581            "emails",
1582            "phones",
1583            "urls.homepage",
1584            "social.linkedin",
1585            "im",
1586            "addresses.home.city",
1587        ] {
1588            assert!(!is_public_frontmatter_key(private), "{private}");
1589        }
1590        for public in [
1591            "title",
1592            "dates.birthday",
1593            "aliases",
1594            "company",
1595            "emailsx",
1596            "phone",
1597        ] {
1598            assert!(is_public_frontmatter_key(public), "{public}");
1599        }
1600    }
1601
1602    /// Regression: `Emails: x@y` used to be published in `site.json` because
1603    /// the root was compared case-sensitively.
1604    #[test]
1605    fn public_keys_drop_contact_details_in_any_case() {
1606        for private in [
1607            "Emails",
1608            "EMAILS",
1609            "eMaIlS",
1610            "Phones",
1611            "PHONES.work",
1612            "Urls.Homepage",
1613            "SOCIAL.linkedin",
1614            "IM",
1615            "Im.signal",
1616            "Addresses.home",
1617            "ADDRESSES.home.city",
1618        ] {
1619            assert!(!is_public_frontmatter_key(private), "{private}");
1620        }
1621        for public in ["Title", "Dates.birthday", "EmailsX", "PHONE", "Imx"] {
1622            assert!(is_public_frontmatter_key(public), "{public}");
1623        }
1624    }
1625
1626    /// End to end through the `site.json` serializer: no case variant of a
1627    /// private key, flat or dotted, survives.
1628    #[test]
1629    fn serialized_frontmatter_omits_private_keys_in_any_case() {
1630        #[derive(serde::Serialize)]
1631        struct Wrapper {
1632            #[serde(serialize_with = "serialize_public_frontmatter")]
1633            frontmatter: Option<SimpleMetadata>,
1634        }
1635        let mut fm = SimpleMetadata::new();
1636        for (k, v) in [
1637            ("title", "Ada"),
1638            ("Emails", "UPPERLEAK@acme.com"),
1639            ("PHONES", "+1 555 0100"),
1640            ("Addresses.home", "1 Leak Lane"),
1641            ("Urls.blog", "https://leak.example"),
1642        ] {
1643            fm.insert(k.to_string(), json!(v));
1644        }
1645        let out = serde_json::to_string(&Wrapper {
1646            frontmatter: Some(fm),
1647        })
1648        .unwrap();
1649        assert!(out.contains("\"title\":\"Ada\""), "{out}");
1650        for leaked in ["UPPERLEAK", "555", "Leak Lane", "leak.example"] {
1651            assert!(!out.contains(leaked), "{leaked} leaked: {out}");
1652        }
1653    }
1654
1655    // ----- properties -----
1656
1657    /// Arbitrary YAML-ish JSON, nested a few levels.
1658    fn arb_value() -> impl Strategy<Value = Value> {
1659        let leaf = prop_oneof![
1660            Just(Value::Null),
1661            any::<bool>().prop_map(Value::Bool),
1662            any::<i64>().prop_map(|n| json!(n)),
1663            ".{0,12}".prop_map(Value::String),
1664        ];
1665        leaf.prop_recursive(3, 24, 4, |inner| {
1666            prop_oneof![
1667                prop::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
1668                prop::collection::btree_map("[a-z_]{1,8}", inner, 0..4)
1669                    .prop_map(|m| Value::Object(m.into_iter().collect())),
1670            ]
1671        })
1672    }
1673
1674    const FIELDS: &[&str] = &[
1675        "title",
1676        "first_name",
1677        "company",
1678        "aliases",
1679        "emails",
1680        "phones",
1681        "urls",
1682        "social",
1683        "im",
1684        "addresses",
1685        "dates",
1686        "born",
1687        "died",
1688        "image",
1689    ];
1690
1691    proptest! {
1692        /// Whatever the frontmatter holds, parsing never panics and never
1693        /// yields an href outside the allowlisted schemes.
1694        #[test]
1695        fn never_panics_and_hrefs_stay_safe(values in prop::collection::vec(arb_value(), FIELDS.len())) {
1696            let mut fm: Map<String, Value> = FIELDS
1697                .iter()
1698                .zip(values)
1699                .map(|(k, v)| (k.to_string(), v))
1700                .collect();
1701            fm.insert("type".into(), json!("person"));
1702            let c = Contact::from_frontmatter(&fm).unwrap();
1703            for l in c.emails.iter().chain(&c.phones).chain(&c.urls).chain(&c.social).chain(&c.im) {
1704                if let Some(href) = &l.href {
1705                    let lower = href.to_ascii_lowercase();
1706                    prop_assert!(
1707                        ["mailto:", "tel:", "http://", "https://"].iter().any(|s| lower.starts_with(s)),
1708                        "unsafe href {href}"
1709                    );
1710                }
1711            }
1712            let _ = alias_names(fm.get("aliases"));
1713        }
1714
1715        /// The map form and the list-of-one-key-maps form read identically
1716        /// when labels are unique.
1717        #[test]
1718        fn map_and_list_forms_agree(entries in prop::collection::btree_map("[a-z]{1,6}", "[a-z0-9@.]{1,10}", 0..5)) {
1719            let map: Map<String, Value> = entries.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
1720            let list: Vec<Value> = entries.iter().map(|(k, v)| json!({ k.clone(): v })).collect();
1721            let as_map = Contact::from_frontmatter(&Map::from_iter([
1722                ("type".to_string(), json!("person")),
1723                ("emails".to_string(), Value::Object(map)),
1724            ])).unwrap();
1725            let as_list = Contact::from_frontmatter(&Map::from_iter([
1726                ("type".to_string(), json!("person")),
1727                ("emails".to_string(), Value::Array(list)),
1728            ])).unwrap();
1729            prop_assert_eq!(as_map.emails, as_list.emails);
1730        }
1731
1732        /// Every well-formed date survives the iso → parse round trip.
1733        #[test]
1734        fn date_iso_round_trips(year in 1000u16..=9999, month in 1u8..=12, day in 1u8..=28) {
1735            for d in [
1736                PartialDate::Full { year, month, day },
1737                PartialDate::YearMonth { year, month },
1738                PartialDate::Year { year },
1739                PartialDate::MonthDay { month, day },
1740            ] {
1741                prop_assert_eq!(PartialDate::parse(&d.iso()), Some(d));
1742            }
1743        }
1744    }
1745}