diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 02b34cc4b..1e896d101 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,6 +31,10 @@ jobs: run: | python extensions/cli/ccfddl/convert_to_ical.py mv *.ics public/conference/ + - name: Generate RSS feeds + run: | + python extensions/cli/ccfddl/convert_to_rss.py + mv *.xml public/conference/ # Install Rust Nightly Toolchain, with Clippy & Rustfmt - name: Install nightly Rust uses: dtolnay/rust-toolchain@nightly diff --git a/extensions/cli/ccfddl/convert_to_rss.py b/extensions/cli/ccfddl/convert_to_rss.py new file mode 100644 index 000000000..4bcc92876 --- /dev/null +++ b/extensions/cli/ccfddl/convert_to_rss.py @@ -0,0 +1,176 @@ +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime + +from convert_to_ical import load_mapping, get_timezone, reverse_index + +import yaml + + +def convert_to_rss( + file_paths: list[str], output_path: str, lang: str = "en", SUB_MAPPING={} +): + rss = ET.Element("rss", version="2.0") + channel = ET.SubElement(rss, "channel") + + ET.SubElement(channel, "title").text = ( + "CCF Conference Deadlines" if lang == "en" else "CCF 会议截止日期" + ) + ET.SubElement(channel, "link").text = "https://ccfddl.com" + ET.SubElement(channel, "description").text = ( + "Conference submission deadline tracking" + if lang == "en" + else "会议投稿截止日期追踪" + ) + ET.SubElement(channel, "language").text = "en" if lang == "en" else "zh-CN" + ET.SubElement(channel, "lastBuildDate").text = format_datetime( + datetime.now(timezone.utc) + ) + + for file_path in file_paths: + with open(file_path, "r", encoding="utf-8") as f: + conferences = yaml.safe_load(f) + + for conf_data in conferences: + title = conf_data["title"] + sub = conf_data["sub"] + sub_chinese = SUB_MAPPING.get(sub, sub) + rank = conf_data["rank"] + dblp = conf_data["dblp"] + + for conf in conf_data["confs"]: + year = conf["year"] + link = conf["link"] + timeline = conf["timeline"] + timezone_str = conf["timezone"] + place = conf["place"] + date = conf["date"] + + for entry in timeline: + try: + tz = get_timezone(timezone_str) + except ValueError: + continue + + deadlines_to_process = [] + + if "abstract_deadline" in entry: + deadlines_to_process.append( + ( + ("摘要截稿", "Abstract Deadline"), + entry["abstract_deadline"], + "abstract", + ) + ) + + if "deadline" in entry: + deadlines_to_process.append( + (("截稿日期", "Deadline"), entry["deadline"], "deadline") + ) + + if not deadlines_to_process: + continue + + for deadline_type, deadline_str, type_key in deadlines_to_process: + if deadline_str == "TBD": + continue + + try: + deadline_dt = datetime.strptime( + deadline_str, "%Y-%m-%d %H:%M:%S" + ) + except ValueError: + try: + deadline_dt = datetime.strptime( + deadline_str, "%Y-%m-%d" + ) + except ValueError: + continue + + aware_dt = deadline_dt.replace(tzinfo=tz) + + item = ET.SubElement(channel, "item") + + if lang == "en": + summary = f"{title} {year} {deadline_type[1]}" + else: + summary = f"{title} {year} {deadline_type[0]}" + + if "comment" in entry: + summary += f" [{entry['comment']}]" + + ET.SubElement(item, "title").text = summary + ET.SubElement(item, "link").text = link + + # Build description + level_desc = [ + f"CCF {rank['ccf']}" if rank["ccf"] != "N" else None, + f"CORE {rank['core']}" + if rank.get("core", "N") != "N" + else None, + f"THCPL {rank['thcpl']}" + if rank.get("thcpl", "N") != "N" + else None, + ] + level_desc = [x for x in level_desc if x] + level_str = ", ".join(level_desc) if level_desc else None + + if lang == "en": + desc_lines = [ + conf_data["description"], + f"Date: {date}", + f"Location: {place}", + f"Deadline ({timezone_str}): {deadline_str}", + f"Category: {sub_chinese} ({sub})", + level_str, + f"Conference Website: {link}", + f"DBLP: https://dblp.org/db/conf/{dblp}", + ] + else: + desc_lines = [ + conf_data["description"], + f"会议时间: {date}", + f"会议地点: {place}", + f"截止时间 ({timezone_str}): {deadline_str}", + f"分类: {sub_chinese} ({sub})", + level_str, + f"会议官网: {link}", + f"DBLP索引: https://dblp.org/db/conf/{dblp}", + ] + desc_lines = [x for x in desc_lines if x] + ET.SubElement(item, "description").text = "\n".join(desc_lines) + + ET.SubElement(item, "pubDate").text = format_datetime(aware_dt) + + guid = ET.SubElement( + item, "guid", isPermaLink="false" + ) + guid.text = f"{title}-{year}-{type_key}-{deadline_str}@ccfddl.com" + + ET.SubElement(item, "category").text = sub + + tree = ET.ElementTree(rss) + ET.indent(tree, space=" ") + with open(output_path, "wb") as f: + tree.write(f, encoding="utf-8", xml_declaration=True) + + +if __name__ == "__main__": + from xlin import ls, element_mapping + + SUB_MAPPING = load_mapping("conference/types.yml") + paths = ls("conference", filter=lambda f: f.name != "types.yml") + index = reverse_index(paths, list(SUB_MAPPING.keys())) + for lang in ["zh", "en"]: + convert_to_rss(paths, f"deadlines_{lang}.xml", lang, SUB_MAPPING) + f = lambda key: ( + len(index[key]) > 0, + convert_to_rss( + index[key], + f"deadlines_{lang}_{key.replace('*', 'star')}.xml", + lang, + SUB_MAPPING, + ), + ) + element_mapping(index.keys(), f, thread_pool_size=8) + print("RSS feed generation complete") diff --git a/index.html b/index.html index e5a3a93a1..cfdd9e707 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,8 @@ + + diff --git a/src/components/subscription_modal.rs b/src/components/subscription_modal.rs index edaf58337..1b2bcab6d 100644 --- a/src/components/subscription_modal.rs +++ b/src/components/subscription_modal.rs @@ -6,11 +6,13 @@ use web_sys::js_sys; use web_sys::window; #[derive(Clone, Debug, PartialEq)] -pub struct IcsSubscription { +pub struct SubscriptionLink { pub url: String, pub description: String, } +pub type IcsSubscription = SubscriptionLink; + pub fn generate_ics_urls( lang: &str, subs: &HashSet, @@ -67,6 +69,62 @@ pub fn generate_ics_urls( urls } +pub fn generate_rss_urls( + lang: &str, + subs: &HashSet, + ranks: &HashSet, +) -> Vec { + let base_url = "https://ccfddl.com/conference"; + let mut urls = Vec::new(); + + if subs.is_empty() && ranks.is_empty() { + urls.push(SubscriptionLink { + url: format!("{}/deadlines_{}.xml", base_url, lang), + description: if lang == "zh" { + "所有会议".to_string() + } else { + "All Conferences".to_string() + }, + }); + return urls; + } + + if !subs.is_empty() && !ranks.is_empty() { + for sub in subs.iter() { + for rank in ranks.iter() { + let rank_prefix = get_rank_prefix(rank); + urls.push(SubscriptionLink { + url: format!( + "{}/deadlines_{}_{}_{}_{}.xml", + base_url, lang, rank_prefix, rank, sub + ), + description: format!("{} {} {}", sub, rank_prefix.to_uppercase(), rank), + }); + } + } + } else if !subs.is_empty() { + for sub in subs.iter() { + urls.push(SubscriptionLink { + url: format!("{}/deadlines_{}_{}.xml", base_url, lang, sub), + description: sub.clone(), + }); + } + } else if !ranks.is_empty() { + for rank in ranks.iter() { + let rank_prefix = get_rank_prefix(rank); + urls.push(SubscriptionLink { + url: format!( + "{}/deadlines_{}_{}_{}.xml", + base_url, lang, rank_prefix, rank + ), + description: format!("{} {}", rank_prefix.to_uppercase(), rank), + }); + } + } + + urls +} + fn get_rank_prefix(rank: &str) -> &'static str { match rank { "A" | "B" | "C" | "N" => "ccf", @@ -173,6 +231,13 @@ pub fn SubscriptionModal( generate_ics_urls(lang, &subs, &ranks) }); + let rss_subscriptions = Memo::new(move |_| { + let lang = if use_english.get() { "en" } else { "zh" }; + let subs = check_list.get(); + let ranks = rank_list.get(); + generate_rss_urls(lang, &subs, &ranks) + }); + let platform_hint = get_platform_instruction(use_english.get_untracked()); view! { @@ -284,6 +349,92 @@ pub fn SubscriptionModal( }} +
+
+ {move || { + if use_english.get() { + "RSS Feed:" + } else { + "RSS 订阅:" + } + }} +
+ + {move || { + let subs = rss_subscriptions.get(); + if subs.len() > 10 { + let msg = if use_english.get() { + format!( + "Too many filter combinations ({} links). Consider reducing filters or subscribe to all.", + subs.len(), + ) + } else { + format!( + "筛选组合过多({} 个链接)。建议减少筛选条件或订阅全部。", + subs.len(), + ) + }; + view! { +
+ {msg} +
+ } + .into_any() + } else { + view! { +
+ {subs + .iter() + .enumerate() + .map(|(idx, sub)| { + let url = sub.url.clone(); + let url_for_copy = url.clone(); + let desc = sub.description.clone(); + let label = format!("{}. {}", idx + 1, desc); + view! { +
+
+ {label} +
+
+ + +
+
+ } + }) + .collect::>()} +
+ } + .into_any() + } + }} + +
+ {move || { + if use_english.get() { + "Paste into your RSS reader" + } else { + "粘贴到 RSS 阅读器中" + } + }} +
+
+
{move || {