added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,813 @@
use crate::components::context::{AppState, UIState};
use i18nrs::yew::use_translation;
use regex::Regex;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Element, HtmlElement, Node};
use yew::prelude::*;
use yewdux::prelude::*;
// Original SafeHtml component properties
#[derive(Properties, PartialEq)]
pub struct Props {
pub html: String,
#[prop_or(true)]
pub process_timecodes: bool,
// Optional props for episode details when processing timecodes
#[prop_or(None)]
pub episode_url: Option<String>,
#[prop_or(None)]
pub episode_title: Option<String>,
#[prop_or(None)]
pub episode_description: Option<String>,
#[prop_or(None)]
pub episode_release_date: Option<String>,
#[prop_or(None)]
pub episode_artwork: Option<String>,
#[prop_or(None)]
pub episode_duration: Option<i32>,
#[prop_or(None)]
pub episode_id: Option<i32>,
#[prop_or(None)]
pub listen_duration: Option<i32>,
#[prop_or(None)]
pub is_youtube: Option<bool>,
}
// Function to convert timecode to seconds
fn timecode_to_seconds(hours: Option<&str>, minutes: &str, seconds: Option<&str>) -> i32 {
let h = hours.map_or(0, |h| h.parse::<i32>().unwrap_or(0));
let m = minutes.parse::<i32>().unwrap_or(0);
let s = seconds.map_or(0, |s| s.parse::<i32>().unwrap_or(0));
h * 3600 + m * 60 + s
}
// Add these debug functions at the top of your file
fn log_debug(message: &str) {
web_sys::console::log_1(&format!("[DEBUG] {}", message).into());
}
fn log_error(message: &str) {
web_sys::console::error_1(&format!("[ERROR] {}", message).into());
}
// Then modify the process_timecodes function to add logging
fn process_timecodes(
html_content: &str,
audio_dispatch: Dispatch<UIState>,
episode_props: &Props,
server_name: String,
api_key: String,
user_id: i32,
current_ep: Option<i32>,
start_episode_first_msg: String,
start_episode_first_audio_msg: String,
) -> String {
// If the content is empty, return early
if html_content.is_empty() {
log_debug("html_content is empty, returning early");
return String::new();
}
// Create a temporary div to hold the HTML content
let document = match web_sys::window().and_then(|win| win.document()) {
Some(doc) => doc,
None => {
log_error("Failed to get document");
return html_content.to_string();
}
};
let temp_div = match document.create_element("div") {
Ok(div) => div,
Err(e) => {
log_error(format!("Failed to create div: {:?}", e).as_str());
return html_content.to_string();
}
};
temp_div.set_inner_html(html_content);
// Define regex patterns for different timecode formats that are compatible with WASM
// Avoid using lookbehind/lookahead assertions which aren't supported
let patterns = vec![
// (00:00:00) or [00:00:00] - full format with parentheses/brackets
r"\((\d{1,2}):(\d{2}):(\d{2})\)",
r"\[(\d{1,2}):(\d{2}):(\d{2})\]",
// (00:00) or [00:00] - minutes:seconds with parentheses/brackets
r"\((\d{1,2}):(\d{2})\)",
r"\[(\d{1,2}):(\d{2})\]",
// 00:00:00 - full format without parentheses/brackets (use word boundaries instead)
r"\b(\d{1,2}):(\d{2}):(\d{2})\b",
// 00:00 - minutes:seconds without parentheses/brackets
r"\b(\d{1,2}):(\d{2})\b",
// 1h 23m 45s or 1h23m45s format
r"(\d{1,2})h\s*(\d{1,2})m\s*(\d{1,2})s",
r"(\d{1,2})h\s*(\d{1,2})m",
r"(\d{1,2})m\s*(\d{1,2})s",
];
// Process all text nodes recursively
process_node(
&temp_div,
&patterns,
&document,
&audio_dispatch,
episode_props,
server_name,
api_key,
user_id,
current_ep,
start_episode_first_msg,
start_episode_first_audio_msg,
);
// Return the processed HTML
let result = temp_div.inner_html();
result
}
// Helper function to process nodes recursively with error handling
fn process_node(
node: &Node,
patterns: &Vec<&str>,
document: &web_sys::Document,
audio_dispatch: &Dispatch<UIState>,
episode_props: &Props,
server_name: String,
api_key: String,
user_id: i32,
currently_playing_id: Option<i32>,
start_episode_first_msg: String,
start_episode_first_audio_msg: String,
) {
// Skip processing if node is a script, style, or already a link
if let Some(element) = node.dyn_ref::<Element>() {
let tag_name = element.tag_name().to_lowercase();
if tag_name == "script" || tag_name == "style" || tag_name == "a" {
return;
}
}
// Process text nodes
if let Some(text) = node.node_value() {
if !text.trim().is_empty() {
let mut has_timecode = false;
let mut replacements = vec![];
// Try each pattern
for &pattern in patterns {
// Use safely compiled regex
let regex = match Regex::new(pattern) {
Ok(r) => r,
Err(e) => {
log_error(
format!("Failed to compile regex pattern {}: {:?}", pattern, e)
.as_str(),
);
continue; // Skip this pattern if it fails to compile
}
};
if regex.is_match(&text) {
for cap in regex.captures_iter(&text) {
let full_match = match cap.get(0) {
Some(m) => m,
None => continue,
};
let start_idx = full_match.start();
let end_idx = full_match.end();
// ADD THE URL CHECK RIGHT HERE! - Before checking for duplicates
let text_before = if start_idx > 0 {
&text[..start_idx]
} else {
""
};
let text_after = if end_idx < text.len() {
&text[end_idx..]
} else {
""
};
// Skip if this looks like part of a URL
if text_before.ends_with("http://")
|| text_before.ends_with("https://")
|| text_before.ends_with("www.")
|| text_after.starts_with(".com")
|| text_after.starts_with(".net")
|| text_after.starts_with(".org")
|| text_after.starts_with(".online")
{
// Added .online for your specific case
continue;
}
// Check if this timecode has already been processed
// (Avoid duplicating the same timecode in overlapping matches)
if replacements.iter().any(|(s, e, _, _)| {
(*s <= start_idx && *e > start_idx) || (*s < end_idx && *e >= end_idx)
}) {
continue;
}
// Extract time components based on the pattern
let (hours, minutes, seconds) = if pattern
== r"\((\d{1,2}):(\d{2}):(\d{2})\)"
|| pattern == r"\[(\d{1,2}):(\d{2}):(\d{2})\]"
|| pattern == r"\b(\d{1,2}):(\d{2}):(\d{2})\b"
{
// Full format with hours, minutes, seconds
let hrs = cap.get(1).map(|m| m.as_str());
let mins = cap.get(2).map(|m| m.as_str()).unwrap_or("0");
let secs = cap.get(3).map(|m| m.as_str());
(hrs, mins, secs)
} else if pattern == r"\((\d{1,2}):(\d{2})\)"
|| pattern == r"\[(\d{1,2}):(\d{2})\]"
|| pattern == r"\b(\d{1,2}):(\d{2})\b"
{
// Format with just minutes and seconds
let mins = cap.get(1).map(|m| m.as_str()).unwrap_or("0");
let secs = cap.get(2).map(|m| m.as_str());
(None, mins, secs)
} else if pattern == r"(\d{1,2})h\s*(\d{1,2})m\s*(\d{1,2})s" {
// 1h 23m 45s format
let hrs = cap.get(1).map(|m| m.as_str());
let mins = cap.get(2).map(|m| m.as_str()).unwrap_or("0");
let secs = cap.get(3).map(|m| m.as_str());
(hrs, mins, secs)
} else if pattern == r"(\d{1,2})h\s*(\d{1,2})m" {
// 1h 23m format
let hrs = cap.get(1).map(|m| m.as_str());
let mins = cap.get(2).map(|m| m.as_str()).unwrap_or("0");
(hrs, mins, None)
} else if pattern == r"(\d{1,2})m\s*(\d{1,2})s" {
// 23m 45s format
let mins = cap.get(1).map(|m| m.as_str()).unwrap_or("0");
let secs = cap.get(2).map(|m| m.as_str());
(None, mins, secs)
} else {
// Fallback (shouldn't happen with our patterns)
(None, "0", None)
};
let total_seconds = timecode_to_seconds(hours, minutes, seconds);
replacements.push((
start_idx,
end_idx,
text[start_idx..end_idx].to_string(),
total_seconds,
));
has_timecode = true;
}
}
}
if has_timecode && !replacements.is_empty() {
// Sort replacements by start position (normal order, not reverse)
replacements.sort_by(|a, b| a.0.cmp(&b.0));
// Get the parent node to replace the text node
if let Some(parent) = node.parent_node() {
// Create a new document fragment
let fragment = document.create_document_fragment();
let mut last_end = 0;
// Process each replacement
for (start, end, match_text, seconds) in &replacements {
// Add text before this timecode
if *start > last_end {
let before_text = document.create_text_node(&text[last_end..*start]);
match fragment.append_child(&before_text) {
Ok(_) => {}
Err(e) => log_error(
format!("Failed to append before_text: {:?}", e).as_str(),
),
}
}
// Create span element for the timecode
let span = match document.create_element("span") {
Ok(el) => el,
Err(e) => {
log_error(
format!("Failed to create span element: {:?}", e).as_str(),
);
continue;
}
};
span.set_text_content(Some(match_text));
// Style and attributes
match span.set_attribute(
"style",
"color: #3498db; cursor: pointer; text-decoration: none; display: inline-block;",
) {
Ok(_) => {},
Err(e) => log_error(format!("Failed to set style attribute: {:?}", e).as_str()),
}
match span.set_attribute("data-timecode", &seconds.to_string()) {
Ok(_) => {}
Err(e) => log_error(
format!("Failed to set data-timecode attribute: {:?}", e).as_str(),
),
}
match span.set_attribute("role", "button") {
Ok(_) => {}
Err(e) => {
log_error(format!("Failed to set role attribute: {:?}", e).as_str())
}
}
match span.set_attribute("tabindex", "0") {
Ok(_) => {}
Err(e) => log_error(
format!("Failed to set tabindex attribute: {:?}", e).as_str(),
),
}
match span.set_attribute("class", "timecode-link") {
Ok(_) => {}
Err(e) => log_error(
format!("Failed to set class attribute: {:?}", e).as_str(),
),
}
// Create click handler
let dispatch = audio_dispatch.clone();
let start_time = *seconds;
// Copy all episode props for the closure
let episode_id = episode_props.episode_id;
// Add inline handler
// Add inline handler
let onclick_handler = if let Some(ep_id) = episode_id {
format!(
"event.preventDefault(); \
event.stopPropagation(); \
console.log('Timecode span clicked inline: {}s, episode: {}'); \
window.handleTimecodeClick && window.handleTimecodeClick({}, {}); \
return false;",
seconds, ep_id, seconds, ep_id
)
} else {
format!(
"event.preventDefault(); \
event.stopPropagation(); \
console.log('Timecode span clicked inline: {}s'); \
window.handleTimecodeClick && window.handleTimecodeClick({}, -1); \
return false;",
seconds, seconds
)
};
match span.set_attribute("onclick", &onclick_handler) {
Ok(_) => {}
Err(e) => log_error(
format!("Failed to set onclick attribute: {:?}", e).as_str(),
),
}
// Add global handleTimecodeClick function if it doesn't exist yet
// Add global handleTimecodeClick function if it doesn't exist yet
let function_text = r#"
if (!window.handleTimecodeClick) {
window.handleTimecodeClick = function(seconds, episodeId) {
// Dispatch a custom event that our Rust code can listen for
document.dispatchEvent(new CustomEvent('timecode-click', {
detail: { seconds: seconds, episodeId: episodeId },
bubbles: true,
cancelable: true
}));
return false;
};
}
"#;
// Only add the function once
if js_sys::eval("typeof window.handleTimecodeClick === 'undefined'")
.unwrap()
.as_bool()
.unwrap_or(true)
{
match js_sys::eval(function_text) {
Ok(_) => {}
Err(e) => log_error(
format!("Failed to add global function: {:?}", e).as_str(),
),
}
}
// Register a document-level event listener for 'timecode-click' custom events
if js_sys::eval("typeof window.timecodeDelegationAdded === 'undefined'")
.unwrap()
.as_bool()
.unwrap_or(true)
{
let dispatch_clone = dispatch.clone();
let start_episode_first_msg_delegation = start_episode_first_msg.clone();
let start_episode_first_audio_msg_delegation = start_episode_first_audio_msg.clone();
let delegation_handler = Closure::wrap(Box::new(
move |e: web_sys::CustomEvent| {
if let Some(detail) = e.detail().dyn_ref::<js_sys::Object>() {
let js_seconds = js_sys::Reflect::get(
&detail,
&JsValue::from_str("seconds"),
)
.unwrap_or(JsValue::from(0));
let js_episode_id = js_sys::Reflect::get(
&detail,
&JsValue::from_str("episodeId"),
)
.unwrap_or(JsValue::from(-1));
if let (Some(seconds_f64), Some(event_episode_id_f64)) =
(js_seconds.as_f64(), js_episode_id.as_f64())
{
let seconds = seconds_f64 as i32;
let event_episode_id = event_episode_id_f64 as i32;
web_sys::console::log_1(
&format!(
"Handling timecode click via delegation: {}s, episode: {}",
seconds, event_episode_id
)
.into(),
);
// Get current playing ID from state
let mut is_current_episode = false;
let mut has_audio_element = false;
dispatch_clone.reduce_mut(|state| {
has_audio_element = state.audio_element.is_some();
if let Some(current) = &state.currently_playing {
is_current_episode = current.episode_id == event_episode_id;
web_sys::console::log_1(
&format!(
"Delegation: Comparing episode IDs: {} vs {}, match: {}",
event_episode_id, current.episode_id, is_current_episode
)
.into(),
);
}
});
if is_current_episode && has_audio_element {
// Now use dispatch to handle the click
dispatch_clone.reduce_mut(|state| {
if let Some(audio_element) = state.audio_element.as_ref() {
web_sys::console::log_1(&format!("Found audio element, seeking to {}s", seconds).into());
// Set the current time
audio_element.set_current_time(seconds as f64);
state.current_time_seconds = seconds as f64;
// If paused, start playing
if !state.audio_playing.unwrap_or(false) {
web_sys::console::log_1(&"Starting playback".into());
let _ = audio_element.play();
state.audio_playing = Some(true);
}
}
});
} else if !is_current_episode {
web_sys::console::log_1(
&"Delegation: Not the current episode".into(),
);
// Alert the user
if let Some(window) = web_sys::window() {
let _ = window.alert_with_message(&start_episode_first_msg_delegation);
}
} else {
web_sys::console::error_1(
&"No audio element available".into(),
);
if let Some(window) = web_sys::window() {
let _ = window.alert_with_message(&start_episode_first_audio_msg_delegation);
}
}
}
}
},
)
as Box<dyn FnMut(_)>);
// Add the event listener to the document
if let Some(document) = web_sys::window().and_then(|win| win.document())
{
match document.add_event_listener_with_callback(
"timecode-click",
delegation_handler.as_ref().unchecked_ref(),
) {
Ok(_) => {
delegation_handler.forget();
// Mark as added so we don't add it multiple times
let _ =
js_sys::eval("window.timecodeDelegationAdded = true;");
}
Err(e) => log_error(
format!(
"Failed to add document-level event handler: {:?}",
e
)
.as_str(),
),
}
}
}
// Create the standard click handler
let current_playing_id = currently_playing_id.clone(); // Clone for the closure
let start_episode_first_msg_click = start_episode_first_msg.clone();
let click_handler = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
web_sys::console::log_1(
&"TIMECODE SPAN CLICKED - HANDLER STARTED".into(),
);
// Critical - prevent default before doing anything else
e.prevent_default();
e.stop_propagation();
// Check if this episode is currently playing by comparing IDs
let is_current_episode = match (episode_id, current_playing_id) {
(Some(ep_id), Some(current_id)) => {
let is_match = ep_id == current_id;
web_sys::console::log_1(
&format!(
"Comparing episode IDs: {} vs {}, match: {}",
ep_id, current_id, is_match
)
.into(),
);
is_match
}
_ => {
web_sys::console::log_1(
&"Missing episode ID for comparison".into(),
);
false
}
};
if is_current_episode {
// Episode IS currently playing - seek to the timecode
web_sys::console::log_1(&"Episode is current, seeking only".into());
dispatch.reduce_mut(|state| {
if let Some(audio_element) = state.audio_element.as_ref() {
let time = start_time as f64;
web_sys::console::log_1(
&format!("Setting current time to {}", time).into(),
);
audio_element.set_current_time(time);
state.current_time_seconds = time;
// Update formatted time display
let hours = (time / 3600.0).floor() as i32;
let minutes = ((time % 3600.0) / 60.0).floor() as i32;
let seconds = (time % 60.0).floor() as i32;
state.current_time_formatted =
format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
// If paused, start playing
if !state.audio_playing.unwrap_or(false) {
let _ = audio_element.play();
state.audio_playing = Some(true);
}
}
});
} else {
// Not the current episode - show message
web_sys::console::log_1(&"Not the current episode".into());
// Alert the user
if let Some(window) = web_sys::window() {
let _ = window.alert_with_message(&start_episode_first_msg_click);
}
}
// Provide visual feedback on click
if let Some(element) = e.current_target() {
if let Some(element) = element.dyn_ref::<HtmlElement>() {
match element
.style()
.set_property("text-decoration", "underline")
{
Ok(_) => {}
Err(e) => web_sys::console::error_1(
&format!("Failed to add visual feedback: {:?}", e)
.into(),
),
}
// Reset after delay
let element_clone = element.clone();
let timeout_callback = Closure::wrap(Box::new(move || {
match element_clone
.style()
.set_property("text-decoration", "none")
{
Ok(_) => {}
Err(e) => web_sys::console::error_1(
&format!(
"Failed to reset visual feedback: {:?}",
e
)
.into(),
),
}
})
as Box<dyn FnMut()>);
match web_sys::window()
.unwrap()
.set_timeout_with_callback_and_timeout_and_arguments_0(
timeout_callback.as_ref().unchecked_ref(),
500,
) {
Ok(_) => {}
Err(e) => web_sys::console::error_1(
&format!(
"Failed to set visual feedback timeout: {:?}",
e
)
.into(),
),
}
timeout_callback.forget();
}
}
})
as Box<dyn FnMut(_)>);
// Attach the click handler
match span.add_event_listener_with_callback(
"click",
click_handler.as_ref().unchecked_ref(),
) {
Ok(_) => {
click_handler.forget();
}
Err(e) => {
web_sys::console::error_1(
&format!("Failed to add click handler: {:?}", e).into(),
);
}
}
// Also add mousedown handler for better coverage
let mousedown_handler =
Closure::wrap(Box::new(move |_e: web_sys::MouseEvent| {
web_sys::console::log_1(
&format!("MOUSEDOWN detected on timecode {}s", start_time)
.into(),
);
}) as Box<dyn FnMut(_)>);
match span.add_event_listener_with_callback(
"mousedown",
mousedown_handler.as_ref().unchecked_ref(),
) {
Ok(_) => {
mousedown_handler.forget();
}
Err(_) => {}
}
// Add the span to the fragment
match fragment.append_child(&span) {
Ok(_) => {}
Err(e) => log_error(format!("Failed to append span: {:?}", e).as_str()),
}
last_end = *end;
}
// Add any remaining text after the last replacement
if last_end < text.len() {
let after_text = document.create_text_node(&text[last_end..]);
match fragment.append_child(&after_text) {
Ok(_) => {}
Err(e) => {
log_error(format!("Failed to append after_text: {:?}", e).as_str())
}
}
}
// Replace the original text node with our fragment
match parent.replace_child(&fragment, node) {
Ok(_) => {}
Err(e) => log_error(format!("Failed to replace node: {:?}", e).as_str()),
}
} else {
log_error("No parent node found for replacement");
}
}
}
} else if node.has_child_nodes() {
// Process child nodes recursively (but create a copy to avoid mutation during iteration)
let child_count = node.child_nodes().length();
let mut children = Vec::with_capacity(child_count as usize);
for i in 0..child_count {
if let Some(child) = node.child_nodes().get(i) {
children.push(child);
}
}
for child in children {
process_node(
&child,
patterns,
document,
audio_dispatch,
episode_props,
server_name.clone(),
api_key.clone(),
user_id,
currently_playing_id,
start_episode_first_msg.clone(),
start_episode_first_audio_msg.clone(),
);
}
}
}
// Also update the SafeHtml component to add logging
#[function_component(SafeHtml)]
pub fn safe_html(props: &Props) -> Html {
let (i18n, _) = use_translation();
let (state, _dispatch) = use_store::<AppState>();
let (ui_state, _ui_dispatch) = use_store::<UIState>();
let api_key = state.auth_details.as_ref().map(|ud| ud.api_key.clone());
let user_id = state.user_details.as_ref().map(|ud| ud.UserID.clone());
let server_name = state.auth_details.as_ref().map(|ud| ud.server_name.clone());
let current_ep = ui_state
.currently_playing
.as_ref()
.map(|ud| ud.episode_id.clone());
// Debug all the episode props being passed
// log_debug(format!("Episode id: {:?}", props.episode_id).as_str());
// log_debug(format!("Currently play Episode id: {:?}", current_ep).as_str());
let (_, audio_dispatch) = use_store::<UIState>();
// Pre-capture translation strings
let start_episode_first_msg = i18n.t("safehtml.start_episode_first");
let start_episode_first_audio_msg = i18n.t("safehtml.start_episode_first_audio");
// Only get the audio_dispatch when timecode processing is enabled
let processed_html = if props.process_timecodes && server_name.is_some() && api_key.is_some() && user_id.is_some() {
process_timecodes(
&props.html,
audio_dispatch,
props,
server_name.unwrap(),
api_key.unwrap().unwrap(),
user_id.unwrap(),
current_ep,
start_episode_first_msg,
start_episode_first_audio_msg,
)
} else {
log_debug("Timecode processing disabled, returning original HTML");
props.html.clone()
};
// Create the div just like the original implementation
let div = match gloo_utils::document().create_element("div") {
Ok(el) => el,
Err(e) => {
log_error(format!("Failed to create div: {:?}", e).as_str());
// Fallback - we need to return something
gloo_utils::document().create_element("span").unwrap()
}
};
div.set_inner_html(&processed_html);
// Return the VRef just like the original implementation
Html::VRef(div.into())
}