#[cfg(feature = "api_bluesky")]
use crate::api::app_bsky::richtext::facet::Facet;
#[cfg(feature = "api_bluesky")]
use crate::api::com_atproto::repo::strong_ref::StrongRef;
#[cfg(feature = "api_bluesky")]
use crate::types::aturi::AtUri;
#[cfg(feature = "api_bluesky")]
use jacquard_common::BosStr;
use jacquard_common::IntoStatic;
#[cfg(feature = "api_bluesky")]
use jacquard_common::http_client::HttpClient;
use jacquard_common::types::did::{DID_REGEX, Did};
use jacquard_common::types::handle::HANDLE_REGEX;
use jacquard_common::types::string::AtStrError;
use jacquard_common::types::uri::UriParseError;
use jacquard_identity::resolver::IdentityError;
#[cfg(feature = "api_bluesky")]
use jacquard_identity::resolver::IdentityResolver;
#[cfg(not(target_family = "wasm"))]
use regex::{Captures, Regex};
#[cfg(target_family = "wasm")]
use regex_lite::{Captures, Regex};
#[cfg(feature = "api_bluesky")]
use smol_str::{SmolStr, ToSmolStr, format_smolstr};
use std::borrow::Cow;
use std::marker::PhantomData;
use std::ops::Range;
use std::sync::LazyLock;
static MENTION_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(^|\s|\()(@)([a-zA-Z0-9.:-]+)(\b)").unwrap());
static URL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(^|\s|\()((https?://[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))")
.unwrap()
});
static TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(^|\s)[##]([^\s\u{00AD}\u{2060}\u{200A}\u{200B}\u{200C}\u{200D}\u{20e2}]*[^\d\s\p{P}\u{00AD}\u{2060}\u{200A}\u{200B}\u{200C}\u{200D}\u{20e2}]+[^\s\u{00AD}\u{2060}\u{200A}\u{200B}\u{200C}\u{200D}\u{20e2}]*)?"
).unwrap()
});
static MARKDOWN_LINK_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
static TRAILING_PUNCT_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\p{P}+$").unwrap());
static SANITIZE_NEWLINES_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"([\r\n\u{00AD}\u{2060}\u{200D}\u{200C}\u{200B}]\s*)+").unwrap());
#[cfg(feature = "api_bluesky")]
pub static DEFAULT_EMBED_DOMAINS: &[&str] = &[
"bsky.app",
"deer.social",
"blacksky.community",
"catsky.social",
];
pub struct Resolved;
pub struct Unresolved;
#[derive(Debug, Clone)]
#[cfg(feature = "api_bluesky")]
pub struct RichText {
pub text: SmolStr,
pub facets: Option<Vec<Facet<SmolStr>>>,
}
#[cfg(feature = "api_bluesky")]
impl RichText {
pub fn parse(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> {
parse(text)
}
pub fn builder() -> RichTextBuilder<Resolved> {
RichTextBuilder::builder()
}
}
#[derive(Debug, Clone)]
#[cfg(feature = "api_bluesky")]
pub enum EmbedCandidate<S: BosStr> {
Record {
at_uri: AtUri<S>,
strong_ref: Option<StrongRef<S>>,
},
External {
url: S,
metadata: Option<ExternalMetadata<S>>,
},
}
#[derive(Debug, Clone)]
#[cfg(feature = "api_bluesky")]
pub struct ExternalMetadata<S> {
pub title: S,
pub description: S,
pub thumbnail: Option<S>,
}
#[derive(Debug)]
pub struct RichTextBuilder<State> {
text: SmolStr,
facet_candidates: Vec<FacetCandidate>,
#[cfg(feature = "api_bluesky")]
embed_candidates: Option<Vec<EmbedCandidate<SmolStr>>>,
_state: PhantomData<State>,
}
#[derive(Debug, Clone)]
enum FacetCandidate {
MarkdownLink {
display_range: Range<usize>,
url: String,
},
Mention {
range: Range<usize>,
did: Option<Did>,
},
Link {
range: Range<usize>,
},
Tag {
range: Range<usize>,
},
}
fn sanitize_text(text: &str) -> Cow<'_, str> {
SANITIZE_NEWLINES_REGEX.replace_all(text, |caps: &Captures| {
let matched = caps.get(0).unwrap().as_str();
let mut newline_sequences = 0;
let mut chars = matched.chars().peekable();
while let Some(c) = chars.next() {
if c == '\r' {
if chars.peek() == Some(&'\n') {
chars.next(); }
newline_sequences += 1;
} else if c == '\n' {
newline_sequences += 1;
}
}
if newline_sequences == 0 {
""
} else if newline_sequences == 1 {
"\n"
} else {
"\n\n"
}
})
}
pub fn parse(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> {
#[cfg(feature = "api_bluesky")]
{
parse_with_domains(text, DEFAULT_EMBED_DOMAINS)
}
#[cfg(not(feature = "api_bluesky"))]
{
parse_with_domains(text)
}
}
#[cfg(feature = "api_bluesky")]
pub fn parse_with_domains(
text: impl AsRef<str>,
embed_domains: &[&str],
) -> RichTextBuilder<Unresolved> {
let text = sanitize_text(text.as_ref());
let mut facet_candidates = Vec::new();
let mut embed_candidates = Vec::new();
let (text_processed, markdown_facets) = detect_markdown_links(&text);
for facet in &markdown_facets {
if let FacetCandidate::MarkdownLink { url, .. } = facet {
if let Some(embed) = classify_embed(url, embed_domains) {
embed_candidates.push(embed);
}
}
}
facet_candidates.extend(markdown_facets);
let mention_facets = detect_mentions(&text_processed);
facet_candidates.extend(mention_facets);
let url_facets = detect_urls(&text_processed);
for facet in &url_facets {
if let FacetCandidate::Link { range } = facet {
let url = &text_processed[range.clone()];
if let Some(embed) = classify_embed(url, embed_domains) {
embed_candidates.push(embed);
}
}
}
facet_candidates.extend(url_facets);
let tag_facets = detect_tags(&text_processed);
facet_candidates.extend(tag_facets);
RichTextBuilder {
text: text_processed.to_smolstr(),
facet_candidates,
embed_candidates: if embed_candidates.is_empty() {
None
} else {
Some(embed_candidates)
},
_state: PhantomData,
}
}
#[cfg(not(feature = "api_bluesky"))]
pub fn parse_with_domains(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> {
let text = sanitize_text(text.as_ref());
let mut facet_candidates = Vec::new();
let (text_processed, markdown_facets) = detect_markdown_links(&text);
facet_candidates.extend(markdown_facets);
let mention_facets = detect_mentions(&text_processed);
facet_candidates.extend(mention_facets);
let url_facets = detect_urls(&text_processed);
facet_candidates.extend(url_facets);
let tag_facets = detect_tags(&text_processed);
facet_candidates.extend(tag_facets);
RichTextBuilder {
text: text_processed,
facet_candidates,
_state: PhantomData,
}
}
impl RichTextBuilder<Resolved> {
pub fn builder() -> Self {
RichTextBuilder {
text: SmolStr::new_static(""),
facet_candidates: Vec::new(),
#[cfg(feature = "api_bluesky")]
embed_candidates: None,
_state: PhantomData,
}
}
pub fn mention_handle(
mut self,
handle: impl AsRef<str>,
range: Option<Range<usize>>,
) -> RichTextBuilder<Unresolved> {
let handle = handle.as_ref();
let range = range.unwrap_or_else(|| {
let search = format!("@{}", handle);
self.find_substring(&search).unwrap_or(0..0)
});
self.facet_candidates
.push(FacetCandidate::Mention { range, did: None });
RichTextBuilder {
text: self.text,
facet_candidates: self.facet_candidates,
#[cfg(feature = "api_bluesky")]
embed_candidates: self.embed_candidates,
_state: PhantomData,
}
}
}
impl<St> RichTextBuilder<St> {
pub fn text(mut self, text: impl AsRef<str>) -> Self {
self.text = sanitize_text(text.as_ref()).to_smolstr();
self
}
pub fn mention(mut self, did: &Did, range: Range<usize>) -> Self {
self.facet_candidates.push(FacetCandidate::Mention {
range,
did: Some(did.clone()),
});
self
}
pub fn link(mut self, url: impl AsRef<str>, range: Option<Range<usize>>) -> Self {
let url = url.as_ref();
let range = range.unwrap_or_else(|| {
self.find_substring(url).unwrap_or(0..0)
});
self.facet_candidates.push(FacetCandidate::Link { range });
self
}
pub fn tag(mut self, tag: impl AsRef<str>, range: Option<Range<usize>>) -> Self {
let tag = tag.as_ref();
let range = range.unwrap_or_else(|| {
let search = format!("#{}", tag);
self.find_substring(&search).unwrap_or(0..0)
});
self.facet_candidates.push(FacetCandidate::Tag { range });
self
}
pub fn markdown_link(mut self, url: impl Into<String>, display_range: Range<usize>) -> Self {
self.facet_candidates.push(FacetCandidate::MarkdownLink {
url: url.into(),
display_range,
});
self
}
#[cfg(feature = "api_bluesky")]
pub fn embed_record(mut self, at_uri: AtUri, strong_ref: Option<StrongRef>) -> Self {
self.embed_candidates
.get_or_insert_with(Vec::new)
.push(EmbedCandidate::Record { at_uri, strong_ref });
self
}
#[cfg(feature = "api_bluesky")]
pub fn embed_external(
mut self,
url: impl Into<SmolStr>,
metadata: Option<ExternalMetadata<SmolStr>>,
) -> Self {
self.embed_candidates
.get_or_insert_with(Vec::new)
.push(EmbedCandidate::External {
url: url.into(),
metadata,
});
self
}
fn find_substring(&self, needle: &str) -> Option<Range<usize>> {
self.text.find(needle).map(|start| {
let end = start + needle.len();
start..end
})
}
}
fn detect_markdown_links(text: &str) -> (String, Vec<FacetCandidate>) {
let mut result = String::with_capacity(text.len());
let mut facets = Vec::new();
let mut last_end = 0;
let mut offset = 0;
for cap in MARKDOWN_LINK_REGEX.captures_iter(text) {
let full_match = cap.get(0).unwrap();
let display_text = cap.get(1).unwrap().as_str();
let url = cap.get(2).unwrap().as_str();
result.push_str(&text[last_end..full_match.start()]);
let start = result.len() - offset;
result.push_str(display_text);
let end = result.len() - offset;
offset += full_match.as_str().len() - display_text.len();
facets.push(FacetCandidate::MarkdownLink {
display_range: start..end,
url: url.to_string(),
});
last_end = full_match.end();
}
result.push_str(&text[last_end..]);
(result, facets)
}
fn detect_mentions(text: &str) -> Vec<FacetCandidate> {
let mut facets = Vec::new();
for cap in MENTION_REGEX.captures_iter(text) {
let handle = cap.get(3).unwrap().as_str();
if !HANDLE_REGEX.is_match(handle) && !DID_REGEX.is_match(handle) {
continue;
}
let did = if let Ok(did) = Did::new(handle) {
Some(did.into_static())
} else {
None
};
let at_sign = cap.get(2).unwrap();
let start = at_sign.start();
let end = cap.get(3).unwrap().end();
facets.push(FacetCandidate::Mention {
range: start..end,
did,
});
}
facets
}
fn detect_urls(text: &str) -> Vec<FacetCandidate> {
let mut facets = Vec::new();
for cap in URL_REGEX.captures_iter(text) {
let url_match = if let Some(full_url) = cap.get(3) {
full_url
} else if let Some(_domain) = cap.name("domain") {
cap.get(2).unwrap()
} else {
continue;
};
let url_str = url_match.as_str();
let trimmed_len = if let Some(trimmed) = TRAILING_PUNCT_REGEX.find(url_str) {
trimmed.start()
} else {
url_str.len()
};
if trimmed_len == 0 {
continue;
}
let start = url_match.start();
let end = start + trimmed_len;
facets.push(FacetCandidate::Link { range: start..end });
}
facets
}
fn detect_tags(text: &str) -> Vec<FacetCandidate> {
let mut facets = Vec::new();
for cap in TAG_REGEX.captures_iter(text) {
let tag_match = match cap.get(2) {
Some(m) => m,
None => continue,
};
let tag_str = tag_match.as_str();
if tag_str.starts_with('\u{fe0f}') {
continue;
}
let trimmed_len = if let Some(trimmed) = TRAILING_PUNCT_REGEX.find(tag_str) {
trimmed.start()
} else {
tag_str.len()
};
if trimmed_len == 0 || trimmed_len > 64 {
continue;
}
let hash_pos = cap.get(0).unwrap().start();
let hash_start = text[hash_pos..]
.chars()
.position(|c| c == '#' || c == '#')
.unwrap();
let start = hash_pos + hash_start;
let end = start + 1 + trimmed_len;
facets.push(FacetCandidate::Tag { range: start..end });
}
facets
}
#[cfg(feature = "api_bluesky")]
fn classify_embed<'s>(url: &'s str, embed_domains: &[&str]) -> Option<EmbedCandidate<SmolStr>> {
if url.starts_with("at://") {
if let Ok(at_uri) = AtUri::new(url.to_smolstr()) {
return Some(EmbedCandidate::Record {
at_uri: at_uri,
strong_ref: None,
});
}
}
if url.starts_with("http://") || url.starts_with("https://") {
if let Some(at_uri) = extract_at_uri_from_url(url, embed_domains) {
return Some(EmbedCandidate::Record {
at_uri,
strong_ref: None,
});
}
return Some(EmbedCandidate::External {
url: url.to_smolstr(),
metadata: None,
});
}
None
}
#[cfg(feature = "api_bluesky")]
pub fn extract_at_uri_from_url<'s>(url: &'s str, embed_domains: &[&str]) -> Option<AtUri<SmolStr>> {
use jacquard_common::deps::fluent_uri::Uri;
let url_parsed = Uri::parse(url).ok()?;
let domain = url_parsed.authority()?.host();
if !embed_domains.contains(&domain) {
return None;
}
let path = url_parsed.path().as_str();
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let at_uri_str = match segments.as_slice() {
["profile", actor, "post", rkey] => {
format_smolstr!("at://{}/app.bsky.feed.post/{}", actor, rkey)
}
["profile", actor, "lists", rkey] => {
format_smolstr!("at://{}/app.bsky.graph.list/{}", actor, rkey)
}
["profile", actor, "feed", rkey] => {
format_smolstr!("at://{}/app.bsky.feed.generator/{}", actor, rkey)
}
["starter-pack", actor, rkey] => {
format_smolstr!("at://{}/app.bsky.graph.starterpack/{}", actor, rkey)
}
["profile", actor, collection, rkey] if collection.contains('.') => {
format_smolstr!("at://{}/{}/{}", actor, collection, rkey)
}
_ => return None,
};
AtUri::new(at_uri_str).ok()
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[non_exhaustive]
pub enum RichTextError {
#[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]
HandleNeedsResolution(String),
#[error("Facets overlap at byte range {0}..{1}")]
OverlappingFacets(usize, usize),
#[error("Failed to resolve identity")]
IdentityResolution(#[from] IdentityError),
#[error("Invalid byte range {start}..{end} for text of length {text_len}")]
InvalidRange {
start: usize,
end: usize,
text_len: usize,
},
#[error("Invalid AT Protocol string")]
InvalidAtStr(#[from] AtStrError),
#[error("Invalid URI")]
Uri(#[from] UriParseError),
}
#[cfg(feature = "api_bluesky")]
impl RichTextBuilder<Resolved> {
pub fn build(self) -> Result<RichText, RichTextError> {
if self.facet_candidates.is_empty() {
return Ok(RichText {
text: self.text,
facets: None,
});
}
let mut candidates = self.facet_candidates;
candidates.sort_by_key(|fc| match fc {
FacetCandidate::MarkdownLink { display_range, .. } => display_range.start,
FacetCandidate::Mention { range, .. } => range.start,
FacetCandidate::Link { range } => range.start,
FacetCandidate::Tag { range } => range.start,
});
let mut facets = Vec::with_capacity(candidates.len());
let mut last_end = 0;
let text_len = self.text.len();
for candidate in candidates {
use crate::api::app_bsky::richtext::facet::{
ByteSlice, FacetFeaturesItem, Link, Mention, Tag,
};
use crate::types::uri::UriValue;
let (range, feature) = match candidate {
FacetCandidate::MarkdownLink { display_range, url } => {
let feature = FacetFeaturesItem::Link(Box::new(Link {
uri: UriValue::new_owned(&url)?,
extra_data: None,
}));
(display_range, feature)
}
FacetCandidate::Mention { range, did } => {
let did = did.ok_or_else(|| {
let handle = if range.end <= text_len {
self.text[range.clone()].trim_start_matches('@')
} else {
"<invalid range>"
};
RichTextError::HandleNeedsResolution(handle.to_string())
})?;
let feature = FacetFeaturesItem::Mention(Box::new(Mention {
did,
extra_data: None,
}));
(range, feature)
}
FacetCandidate::Link { range } => {
if range.end > text_len {
return Err(RichTextError::InvalidRange {
start: range.start,
end: range.end,
text_len,
});
}
let mut url = self.text[range.clone()].to_string();
if !url.starts_with("http://") && !url.starts_with("https://") {
url = format!("https://{}", url);
}
let feature = FacetFeaturesItem::Link(Box::new(Link {
uri: UriValue::new_owned(&url)?,
extra_data: None,
}));
(range, feature)
}
FacetCandidate::Tag { range } => {
use smol_str::ToSmolStr;
if range.end > text_len {
return Err(RichTextError::InvalidRange {
start: range.start,
end: range.end,
text_len,
});
}
let tag_with_hash = &self.text[range.clone()];
let tag = tag_with_hash
.trim_start_matches('#')
.trim_start_matches('#');
let feature = FacetFeaturesItem::Tag(Box::new(Tag {
tag: tag.to_smolstr(),
extra_data: None,
}));
(range, feature)
}
};
if range.start < last_end {
return Err(RichTextError::OverlappingFacets(range.start, range.end));
}
if range.end > text_len {
return Err(RichTextError::InvalidRange {
start: range.start,
end: range.end,
text_len,
});
}
facets.push(Facet {
index: ByteSlice {
byte_start: range.start as i64,
byte_end: range.end as i64,
extra_data: None,
},
features: vec![feature],
extra_data: None,
});
last_end = range.end;
}
Ok(RichText {
text: self.text,
facets: Some(facets.into_static()),
})
}
}
#[cfg(feature = "api_bluesky")]
impl RichTextBuilder<Unresolved> {
pub async fn build_async<R>(self, resolver: &R) -> Result<RichText, RichTextError>
where
R: IdentityResolver + Sync,
{
use crate::api::app_bsky::richtext::facet::{
ByteSlice, FacetFeaturesItem, Link, Mention, Tag,
};
if self.facet_candidates.is_empty() {
return Ok(RichText {
text: self.text,
facets: None,
});
}
let mut candidates = self.facet_candidates;
candidates.sort_by_key(|fc| match fc {
FacetCandidate::MarkdownLink { display_range, .. } => display_range.start,
FacetCandidate::Mention { range, .. } => range.start,
FacetCandidate::Link { range } => range.start,
FacetCandidate::Tag { range } => range.start,
});
let mut facets = Vec::with_capacity(candidates.len());
let mut last_end = 0;
let text_len = self.text.len();
for candidate in candidates {
let (range, feature) = match candidate {
FacetCandidate::MarkdownLink { display_range, url } => {
let feature = FacetFeaturesItem::Link(Box::new(Link {
uri: crate::types::uri::UriValue::new_owned(&url)?,
extra_data: None,
}));
(display_range, feature)
}
FacetCandidate::Mention { range, did } => {
let did = if let Some(did) = did {
did
} else {
if range.end > text_len {
return Err(RichTextError::InvalidRange {
start: range.start,
end: range.end,
text_len,
});
}
let handle_str = self.text[range.clone()].trim_start_matches('@');
let handle = jacquard_common::types::handle::Handle::new(handle_str)?;
resolver.resolve_handle(&handle).await?
};
let feature = FacetFeaturesItem::Mention(Box::new(Mention {
did,
extra_data: None,
}));
(range, feature)
}
FacetCandidate::Link { range } => {
if range.end > text_len {
return Err(RichTextError::InvalidRange {
start: range.start,
end: range.end,
text_len,
});
}
let mut url = self.text[range.clone()].to_string();
if !url.starts_with("http://") && !url.starts_with("https://") {
url = format!("https://{}", url);
}
let feature = FacetFeaturesItem::Link(Box::new(Link {
uri: crate::types::uri::UriValue::new_owned(&url)?,
extra_data: None,
}));
(range, feature)
}
FacetCandidate::Tag { range } => {
use smol_str::ToSmolStr;
if range.end > text_len {
return Err(RichTextError::InvalidRange {
start: range.start,
end: range.end,
text_len,
});
}
let tag_with_hash = &self.text[range.clone()];
let tag = tag_with_hash
.trim_start_matches('#')
.trim_start_matches('#');
let feature = FacetFeaturesItem::Tag(Box::new(Tag {
tag: tag.to_smolstr(),
extra_data: None,
}));
(range, feature)
}
};
if range.start < last_end {
return Err(RichTextError::OverlappingFacets(range.start, range.end));
}
if range.end > text_len {
return Err(RichTextError::InvalidRange {
start: range.start,
end: range.end,
text_len,
});
}
facets.push(Facet {
index: ByteSlice {
byte_start: range.start as i64,
byte_end: range.end as i64,
extra_data: None,
},
features: vec![feature],
extra_data: None,
});
last_end = range.end;
}
Ok(RichText {
text: self.text,
facets: Some(facets.into_static()),
})
}
pub async fn build_with_embeds_async<C>(
mut self,
client: &C,
) -> Result<(RichText, Option<Vec<EmbedCandidate<SmolStr>>>), RichTextError>
where
C: HttpClient + IdentityResolver + Sync,
{
let embed_candidates = self.embed_candidates.take().unwrap_or_default();
let richtext = self.build_async(client).await?;
let mut resolved_embeds: Vec<EmbedCandidate<SmolStr>> = Vec::new();
for candidate in embed_candidates {
match candidate {
EmbedCandidate::Record { at_uri, strong_ref } => {
resolved_embeds.push(EmbedCandidate::Record { at_uri, strong_ref });
}
EmbedCandidate::External {
url,
metadata: None,
} => {
match fetch_opengraph_metadata(client, &url).await {
Ok(Some(metadata)) => {
resolved_embeds.push(EmbedCandidate::External {
url,
metadata: Some(metadata),
});
}
Ok(None) | Err(_) => {
resolved_embeds.push(EmbedCandidate::External {
url,
metadata: None,
});
}
}
}
other => resolved_embeds.push(other),
}
}
Ok((richtext, Some(resolved_embeds).filter(|v| !v.is_empty())))
}
}
#[cfg(feature = "api_bluesky")]
pub async fn fetch_opengraph_metadata<C>(
client: &C,
url: &str,
) -> Result<Option<ExternalMetadata<SmolStr>>, Box<dyn std::error::Error + Send + Sync>>
where
C: HttpClient,
{
let request = http::Request::builder()
.method("GET")
.uri(url)
.header("User-Agent", "jacquard/0.8")
.body(Vec::new())?;
let response = client.send_http(request).await?;
let html = String::from_utf8_lossy(response.body());
let info = webpage::HTML::from_string(html.to_string(), Some(url.to_string()))
.ok()
.map(|html| html.opengraph);
if let Some(og) = info {
let title = og.properties.get("title").map(|s| s.to_smolstr());
let description = og.properties.get("description").map(|s| s.to_smolstr());
let thumbnail = og.images.first().map(|img| SmolStr::from(img.url.clone()));
if let Some(title) = title {
return Ok(Some(ExternalMetadata {
title: title,
description: description.unwrap_or_else(|| SmolStr::new_static("")),
thumbnail: thumbnail,
}));
}
}
Ok(None)
}
#[cfg(test)]
mod tests;