diff --git a/docs/file-format.md b/docs/file-format.md index 1b70d129..6312abc9 100644 --- a/docs/file-format.md +++ b/docs/file-format.md @@ -83,7 +83,7 @@ kinetics: publisher: American Physical Society ``` -This means that the article was published in issue 16, volume 102 of the journal "Physical Review B". Notice that the `title` field is in use for both the article and its parent - every field is available for both top-level use and all parents. +This means that the article was published in issue 16, volume 102 of the journal "Physical Review B". Notice that the `title` field is in use for both the article and its parent - every field is available for both top-level use and all parents. The `volume` and `issue` fields can be placed on either the entry or its parent container; when placed on the parent, they identify the specific volume and issue of the periodical in which the article appears. To specify parent information, write the `parent` field name and a colon and then put all fields for that parent on indented lines below. @@ -208,9 +208,9 @@ This section lists all possible fields and data types for them. | | | |------------------|-----------------------------------------------------------| -| **Data type:** | list of persons with role / list of lists of persons with role | -| **Description:** | persons involved with the item that do not fit `author` or `editor` | -| **Example:** |
affiliated:
- role: Director
names: Cameron, James
- role: CastMember
names: ["Schwarzenegger, Arnold", "Hamilton, Linda", "Patrick, Robert"]
| +| **Data type:** | persons with role / list of persons with role | +| **Description:** | persons involved with the item that do not fit `author` or `editor`. Can be specified as a single object or a list of objects. | +| **Example:** |
affiliated:
- role: Director
names: Cameron, James
- role: CastMember
names: ["Schwarzenegger, Arnold", "Hamilton, Linda", "Patrick, Robert"]
or
affiliated:
role: Director
names: Cameron, James
| #### `call-number` @@ -249,7 +249,7 @@ This section lists all possible fields and data types for them. | | | |------------------|-----------------------------------------------------------| | **Data type:** | numeric or string | -| **Description:** | For an item whose parent has multiple issues, indicates the position in the issue sequence. Also used to indicate the episode number for TV. | +| **Description:** | For an item whose parent has multiple issues, indicates the position in the issue sequence. Also used to indicate the episode number for TV. This field can be placed on either the entry or its parent container; when placed on the parent (e.g., a Periodical), it identifies the specific issue in which the entry appears. | | **Example:** | `issue: 5` | #### `volume` @@ -257,7 +257,7 @@ This section lists all possible fields and data types for them. | | | |------------------|-----------------------------------------------------------| | **Data type:** | numeric or string | -| **Description:** | For an item whose parent has multiple volumes/parts/seasons ... of which this item is one | +| **Description:** | For an item whose parent has multiple volumes/parts/seasons ... of which this item is one. This field can be placed on either the entry or its parent container; when placed on the parent (e.g., a Periodical), it identifies the specific volume in which the entry appears. | | **Example:** | `volume: 2-3` | #### `volume-total` diff --git a/src/lib.rs b/src/lib.rs index 47f6e9ae..ef06cb26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -532,9 +532,9 @@ entry! { /// The total number of pages the item has. "page-total" => page_total: Numeric, /// The time range within the parent this item starts and ends at. - "time-range" => time_range: MaybeTyped, + "time-range" => time_range: DurationRange, /// The total runtime of the item. - "runtime" => runtime: MaybeTyped, + "runtime" => runtime: Duration, /// Canonical public URL of the item, can have access date. "url" => url: QualifiedUrl, /// Any serial number or version describing the item that is not appropriate diff --git a/src/types/mod.rs b/src/types/mod.rs index c552bb4c..7ac8b0a4 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -137,7 +137,7 @@ use deserialize_from_str; use serialize_display; /// Describes which kind of work a database entry refers to. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[derive(Copy, Clone, Debug, Serialize, PartialEq, Eq, Hash)] #[non_exhaustive] #[serde(rename_all = "kebab-case")] pub enum EntryType { @@ -250,6 +250,68 @@ pub enum EntryType { Original, } +impl<'de> Deserialize<'de> for EntryType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, Visitor}; + use std::fmt; + + struct EntryTypeVisitor; + + impl<'de> Visitor<'de> for EntryTypeVisitor { + type Value = EntryType; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an entry type (case-insensitive)") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let lower = value.to_lowercase(); + match lower.as_str() { + "article" => Ok(EntryType::Article), + "chapter" => Ok(EntryType::Chapter), + "entry" => Ok(EntryType::Entry), + "anthos" => Ok(EntryType::Anthos), + "report" => Ok(EntryType::Report), + "thesis" => Ok(EntryType::Thesis), + "web" => Ok(EntryType::Web), + "scene" => Ok(EntryType::Scene), + "artwork" => Ok(EntryType::Artwork), + "patent" => Ok(EntryType::Patent), + "case" => Ok(EntryType::Case), + "newspaper" => Ok(EntryType::Newspaper), + "legislation" => Ok(EntryType::Legislation), + "manuscript" => Ok(EntryType::Manuscript), + "post" => Ok(EntryType::Post), + "misc" => Ok(EntryType::Misc), + "performance" => Ok(EntryType::Performance), + "periodical" => Ok(EntryType::Periodical), + "proceedings" => Ok(EntryType::Proceedings), + "book" => Ok(EntryType::Book), + "blog" => Ok(EntryType::Blog), + "reference" => Ok(EntryType::Reference), + "conference" => Ok(EntryType::Conference), + "anthology" => Ok(EntryType::Anthology), + "repository" => Ok(EntryType::Repository), + "thread" => Ok(EntryType::Thread), + "video" => Ok(EntryType::Video), + "audio" => Ok(EntryType::Audio), + "exhibition" => Ok(EntryType::Exhibition), + "original" => Ok(EntryType::Original), + _ => Err(E::custom(format!("unknown entry type: `{}`", value))), + } + } + } + + deserializer.deserialize_any(EntryTypeVisitor) + } +} + impl EntryType { /// Entry parents have implicit defaults. This function returns the default /// parent for this entry type. @@ -305,7 +367,7 @@ pub enum DeserializationError { } /// A type that may be a string or a strictly typed value. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Serialize, Eq, Hash)] #[serde(untagged)] pub enum MaybeTyped { /// The typed variant. @@ -382,6 +444,69 @@ impl From for MaybeTyped { } } +// Custom deserializer for MaybeTyped that allows fallback to String +impl<'de, T> Deserialize<'de> for MaybeTyped +where + T: Deserialize<'de> + FromStr, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, Visitor}; + use std::fmt; + + struct MaybeTypedVisitor(std::marker::PhantomData); + + impl<'de, T> Visitor<'de> for MaybeTypedVisitor + where + T: Deserialize<'de> + FromStr, + { + type Value = MaybeTyped; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a typed value or a string") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + match T::from_str(value) { + Ok(t) => Ok(MaybeTyped::Typed(t)), + Err(_) => Ok(MaybeTyped::String(value.to_owned())), + } + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(MaybeTyped::Typed) + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + T::deserialize(serde::de::value::I64Deserializer::new(value)) + .map(MaybeTyped::Typed) + } + + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + T::deserialize(serde::de::value::U64Deserializer::new(value)) + .map(MaybeTyped::Typed) + } + } + + deserializer.deserialize_any(MaybeTypedVisitor(std::marker::PhantomData)) + } +} + derive_or_from_str! { /// An URL, possibly with a last visited date. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/src/types/persons.rs b/src/types/persons.rs index a3cfa266..6af77a42 100644 --- a/src/types/persons.rs +++ b/src/types/persons.rs @@ -35,7 +35,7 @@ impl PersonsWithRoles { /// Specifies the role a group of persons had in the creation to the /// cited item. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, Serialize, PartialEq, Eq, Hash)] #[non_exhaustive] #[serde(rename_all = "kebab-case")] pub enum PersonRole { @@ -85,6 +85,58 @@ pub enum PersonRole { Unknown(String), } +impl<'de> Deserialize<'de> for PersonRole { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, Visitor}; + use std::fmt; + + struct PersonRoleVisitor; + + impl<'de> Visitor<'de> for PersonRoleVisitor { + type Value = PersonRole; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a person role (case-insensitive)") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let lower = value.to_lowercase(); + match lower.as_str() { + "translator" => Ok(PersonRole::Translator), + "afterword" => Ok(PersonRole::Afterword), + "foreword" => Ok(PersonRole::Foreword), + "introduction" => Ok(PersonRole::Introduction), + "annotator" => Ok(PersonRole::Annotator), + "commentator" => Ok(PersonRole::Commentator), + "holder" => Ok(PersonRole::Holder), + "compiler" => Ok(PersonRole::Compiler), + "founder" => Ok(PersonRole::Founder), + "collaborator" => Ok(PersonRole::Collaborator), + "organizer" => Ok(PersonRole::Organizer), + "cast-member" => Ok(PersonRole::CastMember), + "composer" => Ok(PersonRole::Composer), + "producer" => Ok(PersonRole::Producer), + "executive-producer" => Ok(PersonRole::ExecutiveProducer), + "writer" => Ok(PersonRole::Writer), + "cinematography" => Ok(PersonRole::Cinematography), + "director" => Ok(PersonRole::Director), + "illustrator" => Ok(PersonRole::Illustrator), + "narrator" => Ok(PersonRole::Narrator), + _ => Ok(PersonRole::Unknown(value.to_owned())), + } + } + } + + deserializer.deserialize_any(PersonRoleVisitor) + } +} + derive_or_from_str! { /// Holds the name of a person. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/src/types/time.rs b/src/types/time.rs index e1a6dce2..07ee20cb 100644 --- a/src/types/time.rs +++ b/src/types/time.rs @@ -433,6 +433,42 @@ impl Duration { return Err(DurationError::Malformed); } + // Validate that lower-order denominations are within bounds when higher-order + // denominations are specified. According to the spec: "The left-most time + // denomination only allows values that could overflow into the next-largest + // denomination if that is not specified." + // This means: + // - DD:HH:MM:SS: hours < 24, minutes < 60, seconds < 60 + // - HH:MM:SS: minutes < 60, seconds < 60 + // - MM:SS: seconds < 60 (minutes can be any value) + match start { + 0 => { + // DD:HH:MM:SS format - hours, minutes, and seconds must be in bounds + if hours >= 24 || minutes >= 60 || seconds >= 60 { + return Err(DurationError::TooLarge); + } + } + 1 => { + // HH:MM:SS format - minutes and seconds must be in bounds + if minutes >= 60 || seconds >= 60 { + return Err(DurationError::TooLarge); + } + } + 2 => { + // MM:SS format - only seconds must be in bounds (minutes can overflow) + if seconds >= 60 { + return Err(DurationError::TooLarge); + } + } + _ => unreachable!(), + } + + // Milliseconds must always be in bounds + if milliseconds >= 1000 { + return Err(DurationError::TooLarge); + } + + // Now normalize overflow values for i in (0..=start).rev() { match i { 0 => {} @@ -448,10 +484,6 @@ impl Duration { } } - if hours >= 24 || minutes >= 60 || seconds >= 60 || milliseconds >= 1000 { - return Err(DurationError::TooLarge); - } - Ok(Duration { days, hours, minutes, seconds, milliseconds }) } @@ -749,6 +781,15 @@ mod tests { ); assert!(Duration::from_str("01:00,").is_err()); assert!(Duration::from_str("010:00,").is_err()); + + // Test validation: 01:78:00 should be invalid (HH:MM:SS format, minutes >= 60) + assert!(Duration::from_str("01:78:00").is_err()); + + // Test validation: 138:00 should be valid (MM:SS format, minutes can overflow) + assert!(Duration::from_str("138:00").is_ok()); + + // Test validation: 00:78:00 should be invalid (HH:MM:SS format, minutes >= 60) + assert!(Duration::from_str("00:78:00").is_err()); } #[test] diff --git a/tests/data/basic.yml b/tests/data/basic.yml index 0697ad00..4083ea9a 100644 --- a/tests/data/basic.yml +++ b/tests/data/basic.yml @@ -39,7 +39,6 @@ omarova-libra: author: ["Omarova, Saule", "Steele, Graham"] title: value: There’s a Lot We Still Don’t Know About Libra - sentence-case: There’s a lot we still don’t know about Libra date: 2019-11-04 url: https://www.nytimes.com/2019/11/04/opinion/facebook-libra-cryptocurrency.html parent: @@ -100,7 +99,7 @@ science-e-issue: given-name: "Laurenz" alias: "laurmaedje" date: 2020-07-18 - parent: + parent: type: Repository title: Typst url: https://github.com/typst/typst @@ -110,7 +109,7 @@ terminator-2: title: "Terminator 2: Judgment Day" publisher: Carolco Pictures; Pacific Western Productions; Lightstorm Entertainment; Le Studio Canal+ S.A. affiliated: - - role: director + - role: Director names: Cameron, James - role: cast-member names: ["Schwarzenegger, Arnold", "Hamilton, Linda", "Patrick, Robert"]