#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InternalLocation {
ElementId(String),
TextOffset(u32),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LinkTarget {
pub spine_index: Option<usize>,
pub location: InternalLocation,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Link {
External(String),
Internal(LinkTarget),
Unknown(String),
}
impl Link {
pub fn is_external(&self) -> bool {
matches!(self, Link::External(_))
}
pub fn is_internal(&self) -> bool {
matches!(self, Link::Internal(_))
}
pub fn as_external(&self) -> Option<&str> {
match self {
Link::External(url) => Some(url),
_ => None,
}
}
pub fn as_internal(&self) -> Option<&LinkTarget> {
match self {
Link::Internal(target) => Some(target),
_ => None,
}
}
pub fn parse(href: &str) -> Link {
let href = href.trim();
if href.starts_with("http://")
|| href.starts_with("https://")
|| href.starts_with("mailto:")
|| href.starts_with("tel:")
{
return Link::External(href.to_string());
}
if href.starts_with("kindle:") {
return Self::parse_kindle_link(href);
}
if let Some(fragment) = href.strip_prefix('#') {
return Link::Internal(LinkTarget {
spine_index: None,
location: InternalLocation::ElementId(fragment.to_string()),
});
}
if href.contains('#') || href.ends_with(".xhtml") || href.ends_with(".html") {
return Link::Unknown(href.to_string());
}
Link::Unknown(href.to_string())
}
fn parse_kindle_link(href: &str) -> Link {
let parts: Vec<&str> = href.split(':').collect();
if parts.len() >= 6 && parts[1] == "pos" && parts[2] == "fid" && parts[4] == "off" {
let fid_str = parts[3];
let off_str = parts[5];
if let Ok(fid) = u32::from_str_radix(fid_str, 16) {
if let Some(offset) = kindle_base32_decode(off_str) {
return Link::Internal(LinkTarget {
spine_index: Some(fid as usize),
location: InternalLocation::TextOffset(offset),
});
}
}
}
Link::Unknown(href.to_string())
}
}
fn kindle_base32_decode(s: &str) -> Option<u32> {
let mut result: u64 = 0;
for c in s.chars() {
let digit = match c {
'0'..='9' => c as u64 - '0' as u64,
'A'..='V' => c as u64 - 'A' as u64 + 10,
'a'..='v' => c as u64 - 'a' as u64 + 10,
_ => return None,
};
result = result * 32 + digit;
if result > u32::MAX as u64 {
return None;
}
}
Some(result as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_external_links() {
assert!(matches!(
Link::parse("https://example.com"),
Link::External(_)
));
assert!(matches!(
Link::parse("http://example.com"),
Link::External(_)
));
assert!(matches!(
Link::parse("mailto:user@example.com"),
Link::External(_)
));
}
#[test]
fn test_parse_fragment_link() {
let link = Link::parse("#footnote-1");
match link {
Link::Internal(target) => {
assert_eq!(target.spine_index, None);
assert_eq!(
target.location,
InternalLocation::ElementId("footnote-1".to_string())
);
}
_ => panic!("Expected internal link"),
}
}
#[test]
fn test_parse_kindle_link() {
let link = Link::parse("kindle:pos:fid:000B:off:00000002SO");
match link {
Link::Internal(target) => {
assert_eq!(target.spine_index, Some(11)); match target.location {
InternalLocation::TextOffset(offset) => {
assert!(offset > 0);
}
_ => panic!("Expected TextOffset"),
}
}
_ => panic!("Expected internal link, got {:?}", link),
}
}
#[test]
fn test_kindle_base32_decode() {
assert_eq!(kindle_base32_decode("0"), Some(0));
assert_eq!(kindle_base32_decode("1"), Some(1));
assert_eq!(kindle_base32_decode("A"), Some(10));
assert_eq!(kindle_base32_decode("V"), Some(31));
assert_eq!(kindle_base32_decode("10"), Some(32)); assert_eq!(kindle_base32_decode("11"), Some(33)); }
}