feat(blog): add markdown blog sourced from a gitea repo

posts are markdown files with yaml frontmatter (title, slug, date;
optional draft/public) in the grenade/blog repo. the worker's new
BlogSource polls the repo — one branch-tip request when nothing
changed — and upserts posts into events with source='blog' and
occurred_at from the frontmatter date, so imported posts keep their
original publish dates and backfill the contribution graph.

- new /v1/blog and /v1/blog/{slug} endpoints over the existing
  EventReader port; drafts stay hidden via the public gate
- new /blog and /blog/:slug routes, nav link, activity-feed entry
  with post icon and filter toggle; relative image srcs resolve to
  gitea raw urls
- shared Markdown component extracted from ProjectPage
- vite proxy target overridable via API_PROXY_TARGET for local dev

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 22:44:56 +03:00
parent 2821548e6e
commit 88ce993df3
23 changed files with 846 additions and 61 deletions

View File

@@ -11,7 +11,7 @@ use chrono::{DateTime, Datelike, NaiveDate, Utc};
use clap::Parser;
use moments_core::{EventReader, reshape};
use moments_data::PgStore;
use moments_entities::{DailyCount, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
use moments_entities::{BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
use serde::Deserialize;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing::info;
@@ -56,6 +56,8 @@ async fn main() -> anyhow::Result<()> {
.route("/v1/events", get(list_events))
.route("/v1/sources", get(list_sources))
.route("/v1/projects", get(list_projects))
.route("/v1/blog", get(list_blog_posts))
.route("/v1/blog/{slug}", get(get_blog_post))
.route("/v1/activity/daily", get(daily_counts))
.route("/v1/activity/hourly", get(hourly_avgs))
.route("/v1/languages/daily", get(language_daily_counts))
@@ -144,6 +146,61 @@ async fn list_projects(
Ok(Json(projects))
}
/// All public blog events, newest first. Blog posts live in the events
/// table (`source = 'blog'`); the payload carries the frontmatter fields
/// and the full markdown body.
async fn blog_events(state: &AppState) -> Result<Vec<Event>, ApiError> {
let query = EventQuery {
sources: Some(vec![Source::Blog]),
// Drafts are stored with public = false and stay invisible here.
include_private: false,
limit: 1000,
..Default::default()
};
state.store.list_events(&query).await.map_err(internal)
}
fn payload_str<'a>(event: &'a Event, key: &str) -> &'a str {
event.payload.get(key).and_then(|v| v.as_str()).unwrap_or("")
}
async fn list_blog_posts(
State(state): State<AppState>,
) -> Result<Json<Vec<BlogPostSummary>>, ApiError> {
let posts = blog_events(&state)
.await?
.iter()
.map(|ev| BlogPostSummary {
slug: payload_str(ev, "slug").to_string(),
title: payload_str(ev, "title").to_string(),
published_at: ev.occurred_at,
excerpt: moments_core::presentation::blog::excerpt(payload_str(ev, "markdown")),
})
.collect();
Ok(Json(posts))
}
async fn get_blog_post(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<BlogPost>, ApiError> {
let id = format!("blog:{slug}");
let events = blog_events(&state).await?;
let ev = events.iter().find(|ev| ev.id == id).ok_or(ApiError {
status: StatusCode::NOT_FOUND,
message: "no such post".into(),
})?;
Ok(Json(BlogPost {
slug: payload_str(ev, "slug").to_string(),
title: payload_str(ev, "title").to_string(),
published_at: ev.occurred_at,
markdown: payload_str(ev, "markdown").to_string(),
host: payload_str(ev, "_host").to_string(),
repo: payload_str(ev, "_repo").to_string(),
branch: payload_str(ev, "_branch").to_string(),
}))
}
#[derive(Debug, Deserialize)]
struct DailyCountsParams {
from: Option<NaiveDate>,

View File

@@ -5,6 +5,7 @@
use moments_entities::{Event, Source, TimelineItem};
pub mod blog;
mod bugzilla;
mod gitea;
mod github;
@@ -16,5 +17,6 @@ pub fn reshape(event: &Event) -> TimelineItem {
Source::Gitea => gitea::reshape(event),
Source::Hg => hg::reshape(event),
Source::Bugzilla => bugzilla::reshape(event),
Source::Blog => blog::reshape(event),
}
}

View File

@@ -0,0 +1,138 @@
use moments_entities::{Event, Source, TimelineBody, TimelineIcon, TimelineItem, TitleSegment};
use serde_json::Value;
const EXCERPT_MAX_CHARS: usize = 280;
pub(crate) fn reshape(event: &Event) -> TimelineItem {
let p = &event.payload;
let title_text = p
.get("title")
.and_then(Value::as_str)
.unwrap_or("(untitled post)");
let slug = p.get("slug").and_then(Value::as_str).unwrap_or("");
let markdown = p.get("markdown").and_then(Value::as_str).unwrap_or("");
let title = vec![
TitleSegment::text("published "),
TitleSegment::link(title_text, format!("/blog/{slug}")),
];
let summary = excerpt(markdown);
let body = (!summary.is_empty()).then_some(TimelineBody::Markdown { text: summary });
TimelineItem {
id: event.id.clone(),
source: Source::Blog,
action: event.action.clone(),
occurred_at: event.occurred_at,
icon: TimelineIcon::Post,
title,
subtitle: None,
body,
}
}
/// First paragraph of prose from a markdown document — skips headings,
/// images, and other block furniture — truncated on a word boundary.
/// Reused by the API for `GET /v1/blog` summaries.
pub fn excerpt(markdown: &str) -> String {
let para = markdown
.split("\n\n")
.map(str::trim)
.find(|block| {
!block.is_empty()
&& !block.starts_with('#')
&& !block.starts_with("![")
&& !block.starts_with("```")
&& !block.starts_with('>')
&& !block.starts_with("---")
})
.unwrap_or("");
let para = para.replace('\n', " ");
if para.chars().count() <= EXCERPT_MAX_CHARS {
return para;
}
let cut: String = para.chars().take(EXCERPT_MAX_CHARS).collect();
let trimmed = match cut.rfind(' ') {
Some(idx) => &cut[..idx],
None => cut.as_str(),
};
format!("{}", trimmed.trim_end())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use serde_json::json;
fn ev() -> Event {
Event {
id: "blog:last-week".into(),
source: Source::Blog,
action: "publish_post".into(),
occurred_at: Utc.with_ymd_and_hms(2026, 6, 12, 0, 0, 0).unwrap(),
public: true,
payload: json!({
"title": "a watchdog, a torrent tracker, and an unhinged friday",
"slug": "last-week",
"markdown": "## monday\n\nthe week opened deep in helexa's neuron engine.\n",
"_host": "git.lair.cafe",
"_repo": "grenade/blog",
"_branch": "main",
}),
}
}
#[test]
fn reshape_links_to_blog_route() {
let item = reshape(&ev());
assert_eq!(item.icon, TimelineIcon::Post);
assert_eq!(
item.title,
vec![
TitleSegment::text("published "),
TitleSegment::link(
"a watchdog, a torrent tracker, and an unhinged friday",
"/blog/last-week"
),
]
);
match item.body {
Some(TimelineBody::Markdown { ref text }) => {
assert_eq!(text, "the week opened deep in helexa's neuron engine.")
}
other => panic!("expected markdown body, got {other:?}"),
}
}
#[test]
fn excerpt_skips_headings_and_images() {
let md = "# title\n\n![alt](img.jpg)\n\nfirst real paragraph\nwith a wrapped line\n\nsecond";
assert_eq!(excerpt(md), "first real paragraph with a wrapped line");
}
#[test]
fn excerpt_truncates_on_word_boundary() {
let long: String = (1..=100)
.map(|i| format!("w{i}"))
.collect::<Vec<_>>()
.join(" ");
let e = excerpt(&long);
assert!(e.chars().count() <= EXCERPT_MAX_CHARS + 1);
assert!(e.ends_with('…'));
let prefix = e.trim_end_matches('…');
assert!(long.starts_with(prefix), "excerpt must be a prefix: {e}");
assert_eq!(
long.as_bytes()[prefix.len()],
b' ',
"cut should land on a word boundary: {e}"
);
}
#[test]
fn excerpt_of_empty_or_heading_only_doc_is_empty() {
assert_eq!(excerpt(""), "");
assert_eq!(excerpt("# only a heading\n"), "");
}
}

View File

@@ -17,4 +17,5 @@ tracing.workspace = true
async-trait.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_yaml_ng.workspace = true
percent-encoding = "2"

View File

@@ -0,0 +1,334 @@
//! Blog post ingestion from a Gitea-hosted markdown repo.
//!
//! Posts are markdown files with YAML frontmatter (`title`, `slug`, `date`)
//! at the root of a repo. The poll cycle is cheap: one request for the
//! branch tip sha, compared against `poller_state.etag`; only when the repo
//! has new commits are the file listing and contents fetched.
//!
//! `occurred_at` comes from the frontmatter `date`, so imported posts keep
//! their original publish dates. Edits re-upsert under the same
//! `blog:{slug}` id.
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError};
use moments_entities::{Event, Source};
use reqwest::{Client, header};
use serde::Deserialize;
use serde_json::{Value, json};
use tracing::{debug, warn};
const SOURCE_NAME: &str = "blog";
const USER_AGENT: &str = concat!(
"moments/",
env!("CARGO_PKG_VERSION"),
" (+https://rob.tn)"
);
#[derive(Clone, Debug)]
pub struct BlogConfig {
/// e.g. `git.lair.cafe`. Stamped into each payload as `_host` so the
/// reshape layer and UI can build raw-content URLs without config.
pub host: String,
/// Repo holding the posts, e.g. `grenade/blog`.
pub repo: String,
pub branch: String,
/// Only needed if the repo is private (which breaks raw image URLs in
/// the UI — keep it public).
pub token: Option<String>,
}
impl Default for BlogConfig {
fn default() -> Self {
Self {
host: "git.lair.cafe".into(),
repo: "grenade/blog".into(),
branch: "main".into(),
token: None,
}
}
}
pub struct BlogSource {
client: Client,
writer: Arc<dyn EventWriter>,
state: Arc<dyn PollerStateStore>,
config: BlogConfig,
}
impl BlogSource {
pub fn new(
client: Client,
writer: Arc<dyn EventWriter>,
state: Arc<dyn PollerStateStore>,
config: BlogConfig,
) -> Self {
Self {
client,
writer,
state,
config,
}
}
fn api_url(&self, rest: &str) -> String {
format!(
"https://{}/api/v1/repos/{}/{rest}",
self.config.host, self.config.repo
)
}
fn apply_headers(&self, mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
req = req.header(header::USER_AGENT, USER_AGENT);
if let Some(token) = &self.config.token {
req = req.header(header::AUTHORIZATION, format!("token {token}"));
}
req
}
async fn get(&self, url: &str) -> Result<reqwest::Response, SourceError> {
let resp = self
.apply_headers(self.client.get(url))
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(SourceError::Http(format!("{} GET {}", resp.status(), url)));
}
Ok(resp)
}
/// Tip sha of the configured branch — the change-detection key.
async fn branch_tip(&self) -> Result<String, SourceError> {
let url = self.api_url(&format!("branches/{}", self.config.branch));
let body: Value = self
.get(&url)
.await?
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
body.get("commit")
.and_then(|c| c.get("id"))
.and_then(Value::as_str)
.map(String::from)
.ok_or_else(|| SourceError::Parse("branch response missing commit.id".into()))
}
/// Markdown file paths at the repo root, excluding READMEs.
async fn list_post_paths(&self) -> Result<Vec<String>, SourceError> {
let url = self.api_url(&format!("contents?ref={}", self.config.branch));
let entries: Vec<Value> = self
.get(&url)
.await?
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
Ok(entries
.iter()
.filter(|e| e.get("type").and_then(Value::as_str) == Some("file"))
.filter_map(|e| e.get("path").and_then(Value::as_str))
.filter(|p| {
p.to_ascii_lowercase().ends_with(".md")
&& !p.eq_ignore_ascii_case("readme.md")
})
.map(String::from)
.collect())
}
async fn fetch_raw(&self, path: &str) -> Result<String, SourceError> {
let url = self.api_url(&format!("raw/{path}?ref={}", self.config.branch));
self.get(&url)
.await?
.text()
.await
.map_err(|e| SourceError::Parse(e.to_string()))
}
}
#[async_trait]
impl EventSource for BlogSource {
fn name(&self) -> &'static str {
SOURCE_NAME
}
async fn poll(&self) -> Result<usize, SourceError> {
let tip = self.branch_tip().await?;
let prior = self.state.load(SOURCE_NAME).await?;
if prior.as_ref().and_then(|p| p.etag.as_deref()) == Some(tip.as_str()) {
self.state.touch(SOURCE_NAME).await?;
return Ok(0);
}
let mut events = Vec::new();
for path in self.list_post_paths().await? {
let content = self.fetch_raw(&path).await?;
match parse_post(&path, &content, &self.config) {
Some(event) => events.push(event),
None => warn!(path = %path, "blog post missing or invalid frontmatter; skipping"),
}
}
let total = self.writer.upsert_events(&events).await?;
self.state.save(SOURCE_NAME, Some(&tip), None).await?;
debug!(ingested = total, tip = %tip, "blog poll complete");
Ok(total)
}
}
#[derive(Debug, Deserialize)]
struct Frontmatter {
title: String,
slug: Option<String>,
date: String,
public: Option<bool>,
draft: Option<bool>,
}
/// Split `content` into (frontmatter yaml, markdown body). The file must
/// open with a `---` fence on the first line.
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let rest = content.strip_prefix("---")?;
let rest = rest.strip_prefix('\n').or_else(|| rest.strip_prefix("\r\n"))?;
// The closing fence is a `---` alone on a line.
let mut offset = 0;
for line in rest.split_inclusive('\n') {
if line.trim_end() == "---" {
let yaml = &rest[..offset];
let body = &rest[offset + line.len()..];
return Some((yaml, body));
}
offset += line.len();
}
// Closing fence with no trailing newline at EOF.
if rest[offset..].trim_end() == "---" && offset > 0 {
return Some((&rest[..offset], ""));
}
None
}
/// Accept RFC3339 (`2026-06-12T09:30:00Z`) or a bare date (`2026-06-12`,
/// treated as midnight UTC) so imported posts keep original publish dates.
fn parse_date(s: &str) -> Option<DateTime<Utc>> {
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
let date = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?;
Some(date.and_time(NaiveTime::MIN).and_utc())
}
fn parse_post(path: &str, content: &str, config: &BlogConfig) -> Option<Event> {
let (yaml, body) = split_frontmatter(content)?;
let fm: Frontmatter = serde_yaml_ng::from_str(yaml)
.map_err(|e| warn!(path = %path, error = %e, "blog frontmatter parse failed"))
.ok()?;
let occurred_at = parse_date(&fm.date)?;
let slug = fm.slug.unwrap_or_else(|| {
path.rsplit('/')
.next()
.unwrap_or(path)
.trim_end_matches(".md")
.to_string()
});
let public = fm.public.unwrap_or(true) && !fm.draft.unwrap_or(false);
Some(Event {
id: format!("blog:{slug}"),
source: Source::Blog,
action: "publish_post".into(),
occurred_at,
public,
payload: json!({
"title": fm.title,
"slug": slug,
"markdown": body.trim_start(),
"_host": config.host,
"_repo": config.repo,
"_branch": config.branch,
"_path": path,
}),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> BlogConfig {
BlogConfig::default()
}
const POST: &str = "---\ntitle: a watchdog, a torrent tracker, and an unhinged friday\nslug: last-week\ndate: 2026-06-12\n---\n\nthe week opened deep in helexa's neuron engine.\n";
#[test]
fn parse_post_with_bare_date() {
let ev = parse_post("last-week.md", POST, &config()).expect("parses");
assert_eq!(ev.id, "blog:last-week");
assert_eq!(ev.source, Source::Blog);
assert_eq!(ev.action, "publish_post");
assert_eq!(ev.occurred_at.to_rfc3339(), "2026-06-12T00:00:00+00:00");
assert!(ev.public);
assert_eq!(
ev.payload.get("title").and_then(Value::as_str),
Some("a watchdog, a torrent tracker, and an unhinged friday")
);
assert_eq!(
ev.payload.get("markdown").and_then(Value::as_str),
Some("the week opened deep in helexa's neuron engine.\n")
);
assert_eq!(
ev.payload.get("_repo").and_then(Value::as_str),
Some("grenade/blog")
);
}
#[test]
fn parse_post_with_rfc3339_date() {
let post = "---\ntitle: t\ndate: 2019-03-04T12:30:00+02:00\n---\nbody\n";
let ev = parse_post("old-post.md", post, &config()).expect("parses");
assert_eq!(ev.occurred_at.to_rfc3339(), "2019-03-04T10:30:00+00:00");
}
#[test]
fn slug_falls_back_to_filename_stem() {
let post = "---\ntitle: t\ndate: 2020-01-01\n---\nbody\n";
let ev = parse_post("posts/my-old-entry.md", post, &config()).expect("parses");
assert_eq!(ev.id, "blog:my-old-entry");
}
#[test]
fn draft_is_private() {
let post = "---\ntitle: t\ndate: 2020-01-01\ndraft: true\n---\nbody\n";
let ev = parse_post("p.md", post, &config()).expect("parses");
assert!(!ev.public);
}
#[test]
fn explicit_public_false_is_private() {
let post = "---\ntitle: t\ndate: 2020-01-01\npublic: false\n---\nbody\n";
let ev = parse_post("p.md", post, &config()).expect("parses");
assert!(!ev.public);
}
#[test]
fn missing_frontmatter_is_skipped() {
assert!(parse_post("p.md", "# just a heading\n\nbody\n", &config()).is_none());
}
#[test]
fn invalid_date_is_skipped() {
let post = "---\ntitle: t\ndate: last tuesday\n---\nbody\n";
assert!(parse_post("p.md", post, &config()).is_none());
}
#[test]
fn split_handles_crlf_and_eof_fence() {
let (yaml, body) = split_frontmatter("---\r\ntitle: t\r\n---\r\nbody").expect("splits");
assert!(yaml.contains("title: t"));
assert_eq!(body, "body");
let (yaml, body) = split_frontmatter("---\ntitle: t\n---").expect("splits at eof");
assert_eq!(yaml, "title: t\n");
assert_eq!(body, "");
}
}

View File

@@ -1,3 +1,4 @@
pub mod blog;
pub mod bugzilla;
pub mod gitea;
pub mod github;

View File

@@ -8,6 +8,7 @@ pub enum Source {
Gitea,
Hg,
Bugzilla,
Blog,
}
impl Source {
@@ -16,6 +17,7 @@ impl Source {
Source::Gitea,
Source::Hg,
Source::Bugzilla,
Source::Blog,
];
pub fn as_str(&self) -> &'static str {
@@ -24,6 +26,7 @@ impl Source {
Source::Gitea => "gitea",
Source::Hg => "hg",
Source::Bugzilla => "bugzilla",
Source::Blog => "blog",
}
}
}
@@ -37,6 +40,7 @@ impl std::str::FromStr for Source {
"gitea" => Ok(Source::Gitea),
"hg" => Ok(Source::Hg),
"bugzilla" => Ok(Source::Bugzilla),
"blog" => Ok(Source::Blog),
other => Err(ParseSourceError(other.to_string())),
}
}
@@ -204,5 +208,28 @@ pub enum TimelineIcon {
Star,
Release,
Bug,
Post,
Generic,
}
/// Blog index entry returned by `GET /v1/blog`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlogPostSummary {
pub slug: String,
pub title: String,
pub published_at: DateTime<Utc>,
pub excerpt: String,
}
/// Full blog post returned by `GET /v1/blog/{slug}`. The host/repo/branch
/// triple lets the UI resolve relative image srcs to forge raw URLs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlogPost {
pub slug: String,
pub title: String,
pub published_at: DateTime<Utc>,
pub markdown: String,
pub host: String,
pub repo: String,
pub branch: String,
}

