krystv's picture
Upload 107 files
3374e90 verified
raw
history blame
28.2 kB
#[allow(warnings)]
mod bindings;
use bindings::bex::plugin::common::*;
use bindings::bex::plugin::http;
use bindings::exports::api::Guest;
use serde::Deserialize;
use std::collections::HashMap;
struct Component;
const IMDB_API_BASE: &str = "https://api.imdbapi.dev";
// ============================================================================
// IMDb API Response Types
// ============================================================================
#[derive(Deserialize, Debug, Clone, Default)]
#[allow(dead_code)]
struct ApiTitle {
#[serde(default)]
id: String,
#[serde(rename = "type", default)]
title_type: String,
#[serde(rename = "primaryTitle", default)]
primary_title: String,
#[serde(rename = "originalTitle", default)]
original_title: Option<String>,
#[serde(rename = "primaryImage", default)]
primary_image: Option<ApiImage>,
#[serde(rename = "startYear", default, deserialize_with = "deserialize_u32_or_string")]
start_year: Option<u32>,
#[serde(rename = "endYear", default, deserialize_with = "deserialize_u32_or_string")]
end_year: Option<u32>,
#[serde(rename = "runtimeSeconds", default)]
runtime_seconds: Option<u32>,
#[serde(default)]
genres: Option<Vec<String>>,
#[serde(default)]
rating: Option<ApiRating>,
#[serde(default)]
plot: Option<String>,
#[serde(default)]
directors: Option<Vec<ApiName>>,
#[serde(default)]
writers: Option<Vec<ApiName>>,
#[serde(default)]
stars: Option<Vec<ApiName>>,
#[serde(rename = "originCountries", default)]
origin_countries: Option<Vec<ApiCountry>>,
#[serde(rename = "spokenLanguages", default)]
spoken_languages: Option<Vec<ApiLanguage>>,
}
fn deserialize_u32_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u32>, D::Error> {
use serde::de::{self, Visitor};
struct U32OrString;
impl<'de> Visitor<'de> for U32OrString {
type Value = Option<u32>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u32 or string") }
fn visit_none<E: de::Error>(self) -> Result<Option<u32>, E> { Ok(None) }
fn visit_some<D2: serde::Deserializer<'de>>(self, d2: D2) -> Result<Option<u32>, D2::Error> {
d2.deserialize_any(U32Inner)
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<u32>, E> { Ok(v.try_into().ok()) }
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<u32>, E> { Ok(v.try_into().ok()) }
fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<u32>, E> { Ok(v.parse().ok()) }
}
struct U32Inner;
impl<'de> Visitor<'de> for U32Inner {
type Value = Option<u32>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u32 or string") }
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<u32>, E> { Ok(v.try_into().ok()) }
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<u32>, E> { Ok(v.try_into().ok()) }
fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<u32>, E> { Ok(v.parse().ok()) }
fn visit_none<E: de::Error>(self) -> Result<Option<u32>, E> { Ok(None) }
}
d.deserialize_option(U32OrString)
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct ApiImage {
url: Option<String>,
width: Option<u32>,
height: Option<u32>,
}
#[derive(Deserialize, Debug, Clone, Default)]
struct ApiRating {
#[serde(rename = "aggregateRating", default, deserialize_with = "deserialize_f64_or_string")]
aggregate_rating: Option<f64>,
#[serde(rename = "voteCount", default, deserialize_with = "deserialize_u64_or_string")]
vote_count: Option<u64>,
}
fn deserialize_f64_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
use serde::de::{self, Visitor};
struct F64OrString;
impl<'de> Visitor<'de> for F64OrString {
type Value = Option<f64>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("f64 or string") }
fn visit_none<E: de::Error>(self) -> Result<Option<f64>, E> { Ok(None) }
fn visit_some<D2: serde::Deserializer<'de>>(self, d2: D2) -> Result<Option<f64>, D2::Error> {
d2.deserialize_any(F64OrStringInner)
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Option<f64>, E> { Ok(Some(v)) }
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<f64>, E> { Ok(Some(v as f64)) }
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<f64>, E> { Ok(Some(v as f64)) }
fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<f64>, E> { Ok(v.parse().ok()) }
}
struct F64OrStringInner;
impl<'de> Visitor<'de> for F64OrStringInner {
type Value = Option<f64>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("f64 or string") }
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Option<f64>, E> { Ok(Some(v)) }
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<f64>, E> { Ok(Some(v as f64)) }
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<f64>, E> { Ok(Some(v as f64)) }
fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<f64>, E> { Ok(v.parse().ok()) }
fn visit_none<E: de::Error>(self) -> Result<Option<f64>, E> { Ok(None) }
}
d.deserialize_option(F64OrString)
}
fn deserialize_u64_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
use serde::de::{self, Visitor};
struct U64OrString;
impl<'de> Visitor<'de> for U64OrString {
type Value = Option<u64>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u64 or string") }
fn visit_none<E: de::Error>(self) -> Result<Option<u64>, E> { Ok(None) }
fn visit_some<D2: serde::Deserializer<'de>>(self, d2: D2) -> Result<Option<u64>, D2::Error> {
d2.deserialize_any(U64OrStringInner)
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<u64>, E> { Ok(Some(v)) }
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<u64>, E> { Ok(v.try_into().ok()) }
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Option<u64>, E> { Ok(Some(v as u64)) }
fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<u64>, E> { Ok(v.parse().ok()) }
}
struct U64OrStringInner;
impl<'de> Visitor<'de> for U64OrStringInner {
type Value = Option<u64>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u64 or string") }
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<u64>, E> { Ok(Some(v)) }
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<u64>, E> { Ok(v.try_into().ok()) }
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Option<u64>, E> { Ok(Some(v as u64)) }
fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<u64>, E> { Ok(v.parse().ok()) }
fn visit_none<E: de::Error>(self) -> Result<Option<u64>, E> { Ok(None) }
}
d.deserialize_option(U64OrString)
}
#[derive(Deserialize, Debug, Clone)]
struct ApiName {
id: String,
#[serde(rename = "displayName")]
display_name: String,
#[serde(rename = "primaryImage")]
primary_image: Option<ApiImage>,
}
#[derive(Deserialize, Debug, Clone)]
struct ApiCountry {
code: String,
name: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
struct ApiLanguage {
code: String,
name: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct ApiEpisode {
id: String,
title: Option<String>,
#[serde(rename = "primaryImage")]
primary_image: Option<ApiImage>,
season: Option<String>,
#[serde(rename = "episodeNumber")]
episode_number: Option<u32>,
#[serde(rename = "runtimeSeconds")]
runtime_seconds: Option<u32>,
plot: Option<String>,
rating: Option<ApiRating>,
#[serde(rename = "releaseDate")]
release_date: Option<ApiPrecisionDate>,
}
#[derive(Deserialize, Debug, Clone)]
struct ApiPrecisionDate {
year: Option<u32>,
month: Option<u32>,
day: Option<u32>,
}
#[derive(Deserialize, Debug, Clone)]
struct ListTitlesResponse {
titles: Option<Vec<ApiTitle>>,
#[serde(rename = "nextPageToken")]
next_page_token: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct ListTitleEpisodesResponse {
episodes: Option<Vec<ApiEpisode>>,
#[serde(rename = "nextPageToken")]
next_page_token: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
struct SearchTitlesResponse {
titles: Option<Vec<ApiTitle>>,
}
// ============================================================================
// HTTP Helper — delegates to host-provided http::send-request
// ============================================================================
impl Component {
fn fetch_url(url: &str) -> Option<String> {
let req = http::Request {
method: http::Method::Get,
url: url.to_string(),
headers: vec![
Attr { key: "User-Agent".to_string(), value: "BexIMDB/1.0".to_string() },
Attr { key: "Accept".to_string(), value: "application/json".to_string() },
],
body: None,
timeout_ms: Some(15000),
follow_redirects: true,
cache_mode: http::CacheMode::Normal,
max_bytes: Some(2 * 1024 * 1024),
};
match http::send_request(&req) {
Ok(resp) if resp.status == 200 => String::from_utf8(resp.body).ok(),
_ => None,
}
}
fn parse_json<T: serde::de::DeserializeOwned>(json_str: &str) -> Option<T> {
serde_json::from_str(json_str).ok()
}
// ========================================================================
// Thumbnail / image helpers
// ========================================================================
fn img(url: Option<&str>, layout: ImageLayout) -> ImageSet {
let u = url.unwrap_or("").to_string();
let img = if u.is_empty() {
None
} else {
Some(Image {
url: u.clone(),
layout,
width: None,
height: None,
blurhash: None,
})
};
ImageSet {
low: img.clone(),
medium: img.clone(),
high: img,
backdrop: None,
logo: None,
}
}
fn format_precision_date(date: &ApiPrecisionDate) -> String {
let y = date.year.unwrap_or(0);
let m = date.month.unwrap_or(0);
let d = date.day.unwrap_or(0);
if y == 0 {
"Unknown".to_string()
} else if m == 0 {
format!("{:04}", y)
} else if d == 0 {
format!("{:04}-{:02}", y, m)
} else {
format!("{:04}-{:02}-{:02}", y, m, d)
}
}
// ========================================================================
// Map API type string → BEX media-kind
// ========================================================================
fn media_kind(type_str: &str) -> MediaKind {
if type_str.eq_ignore_ascii_case("movie")
|| type_str.eq_ignore_ascii_case("tv_movie")
|| type_str.eq_ignore_ascii_case("tvMovie")
{
MediaKind::Movie
} else if type_str.eq_ignore_ascii_case("tv_series")
|| type_str.eq_ignore_ascii_case("tvSeries")
|| type_str.eq_ignore_ascii_case("tv_mini_series")
|| type_str.eq_ignore_ascii_case("tvMiniSeries")
|| type_str.eq_ignore_ascii_case("tv_special")
|| type_str.eq_ignore_ascii_case("tvSpecial")
{
MediaKind::Series
} else if type_str.eq_ignore_ascii_case("short") {
MediaKind::Short
} else {
MediaKind::Unknown
}
}
// ========================================================================
// ApiTitle → MediaCard
// ========================================================================
fn api_title_to_card(title: &ApiTitle) -> Option<MediaCard> {
let id = title.id.clone();
let kind = Self::media_kind(&title.title_type);
let img_url = title
.primary_image
.as_ref()
.and_then(|img| img.url.as_deref());
let score = title
.rating
.as_ref()
.and_then(|r| r.aggregate_rating)
.map(|r| (r * 10.0) as u32);
let year = if let (Some(s), Some(e)) = (title.start_year, title.end_year) {
Some(format!("{} - {}", s, e))
} else {
title.start_year.map(|y| y.to_string())
};
Some(MediaCard {
id: id.clone(),
title: title.primary_title.clone(),
kind: Some(kind),
images: Some(Self::img(img_url, ImageLayout::Portrait)),
original_title: title.original_title.clone(),
tagline: None,
year,
score,
genres: title.genres.clone().unwrap_or_default(),
status: None,
content_rating: None,
url: Some(format!("https://www.imdb.com/title/{}/", id)),
ids: vec![LinkedId { source: "imdb".to_string(), id }],
extra: vec![],
})
}
// ========================================================================
// Fetch seasons + episodes (paginated) for TV series
// ========================================================================
fn fetch_seasons(title_id: &str) -> Vec<Season> {
let mut all_eps = Vec::new();
let mut page_token: Option<String> = None;
loop {
let url = if let Some(ref token) = page_token {
format!(
"{}/titles/{}/episodes?pageSize=50&pageToken={}",
IMDB_API_BASE, title_id, token
)
} else {
format!("{}/titles/{}/episodes?pageSize=50", IMDB_API_BASE, title_id)
};
let resp = match Self::fetch_url(&url) {
Some(r) => r,
None => break,
};
let data: ListTitleEpisodesResponse = match Self::parse_json(&resp) {
Some(d) => d,
None => break,
};
if let Some(eps) = data.episodes {
all_eps.extend(eps);
}
page_token = data.next_page_token;
if page_token.is_none() {
break;
}
}
if all_eps.is_empty() {
return vec![];
}
// Group by season
let mut map: HashMap<String, Vec<Episode>> = HashMap::new();
for ep in all_eps {
let sn = ep.season.clone().unwrap_or_else(|| "Unknown".to_string());
let ep_num = ep.episode_number.unwrap_or(0);
let title_text = ep.title.as_deref().unwrap_or("Unknown");
let full_title = format!("E{}. {}", ep_num, title_text);
let img_url = ep.primary_image.as_ref().and_then(|i| i.url.as_deref());
let score = ep.rating.as_ref().and_then(|r| r.aggregate_rating).map(|r| (r * 10.0) as u32);
let released = ep.release_date.as_ref().map(Self::format_precision_date);
let episode = Episode {
id: ep.id.clone(),
title: full_title,
number: Some(ep_num as f64),
season: sn.parse::<f64>().ok(),
images: Some(Self::img(img_url, ImageLayout::Landscape)),
description: ep.plot.clone(),
released,
score,
url: Some(format!("https://www.imdb.com/title/{}/", ep.id)),
tags: vec![],
extra: vec![],
};
map.entry(sn).or_default().push(episode);
}
let mut seasons: Vec<Season> = map
.into_iter()
.map(|(sn, mut episodes)| {
episodes.sort_by(|a, b| {
let an = a.number.unwrap_or(0.0) as u32;
let bn = b.number.unwrap_or(0.0) as u32;
an.cmp(&bn)
});
let num = sn.parse::<f64>().ok();
let year = episodes
.first()
.and_then(|ep| ep.released.as_ref())
.and_then(|rd| rd.split('-').next())
.and_then(|y| y.parse::<u32>().ok());
Season {
id: format!("{}_s{}", title_id, sn),
title: format!("Season {}", sn),
number: num,
year,
episodes,
}
})
.collect();
seasons.sort_by(|a, b| {
a.number
.unwrap_or(0.0)
.partial_cmp(&b.number.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
seasons
}
}
// ============================================================================
// Guest implementation — the actual plugin API
// ============================================================================
impl Guest for Component {
fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> {
let mut sections = Vec::new();
// Popular Movies
let url = format!(
"{}/titles?sortBy=SORT_BY_POPULARITY&types=MOVIE",
IMDB_API_BASE
);
if let Some(resp) = Self::fetch_url(&url) {
if let Some(data) = Self::parse_json::<ListTitlesResponse>(&resp) {
if let Some(titles) = data.titles {
let items: Vec<MediaCard> = titles.iter().filter_map(Self::api_title_to_card).take(20).collect();
if !items.is_empty() {
sections.push(HomeSection {
id: "popular_movies".to_string(),
title: "Most Popular Movies".to_string(),
subtitle: None,
items,
next_page: Some("popular_movies:2".to_string()),
layout: CardLayout::Grid,
show_rank: true,
categories: vec![],
extra: vec![],
});
}
}
}
}
// Popular TV Shows
let url = format!(
"{}/titles?sortBy=SORT_BY_POPULARITY&types=TV_SERIES",
IMDB_API_BASE
);
if let Some(resp) = Self::fetch_url(&url) {
if let Some(data) = Self::parse_json::<ListTitlesResponse>(&resp) {
if let Some(titles) = data.titles {
let items: Vec<MediaCard> = titles.iter().filter_map(Self::api_title_to_card).take(20).collect();
if !items.is_empty() {
sections.push(HomeSection {
id: "popular_tv".to_string(),
title: "Most Popular TV Shows".to_string(),
subtitle: None,
items,
next_page: Some("popular_tv:2".to_string()),
layout: CardLayout::Grid,
show_rank: true,
categories: vec![],
extra: vec![],
});
}
}
}
}
Ok(sections)
}
fn get_category(_ctx: RequestContext, id: String, page: PageCursor) -> Result<PagedResult, PluginError> {
// Parse our custom page token format: "category_type:page_number"
let page_num: u32 = page
.token
.as_ref()
.and_then(|t| t.split(':').last())
.and_then(|n| n.parse().ok())
.unwrap_or(1);
let url = match id.as_str() {
"popular_movies" => format!(
"{}/titles?sortBy=SORT_BY_POPULARITY&types=MOVIE&pageToken={}",
IMDB_API_BASE, page_num
),
"popular_tv" => format!(
"{}/titles?sortBy=SORT_BY_POPULARITY&types=TV_SERIES&pageToken={}",
IMDB_API_BASE, page_num
),
_ => return Err(PluginError::NotFound),
};
let resp = Self::fetch_url(&url).ok_or(PluginError::Network("Failed to fetch".to_string()))?;
let data: ListTitlesResponse = Self::parse_json(&resp).ok_or(PluginError::Parse("Invalid JSON".to_string()))?;
let items: Vec<MediaCard> = data
.titles
.unwrap_or_default()
.iter()
.filter_map(Self::api_title_to_card)
.collect();
let next_page = data.next_page_token.map(|_t| format!("{}:{}", id, page_num + 1));
Ok(PagedResult {
items,
categories: vec![],
next_page,
})
}
fn search(_ctx: RequestContext, query: String, _filters: SearchFilters) -> Result<PagedResult, PluginError> {
if query.trim().is_empty() {
return Ok(PagedResult {
items: vec![],
categories: vec![],
next_page: None,
});
}
let encoded = urlencoding(&query);
let url = format!(
"{}/search/titles?query={}&limit=30",
IMDB_API_BASE, encoded
);
let resp = Self::fetch_url(&url).ok_or(PluginError::Network("Search request failed".to_string()))?;
let data: SearchTitlesResponse = Self::parse_json(&resp).ok_or(PluginError::Parse("Invalid search response".to_string()))?;
let items: Vec<MediaCard> = data
.titles
.unwrap_or_default()
.iter()
.filter_map(Self::api_title_to_card)
.collect();
Ok(PagedResult {
items,
categories: vec![],
next_page: None,
})
}
fn get_info(_ctx: RequestContext, id: String) -> Result<MediaInfo, PluginError> {
let url = format!("{}/titles/{}", IMDB_API_BASE, id);
let resp = Self::fetch_url(&url).ok_or(PluginError::Network("Failed to fetch title".to_string()))?;
let title: ApiTitle = Self::parse_json(&resp).ok_or(PluginError::Parse("Invalid title JSON".to_string()))?;
let kind = Self::media_kind(&title.title_type);
let img_url = title.primary_image.as_ref().and_then(|i| i.url.as_deref());
let score = title.rating.as_ref().and_then(|r| r.aggregate_rating).map(|r| (r * 10.0) as u32);
let scored_by = title.rating.as_ref().and_then(|r| r.vote_count);
// Crew: directors + writers
let mut crew_list: Vec<Person> = Vec::new();
if let Some(directors) = &title.directors {
for d in directors {
let d_img = d.primary_image.as_ref().and_then(|i| i.url.as_deref());
crew_list.push(Person {
id: d.id.clone(),
name: d.display_name.clone(),
image: Some(Self::img(d_img, ImageLayout::Portrait)),
role: Some("Director".to_string()),
url: Some(format!("https://www.imdb.com/name/{}/", d.id)),
});
}
}
if let Some(writers) = &title.writers {
for w in writers {
let w_img = w.primary_image.as_ref().and_then(|i| i.url.as_deref());
crew_list.push(Person {
id: w.id.clone(),
name: w.display_name.clone(),
image: Some(Self::img(w_img, ImageLayout::Portrait)),
role: Some("Writer".to_string()),
url: Some(format!("https://www.imdb.com/name/{}/", w.id)),
});
}
}
// Cast from stars
let cast: Vec<Person> = title
.stars
.unwrap_or_default()
.iter()
.map(|s| {
let s_img = s.primary_image.as_ref().and_then(|i| i.url.as_deref());
Person {
id: s.id.clone(),
name: s.display_name.clone(),
image: Some(Self::img(s_img, ImageLayout::Portrait)),
role: None,
url: Some(format!("https://www.imdb.com/name/{}/", s.id)),
}
})
.collect();
// Seasons for TV series
let seasons = if matches!(kind, MediaKind::Series) {
Self::fetch_seasons(&id)
} else {
vec![]
};
let runtime_minutes = title.runtime_seconds.map(|s| s / 60);
let country = title.origin_countries.map(|cs| {
cs.iter()
.map(|c| c.name.as_deref().unwrap_or(&c.code).to_string())
.collect::<Vec<_>>()
.join(", ")
});
let language = title.spoken_languages.map(|ls| {
ls.iter()
.map(|l| l.name.as_deref().unwrap_or(&l.code).to_string())
.collect::<Vec<_>>()
.join(", ")
});
let year = if let (Some(s), Some(e)) = (title.start_year, title.end_year) {
Some(format!("{} - {}", s, e))
} else {
title.start_year.map(|y| y.to_string())
};
Ok(MediaInfo {
id: id.clone(),
title: title.primary_title.clone(),
kind,
images: Some(Self::img(img_url, ImageLayout::Portrait)),
original_title: title.original_title.clone(),
description: title.plot.clone(),
score,
scored_by,
year,
release_date: None,
genres: title.genres.clone().unwrap_or_default(),
tags: vec![],
status: None,
content_rating: None,
seasons,
cast,
crew: crew_list,
runtime_minutes,
trailer_url: None,
ids: vec![LinkedId { source: "imdb".to_string(), id: id.clone() }],
studio: None,
country,
language,
url: Some(format!("https://www.imdb.com/title/{}/", id)),
extra: vec![],
})
}
fn get_servers(_ctx: RequestContext, _id: String) -> Result<Vec<Server>, PluginError> {
// IMDB is metadata-only; no streaming servers
Err(PluginError::Unsupported)
}
fn resolve_stream(_ctx: RequestContext, _server: Server) -> Result<StreamSource, PluginError> {
Err(PluginError::Unsupported)
}
fn search_subtitles(_ctx: RequestContext, _query: SubtitleQuery) -> Result<Vec<SubtitleEntry>, PluginError> {
Err(PluginError::Unsupported)
}
fn download_subtitle(_ctx: RequestContext, _id: String) -> Result<SubtitleFile, PluginError> {
Err(PluginError::Unsupported)
}
fn get_articles(_ctx: RequestContext) -> Result<Vec<ArticleSection>, PluginError> {
Err(PluginError::Unsupported)
}
fn search_articles(_ctx: RequestContext, _query: String) -> Result<Vec<Article>, PluginError> {
Err(PluginError::Unsupported)
}
}
/// Minimal URL-encoding (replaces spaces with + and percent-encodes special chars)
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
b' ' => out.push('+'),
_ => {
out.push('%');
out.push_str(&format!("{:02X}", b));
}
}
}
out
}
bindings::export!(Component with_types_in bindings);