From ae1aecf1db0140cf4a95b4ffc1ab40997bde6611 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 4 May 2026 10:36:28 +0200 Subject: [PATCH] auto-git: [change] src-tauri/src/main.rs --- src-tauri/src/main.rs | 530 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 530 insertions(+) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4ceb99b..2e04a94 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -714,6 +714,536 @@ fn get_entry_by_id(state: &AppState, id: i64) -> Result { Ok(stored.into_entry(state)) } +fn option_trimmed(value: &Option) -> Option<&str> { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn select_summary_for_discord(entry: &SummaryEntry) -> Result<&str, String> { + option_trimmed(&entry.summary_de) + .or_else(|| option_trimmed(&entry.summary_en)) + .or_else(|| option_trimmed(&entry.summary_jp)) + .ok_or_else(|| "Entry has no summary to send.".to_string()) +} + +fn parse_discord_webhook_url(raw_url: &str) -> Result { + let trimmed = raw_url.trim(); + if trimmed.is_empty() { + return Err("Add a Discord Webhook URL in Settings first.".to_string()); + } + + let url = + reqwest::Url::parse(trimmed).map_err(|_| "Discord Webhook URL is invalid.".to_string())?; + if url.scheme() != "https" { + return Err("Discord Webhook URL must use HTTPS.".to_string()); + } + + let host = url.host_str().unwrap_or_default(); + let allowed_host = matches!( + host, + "discord.com" | "discordapp.com" | "canary.discord.com" | "ptb.discord.com" + ); + if !allowed_host || !url.path().starts_with("/api/webhooks/") { + return Err("Discord Webhook URL must be a Discord webhook URL.".to_string()); + } + + Ok(url) +} + +fn char_len(text: &str) -> usize { + text.chars().count() +} + +fn take_chars(text: &str, max_chars: usize) -> String { + text.chars().take(max_chars).collect() +} + +fn skip_chars(text: &str, chars_to_skip: usize) -> String { + text.chars().skip(chars_to_skip).collect() +} + +fn limit_chars(text: &str, max_chars: usize) -> String { + if char_len(text) <= max_chars { + return text.to_string(); + } + if max_chars <= 3 { + return take_chars(text, max_chars); + } + format!("{}...", take_chars(text, max_chars - 3)) +} + +fn normalize_newlines(text: &str) -> String { + text.replace("\r\n", "\n").replace('\r', "\n") +} + +fn find_first_tag(text: &str, tags: &[&str]) -> Option<(usize, usize)> { + tags.iter() + .filter_map(|tag| text.find(tag).map(|index| (index, tag.len()))) + .min_by_key(|(index, _)| *index) +} + +fn remove_think_blocks(text: &str) -> String { + let mut output = String::new(); + let mut rest = text; + + while let Some((start, tag_len)) = find_first_tag(rest, &["", ""]) { + output.push_str(&rest[..start]); + let after_start = &rest[start + tag_len..]; + if let Some((end, end_tag_len)) = find_first_tag(after_start, &["", ""]) + { + rest = &after_start[end + end_tag_len..]; + } else { + rest = ""; + break; + } + } + + output.push_str(rest); + output +} + +fn is_sentence_start(ch: char) -> bool { + ch.is_ascii_uppercase() || ch.is_ascii_digit() || matches!(ch, '"' | '\'' | '(' | '[') +} + +fn is_word_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '.' +} + +fn is_abbreviation(token: &str) -> bool { + const ABBREVIATIONS: &[&str] = &[ + "mr.", "mrs.", "ms.", "dr.", "prof.", "sr.", "jr.", "st.", "vs.", "etc.", "e.g.", + "i.e.", "cf.", "al.", "a.m.", "p.m.", "jan.", "feb.", "mar.", "apr.", "jun.", "jul.", + "aug.", "sep.", "sept.", "oct.", "nov.", "dec.", "no.", "fig.", "eq.", "vol.", "rev.", + "gen.", "gov.", "sen.", "rep.", "dept.", "univ.", "inc.", "ltd.", "co.", "corp.", + "bros.", "approx.", "est.", "min.", "sec.", "hr.", "fr.", "bzw.", "z.b.", "d.h.", + "u.a.", "u.u.", "i.d.r.", "ca.", "ggf.", "vgl.", "evtl.", "sog.", "u.s.w.", "nr.", + "abs.", "art.", "s.", "ff.", "mio.", "mrd.", "sek.", "okt.", "dez.", + ]; + ABBREVIATIONS.contains(&token) +} + +fn is_non_terminal_punctuation_token(token: &str) -> bool { + matches!(token, "yahoo!" | "jeopardy!" | "wii!" | "o2!" | "who?") +} + +fn insert_sentence_line_breaks(text: &str) -> String { + let chars = text.chars().collect::>(); + let mut output = String::new(); + let mut i = 0; + + while i < chars.len() { + let ch = chars[i]; + output.push(ch); + + if !matches!(ch, '.' | '!' | '?') { + i += 1; + continue; + } + + let Some(next) = chars.get(i + 1) else { + i += 1; + continue; + }; + if !next.is_whitespace() { + i += 1; + continue; + } + + let mut k = i + 1; + while k < chars.len() && chars[k].is_whitespace() { + k += 1; + } + if k >= chars.len() || !is_sentence_start(chars[k]) { + i += 1; + continue; + } + + if ch == '.' + && i > 0 + && chars[i - 1].is_ascii_alphabetic() + && chars.get(k + 1) == Some(&'.') + { + i += 1; + continue; + } + + let mut j = i; + while j > 0 && is_word_char(chars[j - 1]) { + j -= 1; + } + let token = chars[j..=i] + .iter() + .collect::() + .to_ascii_lowercase(); + if ch == '.' && is_abbreviation(&token) { + i += 1; + continue; + } + if matches!(ch, '!' | '?') && is_non_terminal_punctuation_token(&token) { + i += 1; + continue; + } + + output.push('\n'); + i = k; + } + + output +} + +fn parse_markdown_heading(line: &str) -> Option<&str> { + let hash_count = line.chars().take_while(|ch| *ch == '#').count(); + if !(1..=6).contains(&hash_count) { + return None; + } + line.get(hash_count..) + .and_then(|rest| rest.strip_prefix(' ')) + .map(str::trim) + .filter(|heading| !heading.is_empty()) +} + +fn is_horizontal_rule(line: &str) -> bool { + let trimmed = line.trim(); + if char_len(trimmed) < 3 { + return false; + } + trimmed.chars().all(|ch| ch == '-') + || trimmed.chars().all(|ch| ch == '*') + || trimmed.chars().all(|ch| ch == '_') +} + +fn parse_numbered_list(line: &str) -> Option<(&str, &str)> { + let (number, rest) = line.split_once(". ")?; + if number.is_empty() || !number.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + Some((number, rest.trim())) +} + +fn parse_bullet_list(line: &str) -> Option<&str> { + line.strip_prefix("- ") + .or_else(|| line.strip_prefix("* ")) + .or_else(|| line.strip_prefix("+ ")) + .map(str::trim) +} + +fn collapse_inline_whitespace(line: &str) -> String { + line.split_whitespace().collect::>().join(" ") +} + +fn collapse_blank_lines(text: &str) -> String { + let mut lines = Vec::new(); + let mut blank_count = 0; + + for line in text.lines() { + if line.trim().is_empty() { + blank_count += 1; + if blank_count <= 1 { + lines.push(String::new()); + } + } else { + blank_count = 0; + lines.push(line.to_string()); + } + } + + lines.join("\n").trim().to_string() +} + +fn format_summary_for_discord(summary_text: &str) -> String { + let mut text = normalize_newlines(&remove_think_blocks(summary_text)); + if !text.contains('\n') && !text.contains("```") { + text = insert_sentence_line_breaks(&text); + } + + let mut formatted = Vec::new(); + let mut in_code_block = false; + let mut in_ordered_context = false; + + for raw_line in text.lines() { + let trimmed_line = raw_line.trim_end(); + let compact_line = trimmed_line.trim(); + + if compact_line.starts_with("```") { + in_code_block = !in_code_block; + formatted.push(trimmed_line.to_string()); + continue; + } + if in_code_block { + formatted.push(trimmed_line.to_string()); + continue; + } + if compact_line.is_empty() { + formatted.push(String::new()); + in_ordered_context = false; + continue; + } + + if let Some(heading) = parse_markdown_heading(compact_line) { + formatted.push(format!("**{heading}**")); + formatted.push(String::new()); + in_ordered_context = false; + continue; + } + if is_horizontal_rule(compact_line) { + formatted.push(String::new()); + in_ordered_context = false; + continue; + } + if let Some((number, rest)) = parse_numbered_list(compact_line) { + formatted.push(format!("{number}. {rest}")); + in_ordered_context = true; + continue; + } + if let Some(rest) = parse_bullet_list(compact_line) { + let indent = if in_ordered_context { " " } else { "" }; + formatted.push(format!("{indent}- {rest}")); + continue; + } + + formatted.push(collapse_inline_whitespace(compact_line)); + in_ordered_context = false; + } + + collapse_blank_lines(&formatted.join("\n")) +} + +fn split_segment_by_words(segment: &str, max_len: usize) -> Vec { + let mut chunks = Vec::new(); + let mut piece = String::new(); + + for word in segment.split(' ') { + let next = if piece.is_empty() { + word.to_string() + } else { + format!("{piece} {word}") + }; + + if char_len(&next) <= max_len { + piece = next; + continue; + } + + if !piece.is_empty() { + chunks.push(piece.trim_end().to_string()); + } + + piece = word.to_string(); + while char_len(&piece) > max_len { + chunks.push(take_chars(&piece, max_len)); + piece = skip_chars(&piece, max_len); + } + } + + if !piece.is_empty() { + chunks.push(piece.trim_end().to_string()); + } + + chunks +} + +fn split_summary_into_chunks(summary_text: &str, max_len: usize) -> Vec { + let cleaned = normalize_newlines(summary_text).trim().to_string(); + if cleaned.is_empty() { + return vec![String::new()]; + } + + let prepared = if cleaned.contains('\n') { + cleaned + } else { + insert_sentence_line_breaks(&cleaned) + }; + + let mut segments = Vec::new(); + let mut lines = prepared.split('\n').peekable(); + while let Some(line) = lines.next() { + segments.push(line.to_string()); + if lines.peek().is_some() { + segments.push("\n".to_string()); + } + } + + let mut chunks = Vec::new(); + let mut current = String::new(); + + for segment in segments { + let candidate = format!("{current}{segment}"); + if char_len(&candidate) <= max_len { + current = candidate; + continue; + } + + if !current.is_empty() { + chunks.push(current.trim_end().to_string()); + current.clear(); + } + + if char_len(&segment) <= max_len { + current = segment; + } else { + chunks.extend(split_segment_by_words(&segment, max_len)); + } + } + + if !current.is_empty() { + chunks.push(current.trim_end().to_string()); + } + + if chunks.is_empty() { + vec![String::new()] + } else { + chunks + } +} + +fn build_discord_messages( + title_line: &str, + channel_text_line: &str, + video_line: &str, + summary_text: &str, +) -> Vec { + let first_prefix = format!("{title_line}\n{channel_text_line}\n\n"); + let last_suffix = format!("\n\n{video_line}"); + + let available = |used: usize| DISCORD_MAX_MESSAGE_LENGTH.saturating_sub(used).max(1); + let first_limit = available(char_len(&first_prefix)); + let middle_limit = DISCORD_MAX_MESSAGE_LENGTH; + let last_limit = available(char_len(&last_suffix)); + let single_limit = available(char_len(&first_prefix) + char_len(&last_suffix)); + + let mut chunks = split_summary_into_chunks(summary_text, middle_limit); + if chunks.len() == 1 && char_len(&chunks[0]) > single_limit { + chunks = split_summary_into_chunks(summary_text, first_limit); + } + + if char_len(&chunks[0]) > first_limit { + let first_parts = split_summary_into_chunks(&chunks[0], first_limit); + chunks.splice(0..1, first_parts); + } + + if chunks.len() > 1 && char_len(chunks.last().unwrap_or(&String::new())) > last_limit { + let last_index = chunks.len() - 1; + let last_parts = split_summary_into_chunks(&chunks[last_index], last_limit); + chunks.splice(last_index.., last_parts); + } + + if chunks.len() == 1 { + return vec![format!("{first_prefix}{}{last_suffix}", chunks[0])]; + } + + let mut messages = Vec::new(); + messages.push(format!("{first_prefix}{}", chunks[0])); + for chunk in chunks.iter().skip(1).take(chunks.len().saturating_sub(2)) { + messages.push(chunk.to_string()); + } + messages.push(format!("{}{last_suffix}", chunks[chunks.len() - 1])); + messages +} + +fn fetch_channel_name(client: &Client, video_url: Option<&str>) -> String { + let Some(video_url) = video_url.map(str::trim).filter(|value| !value.is_empty()) else { + return "Unknown channel".to_string(); + }; + + let Ok(mut oembed_url) = reqwest::Url::parse("https://www.youtube.com/oembed") else { + return "Unknown channel".to_string(); + }; + oembed_url + .query_pairs_mut() + .append_pair("url", video_url) + .append_pair("format", "json"); + + client + .get(oembed_url) + .send() + .and_then(|response| response.error_for_status()) + .and_then(|response| response.json::()) + .ok() + .and_then(|json| { + json.get("author_name") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) + .unwrap_or_else(|| "Unknown channel".to_string()) +} + +fn post_discord_message( + client: &Client, + webhook_url: &reqwest::Url, + content: &str, +) -> Result<(), String> { + if char_len(content) > DISCORD_MAX_MESSAGE_LENGTH { + return Err("Internal error: Discord message exceeded 2000 characters.".to_string()); + } + + let response = client + .post(webhook_url.clone()) + .json(&serde_json::json!({ + "content": content, + "allowed_mentions": { + "parse": [] + } + })) + .send() + .map_err(|err| format!("Discord webhook request failed: {err}"))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().unwrap_or_default(); + return Err(format!("Discord webhook failed: HTTP {status} - {body}")); + } + + Ok(()) +} + +fn post_summary_to_discord( + client: &Client, + webhook_url: &reqwest::Url, + entry: &SummaryEntry, +) -> Result<(), String> { + let summary = select_summary_for_discord(entry)?; + let channel_name = option_trimmed(&entry.channel) + .map(str::to_string) + .unwrap_or_else(|| fetch_channel_name(client, option_trimmed(&entry.url))); + let title = option_trimmed(&entry.video_name).unwrap_or("Untitled"); + let video_url = option_trimmed(&entry.url).unwrap_or_default(); + + let title_line = limit_chars(&format!("**{title}**"), 500); + let channel_text_line = limit_chars(&channel_name, 300); + let video_line = limit_chars(&format!("Video: {video_url}"), 700); + let summary_text = format_summary_for_discord(summary); + let messages = build_discord_messages( + &title_line, + &channel_text_line, + &video_line, + &summary_text, + ); + + for (index, message) in messages.iter().enumerate() { + post_discord_message(client, webhook_url, message)?; + if index < messages.len() - 1 { + thread::sleep(Duration::from_millis(DISCORD_MESSAGE_DELAY_MS)); + } + } + + Ok(()) +} + +fn send_summary_to_discord_inner( + state: &AppState, + request: SendSummaryToDiscordRequest, +) -> Result<(), String> { + let webhook_url = parse_discord_webhook_url(&request.webhook_url)?; + let entry = get_entry_by_id(state, request.id)?; + let client = Client::new(); + post_summary_to_discord(&client, &webhook_url, &entry) +} + fn summarize_video_inner( state: &AppState, app: &AppHandle,