View File

@@ -4,6 +4,7 @@ use clap::Parser;
use moments_core::{EventSource, run_poller};
use moments_data::{
PgStore,
blog::{BlogConfig, BlogSource},
bugzilla::{BugzillaConfig, BugzillaSource},
gitea::{GiteaConfig, GiteaSource},
github::{GithubConfig, GithubSource},
@@ -102,6 +103,19 @@ struct Args {
/// Seconds between bugzilla creator-query polls (defaults to 24h).
#[arg(long, env = "BUGZILLA_POLL_INTERVAL_SECS", default_value = "86400")]
bugzilla_interval_secs: u64,
/// Gitea repo holding blog posts (markdown + frontmatter at the repo
/// root, on `GITEA_HOST`). Empty string disables blog ingestion.
#[arg(long, env = "BLOG_REPO", default_value = "grenade/blog")]
blog_repo: String,
#[arg(long, env = "BLOG_BRANCH", default_value = "main")]
blog_branch: String,
/// Seconds between blog repo polls (cheap: one branch-tip request when
/// nothing changed).
#[arg(long, env = "BLOG_POLL_INTERVAL_SECS", default_value = "600")]
blog_interval_secs: u64,
}
#[tokio::main]
@@ -185,6 +199,20 @@ async fn main() -> anyhow::Result<()> {
},
)) as Arc<dyn EventSource>;
let blog = (!args.blog_repo.is_empty()).then(|| {
Arc::new(BlogSource::new(
http.clone(),
store.clone(),
store.clone(),
BlogConfig {
host: args.gitea_host.clone(),
repo: args.blog_repo.clone(),
branch: args.blog_branch.clone(),
token: args.gitea_token.clone(),
},
)) as Arc<dyn EventSource>
});
info!(
github_user = args.github_user,
gitea_host = args.gitea_host,
@@ -201,6 +229,9 @@ async fn main() -> anyhow::Result<()> {
gitea_interval_secs = args.gitea_interval_secs,
hg_interval_secs = args.hg_interval_secs,
bugzilla_interval_secs = args.bugzilla_interval_secs,
blog_repo = args.blog_repo,
blog_branch = args.blog_branch,
blog_interval_secs = args.blog_interval_secs,
"worker started"
);
@@ -210,6 +241,7 @@ async fn main() -> anyhow::Result<()> {
let gitea_interval = Duration::from_secs(args.gitea_interval_secs);
let hg_interval = Duration::from_secs(args.hg_interval_secs);
let bugzilla_interval = Duration::from_secs(args.bugzilla_interval_secs);
let blog_interval = Duration::from_secs(args.blog_interval_secs);
let github_task = tokio::spawn(async move { run_poller(github, interval).await });
let github_search_task =
@@ -220,6 +252,8 @@ async fn main() -> anyhow::Result<()> {
let hg_task = tokio::spawn(async move { run_poller(hg, hg_interval).await });
let bugzilla_task =
tokio::spawn(async move { run_poller(bugzilla, bugzilla_interval).await });
let blog_task =
blog.map(|src| tokio::spawn(async move { run_poller(src, blog_interval).await }));
tokio::signal::ctrl_c().await?;
info!("shutdown signal received");
@@ -229,6 +263,9 @@ async fn main() -> anyhow::Result<()> {
gitea_task.abort();
hg_task.abort();
bugzilla_task.abort();
if let Some(task) = blog_task {
task.abort();
}
Ok(())
}