Skip to content
Open
12 changes: 6 additions & 6 deletions docs/file-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:** | <pre>affiliated:<br> - role: Director<br> names: Cameron, James<br> - role: CastMember<br> names: ["Schwarzenegger, Arnold", "Hamilton, Linda", "Patrick, Robert"]<br></pre> |
| **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:** | <pre>affiliated:<br> - role: Director<br> names: Cameron, James<br> - role: CastMember<br> names: ["Schwarzenegger, Arnold", "Hamilton, Linda", "Patrick, Robert"]<br></pre> or <pre>affiliated:<br> role: Director<br> names: Cameron, James</pre> |

#### `call-number`

Expand Down Expand Up @@ -249,15 +249,15 @@ 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`

| | |
|------------------|-----------------------------------------------------------|
| **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`
Expand Down
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DurationRange>,
"time-range" => time_range: DurationRange,
/// The total runtime of the item.
"runtime" => runtime: MaybeTyped<Duration>,
"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
Expand Down
129 changes: 127 additions & 2 deletions src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -250,6 +250,68 @@ pub enum EntryType {
Original,
}

impl<'de> Deserialize<'de> for EntryType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<E>(self, value: &str) -> Result<Self::Value, E>
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.
Expand Down Expand Up @@ -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<T> {
/// The typed variant.
Expand Down Expand Up @@ -382,6 +444,69 @@ impl<T> From<T> for MaybeTyped<T> {
}
}

// Custom deserializer for MaybeTyped that allows fallback to String
impl<'de, T> Deserialize<'de> for MaybeTyped<T>
where
T: Deserialize<'de> + FromStr,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, Visitor};
use std::fmt;

struct MaybeTypedVisitor<T>(std::marker::PhantomData<T>);

impl<'de, T> Visitor<'de> for MaybeTypedVisitor<T>
where
T: Deserialize<'de> + FromStr,
{
type Value = MaybeTyped<T>;

fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a typed value or a string")
}

fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
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<A>(self, map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
T::deserialize(serde::de::value::MapAccessDeserializer::new(map))
.map(MaybeTyped::Typed)
}

fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: de::Error,
{
T::deserialize(serde::de::value::I64Deserializer::new(value))
.map(MaybeTyped::Typed)
}

fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
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)]
Expand Down
54 changes: 53 additions & 1 deletion src/types/persons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -85,6 +85,58 @@ pub enum PersonRole {
Unknown(String),
}

impl<'de> Deserialize<'de> for PersonRole {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<E>(self, value: &str) -> Result<Self::Value, E>
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)]
Expand Down
49 changes: 45 additions & 4 deletions src/types/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {}
Expand All @@ -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 })
}

Expand Down Expand Up @@ -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]
Expand Down
5 changes: 2 additions & 3 deletions tests/data/basic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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"]
Expand Down