use crate::book::Metadata;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataCategory {
KindleTitle,
KindleEbook,
KindleAudit,
}
impl MetadataCategory {
pub fn as_str(self) -> &'static str {
match self {
MetadataCategory::KindleTitle => "kindle_title_metadata",
MetadataCategory::KindleEbook => "kindle_ebook_metadata",
MetadataCategory::KindleAudit => "kindle_audit_metadata",
}
}
}
#[derive(Debug, Clone)]
pub struct MetadataRule {
pub key: &'static str,
pub category: MetadataCategory,
pub source: MetadataSource,
}
#[derive(Debug, Clone)]
pub enum MetadataSource {
Static(&'static str),
Dynamic(MetadataField),
}
#[derive(Debug, Clone, Copy)]
pub enum MetadataField {
Title,
Language,
FirstAuthor,
Description,
Publisher,
Identifier,
Date,
CoverImage,
AssetId,
BookId,
ModifiedDate,
Translator,
TitleSort,
AuthorSort,
SeriesName,
SeriesPosition,
}
impl MetadataField {
pub fn extract(self, meta: &Metadata) -> Option<&str> {
match self {
MetadataField::Title => {
if meta.title.is_empty() {
None
} else {
Some(&meta.title)
}
}
MetadataField::Language => {
if meta.language.is_empty() {
None
} else {
Some(&meta.language)
}
}
MetadataField::FirstAuthor => meta.authors.first().map(|s| s.as_str()),
MetadataField::Description => meta.description.as_deref(),
MetadataField::Publisher => meta.publisher.as_deref(),
MetadataField::Identifier => {
if meta.identifier.is_empty() {
None
} else {
Some(&meta.identifier)
}
}
MetadataField::Date => meta.date.as_deref(),
MetadataField::CoverImage => meta.cover_image.as_deref(),
MetadataField::ModifiedDate => meta.modified_date.as_deref(),
MetadataField::Translator => {
meta.contributors
.iter()
.find(|c| c.role.as_deref() == Some("trl"))
.map(|c| c.name.as_str())
}
MetadataField::TitleSort => meta.title_sort.as_deref(),
MetadataField::AuthorSort => meta.author_sort.as_deref(),
MetadataField::SeriesName => meta.collection.as_ref().map(|c| c.name.as_str()),
MetadataField::AssetId | MetadataField::BookId | MetadataField::SeriesPosition => None,
}
}
}
pub fn metadata_schema() -> Vec<MetadataRule> {
vec![
MetadataRule {
key: "title",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::Title),
},
MetadataRule {
key: "language",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::Language),
},
MetadataRule {
key: "author",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::FirstAuthor),
},
MetadataRule {
key: "description",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::Description),
},
MetadataRule {
key: "publisher",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::Publisher),
},
MetadataRule {
key: "issue_date",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::Date),
},
MetadataRule {
key: "cover_image",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::CoverImage),
},
MetadataRule {
key: "asset_id",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::AssetId),
},
MetadataRule {
key: "book_id",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::BookId),
},
MetadataRule {
key: "cde_content_type",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Static("EBOK"),
},
MetadataRule {
key: "modified_date",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::ModifiedDate),
},
MetadataRule {
key: "translator",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::Translator),
},
MetadataRule {
key: "title_pronunciation",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::TitleSort),
},
MetadataRule {
key: "author_pronunciation",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::AuthorSort),
},
MetadataRule {
key: "series_name",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::SeriesName),
},
MetadataRule {
key: "series_position",
category: MetadataCategory::KindleTitle,
source: MetadataSource::Dynamic(MetadataField::SeriesPosition),
},
MetadataRule {
key: "selection",
category: MetadataCategory::KindleEbook,
source: MetadataSource::Static("enabled"),
},
MetadataRule {
key: "nested_span",
category: MetadataCategory::KindleEbook,
source: MetadataSource::Static("enabled"),
},
MetadataRule {
key: "file_creator",
category: MetadataCategory::KindleAudit,
source: MetadataSource::Static("boko"),
},
]
}
use crate::util::truncate_to_date;
#[derive(Debug, Default)]
pub struct MetadataContext<'a> {
pub version: Option<&'a str>,
pub cover_resource_name: Option<&'a str>,
pub asset_id: Option<&'a str>,
pub book_id: Option<String>,
}
pub fn generate_book_id(identifier: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut bytes = vec![0x05u8];
let mut hasher = DefaultHasher::new();
identifier.hash(&mut hasher);
let hash1 = hasher.finish();
"boko-book-id".hash(&mut hasher);
let hash2 = hasher.finish();
bytes.extend_from_slice(&hash1.to_le_bytes());
bytes.extend_from_slice(&hash2.to_le_bytes());
base64_url_encode(&bytes[..17])
}
fn base64_url_encode(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut result = String::new();
let mut bits: u32 = 0;
let mut bit_count = 0;
for &byte in bytes {
bits = (bits << 8) | byte as u32;
bit_count += 8;
while bit_count >= 6 {
bit_count -= 6;
let idx = ((bits >> bit_count) & 0x3F) as usize;
result.push(ALPHABET[idx] as char);
}
}
if bit_count > 0 {
let idx = ((bits << (6 - bit_count)) & 0x3F) as usize;
result.push(ALPHABET[idx] as char);
}
result
}
pub fn build_category_entries(
category: MetadataCategory,
meta: &Metadata,
ctx: &MetadataContext,
) -> Vec<(&'static str, String)> {
let schema = metadata_schema();
let mut entries = Vec::new();
for rule in schema.iter().filter(|r| r.category == category) {
let value = match &rule.source {
MetadataSource::Static(s) => Some(s.to_string()),
MetadataSource::Dynamic(field) => {
match field {
MetadataField::CoverImage => {
ctx.cover_resource_name.map(|s| s.to_string())
}
MetadataField::Date => {
field.extract(meta).map(truncate_to_date)
}
MetadataField::AssetId => {
ctx.asset_id.map(|s| s.to_string())
}
MetadataField::BookId => {
ctx.book_id.clone()
}
MetadataField::SeriesPosition => {
meta.collection.as_ref().and_then(|c| c.position).map(|p| {
if p.fract() == 0.0 {
format!("{}", p as i64)
} else {
format!("{}", p)
}
})
}
_ => field.extract(meta).map(|s| s.to_string()),
}
}
};
if let Some(v) = value {
entries.push((rule.key, v));
}
}
if category == MetadataCategory::KindleAudit
&& let Some(v) = ctx.version
{
entries.push(("creator_version", v.to_string()));
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metadata_field_extraction() {
let meta = Metadata {
title: "Test Book".to_string(),
authors: vec!["Author One".to_string()],
language: "en".to_string(),
description: Some("A description".to_string()),
publisher: None,
..Default::default()
};
assert_eq!(MetadataField::Title.extract(&meta), Some("Test Book"));
assert_eq!(
MetadataField::FirstAuthor.extract(&meta),
Some("Author One")
);
assert_eq!(MetadataField::Language.extract(&meta), Some("en"));
assert_eq!(
MetadataField::Description.extract(&meta),
Some("A description")
);
assert_eq!(MetadataField::Publisher.extract(&meta), None);
}
#[test]
fn test_build_category_entries() {
let meta = Metadata {
title: "Test Book".to_string(),
authors: vec!["Author".to_string()],
language: "en".to_string(),
..Default::default()
};
let ctx = MetadataContext::default();
let entries = build_category_entries(MetadataCategory::KindleTitle, &meta, &ctx);
assert!(
entries
.iter()
.any(|(k, v)| *k == "title" && v == "Test Book")
);
assert!(entries.iter().any(|(k, v)| *k == "language" && v == "en"));
assert!(entries.iter().any(|(k, v)| *k == "author" && v == "Author"));
assert!(!entries.iter().any(|(k, _)| *k == "description"));
}
#[test]
fn test_build_ebook_entries() {
let meta = Metadata::default();
let ctx = MetadataContext::default();
let entries = build_category_entries(MetadataCategory::KindleEbook, &meta, &ctx);
assert!(
entries
.iter()
.any(|(k, v)| *k == "selection" && v == "enabled")
);
assert!(
entries
.iter()
.any(|(k, v)| *k == "nested_span" && v == "enabled")
);
}
#[test]
fn test_build_audit_entries_with_version() {
let meta = Metadata::default();
let ctx = MetadataContext {
version: Some("1.0.0"),
..Default::default()
};
let entries = build_category_entries(MetadataCategory::KindleAudit, &meta, &ctx);
assert!(
entries
.iter()
.any(|(k, v)| *k == "file_creator" && v == "boko")
);
assert!(
entries
.iter()
.any(|(k, v)| *k == "creator_version" && v == "1.0.0")
);
}
#[test]
fn test_build_entries_with_cover_image() {
let meta = Metadata {
title: "Test".to_string(),
language: "en".to_string(),
cover_image: Some("images/cover.jpg".to_string()),
..Default::default()
};
let ctx = MetadataContext::default();
let entries = build_category_entries(MetadataCategory::KindleTitle, &meta, &ctx);
assert!(!entries.iter().any(|(k, _)| *k == "cover_image"));
let ctx = MetadataContext {
cover_resource_name: Some("e6"),
..Default::default()
};
let entries = build_category_entries(MetadataCategory::KindleTitle, &meta, &ctx);
assert!(
entries
.iter()
.any(|(k, v)| *k == "cover_image" && v == "e6")
);
}
#[test]
fn test_build_entries_with_issue_date() {
let meta = Metadata {
title: "Test".to_string(),
language: "en".to_string(),
date: Some("2022-05-26".to_string()),
..Default::default()
};
let ctx = MetadataContext::default();
let entries = build_category_entries(MetadataCategory::KindleTitle, &meta, &ctx);
assert!(
entries
.iter()
.any(|(k, v)| *k == "issue_date" && v == "2022-05-26")
);
}
#[test]
fn test_category_strings() {
assert_eq!(
MetadataCategory::KindleTitle.as_str(),
"kindle_title_metadata"
);
assert_eq!(
MetadataCategory::KindleEbook.as_str(),
"kindle_ebook_metadata"
);
assert_eq!(
MetadataCategory::KindleAudit.as_str(),
"kindle_audit_metadata"
);
}
#[test]
fn test_generate_book_id_format() {
let id = super::generate_book_id("urn:uuid:12345678-1234-1234-1234-123456789abc");
assert_eq!(id.len(), 23, "book_id should be 23 characters");
assert!(
id.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"book_id should only contain URL-safe Base64 characters"
);
}
#[test]
fn test_generate_book_id_deterministic() {
let id1 = super::generate_book_id("urn:uuid:12345678-1234-1234-1234-123456789abc");
let id2 = super::generate_book_id("urn:uuid:12345678-1234-1234-1234-123456789abc");
assert_eq!(id1, id2, "book_id should be deterministic");
}
#[test]
fn test_generate_book_id_different_inputs() {
let id1 = super::generate_book_id("urn:uuid:aaaaaaaa-1234-1234-1234-123456789abc");
let id2 = super::generate_book_id("urn:uuid:bbbbbbbb-1234-1234-1234-123456789abc");
assert_ne!(
id1, id2,
"different identifiers should produce different book_ids"
);
}
#[test]
fn test_cde_content_type_is_ebok() {
let meta = Metadata {
title: "Test".to_string(),
language: "en".to_string(),
..Default::default()
};
let ctx = MetadataContext::default();
let entries = build_category_entries(MetadataCategory::KindleTitle, &meta, &ctx);
assert!(
entries
.iter()
.any(|(k, v)| *k == "cde_content_type" && v == "EBOK")
);
}
#[test]
fn test_build_entries_with_asset_id_and_book_id() {
let meta = Metadata {
title: "Test".to_string(),
language: "en".to_string(),
identifier: "urn:uuid:test-id".to_string(),
..Default::default()
};
let ctx = MetadataContext {
asset_id: Some("CR!ABCDEFGHIJKLMNOPQRSTUVWXYZ12"),
book_id: Some("BtestBookId12345678901".to_string()),
..Default::default()
};
let entries = build_category_entries(MetadataCategory::KindleTitle, &meta, &ctx);
assert!(
entries
.iter()
.any(|(k, v)| *k == "asset_id" && v == "CR!ABCDEFGHIJKLMNOPQRSTUVWXYZ12")
);
assert!(
entries
.iter()
.any(|(k, v)| *k == "book_id" && v == "BtestBookId12345678901")
);
}
}