mirror of
https://github.com/Lumither/nocturne.git
synced 2026-10-17 10:26:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6c7602123 |
No files matched your search
@@ -8,6 +8,3 @@
|
||||
|
||||
# exclude post repo
|
||||
/blog_posts_git
|
||||
|
||||
# rustc ICE dump
|
||||
rustc-ice-*.txt
|
||||
@@ -3,7 +3,14 @@ ifeq ($(ARCH), arm64)
|
||||
ARCH := aarch64
|
||||
endif
|
||||
|
||||
.PHONY: up
|
||||
clean:
|
||||
echo "todo"
|
||||
all: release
|
||||
|
||||
release:
|
||||
docker compose -f prod.compose.yaml build --build-arg ARCH=$(ARCH)
|
||||
|
||||
dev:
|
||||
docker-compose -f dev.compose.yaml up --build
|
||||
|
||||
clean:
|
||||
docker-compose -f prod.compose.yaml -f dev.compose.yaml rm -fsv
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
# `Nocturne: Personal Website`
|
||||
# `Nocturne: personal website & CMS`
|
||||
|
||||
This is the code repo of my [personal website](https://lumither.com),
|
||||
and [here](https://github.com/Lumither/blog-posts/) is the link to the
|
||||
content (blog posts etc.) repo.
|
||||
> This is the last working version
|
||||
|
||||
This is the code repo of my personal website, and [here](https://github.com/Lumither/blog-posts/) is the link of the
|
||||
content repo.
|
||||
|
||||
Powered by Next.js and Rust, built with passion and love
|
||||
|
||||
> [!NOTE]
|
||||
> This system is not production ready, and contains bugs, (security risk potentially).
|
||||
> But have fun on it =)
|
||||
*will add more here in the future
|
||||
|
||||
# Todos
|
||||
## Nav:
|
||||
|
||||
<todo>
|
||||
|
||||
## Todos:
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] Add: log system
|
||||
- [ ] Add: search api
|
||||
- [ ] Add: tag filter (api)
|
||||
- [ ] Abandon: UUID mechanism
|
||||
- [ ] Fix: db connection issue
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Page: Home
|
||||
- [ ] Page: About
|
||||
- [ ] Page: Tags
|
||||
- [ ] Page: Archive
|
||||
- [ ] Page: Search
|
||||
- [ ] Add: Right sidebar
|
||||
|
||||
- [ ] the todo list lol
|
||||
- [ ] and much more...
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
resolver = "2"
|
||||
members = [
|
||||
"server",
|
||||
"api_server",
|
||||
"constants",
|
||||
"macros",
|
||||
"markdown",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "api_server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
macros = { path = "../macros" }
|
||||
constants = { path = "../constants" }
|
||||
markdown = { path = "../markdown" }
|
||||
chrono = { version = "0.4.38", features = ["serde", "alloc"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "fmt"] }
|
||||
tracing-appender = "0.2.3"
|
||||
dotenv = "0.15.0"
|
||||
dirs = "5.0.1"
|
||||
tokio = { version = "1.40.0", features = ["full"] }
|
||||
sqlx = { version = "0.8.2", features = ["runtime-tokio", "tls-native-tls", "postgres", "chrono", "uuid"] }
|
||||
axum = { version = "0.7.5", features = ["macros"] }
|
||||
serde = { version = "1.0.199", features = ["derive"] }
|
||||
serde_json = "1.0.128"
|
||||
uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] }
|
||||
futures = { version = "0.3.30", features = ["executor"] }
|
||||
cron = "0.13.0"
|
||||
git2 = "0.19.0"
|
||||
thiserror = "2.0.3"
|
||||
@@ -0,0 +1,26 @@
|
||||
use constants::blog::PAGE_SIZE;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{query, PgPool, Row};
|
||||
use tracing::error;
|
||||
|
||||
pub async fn get_page_count(
|
||||
State(db_connection): State<PgPool>,
|
||||
) -> Result<Json<Value>, Json<Value>> {
|
||||
match query("select cast(ceil(count(*) / $1) as integer) as res from post")
|
||||
.bind(PAGE_SIZE as f32)
|
||||
.fetch_one(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(res) => {
|
||||
let res: i32 = res.get("res");
|
||||
Ok(Json::from(json!({"res": res})))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get blog page count: {}", e.to_string());
|
||||
Err(Json::from(json!({"error": e.to_string()})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{query, Error, PgPool, Row};
|
||||
use std::str::FromStr;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_post(
|
||||
State(db_connection): State<PgPool>,
|
||||
Path(post_id): Path<String>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let post_id = match Uuid::from_str(post_id.as_str()) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to parse UUID <{}>: {}",
|
||||
post_id.as_str(),
|
||||
e.to_string()
|
||||
);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json::from(json!(("error", e.to_string()))),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let post = match query(
|
||||
r#"
|
||||
SELECT jsonb_strip_nulls(to_jsonb(t.*)) - 'post_id' AS post
|
||||
FROM post t
|
||||
WHERE post_id = $1;
|
||||
"#,
|
||||
)
|
||||
.bind(post_id)
|
||||
.fetch_one(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value.get::<Value, _>("post"),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Database error on Reading `base post information` from table `post`: <{}>: {}",
|
||||
post_id.to_string(),
|
||||
e.to_string()
|
||||
);
|
||||
return match e {
|
||||
Error::RowNotFound => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json::from(json!({"error": e.to_string()})),
|
||||
)),
|
||||
_ => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json::from(json!({"error": e.to_string()})),
|
||||
)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let tags: Vec<_> = match query("SELECT tag FROM Tag WHERE post_id = $1;")
|
||||
.bind(post_id)
|
||||
.fetch_all(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(tags) => tags.iter().map(|tag| tag.get::<String, _>("tag")).collect(),
|
||||
Err(e) => {
|
||||
error!("Database query error :{}", e.to_string());
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json::from(json!({"error": e.to_string()})),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let meta = match query(
|
||||
r#"
|
||||
SELECT jsonb_strip_nulls(to_jsonb(t.*)) - 'post_id' AS meta
|
||||
FROM meta t
|
||||
WHERE post_id = $1;
|
||||
"#,
|
||||
)
|
||||
.bind(post_id)
|
||||
.fetch_one(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value.get::<Value, _>("meta"),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Database error on Reading `{}` from table `{}`: <{}>: {}",
|
||||
"post meta data",
|
||||
"meta",
|
||||
post_id.to_string(),
|
||||
e.to_string()
|
||||
);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json::from(json!({"error": e.to_string()})),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let adjacent = match query(r"
|
||||
WITH target_post AS (SELECT t.date FROM meta t WHERE post_id = $1),
|
||||
prev AS (SELECT post_id, t.title, t.sub_title, t.header_img
|
||||
FROM meta t
|
||||
WHERE t.date < (SELECT date FROM target_post)
|
||||
ORDER BY t.date DESC
|
||||
LIMIT 1),
|
||||
next AS (SELECT post_id, t.title, t.sub_title, t.header_img
|
||||
FROM meta t
|
||||
WHERE t.date > (SELECT date FROM target_post)
|
||||
ORDER BY t.date
|
||||
LIMIT 1)
|
||||
SELECT json_strip_nulls(json_build_object(
|
||||
'prev',
|
||||
(SELECT json_build_object('id', post_id, 'title', title, 'sub_title', sub_title, 'header_img', header_img)
|
||||
FROM prev),
|
||||
'next',
|
||||
(SELECT json_build_object('id', post_id, 'title', title, 'sub_title', sub_title, 'header_img', header_img)
|
||||
FROM next)
|
||||
)) AS result;
|
||||
"
|
||||
)
|
||||
.bind(post_id)
|
||||
.fetch_one(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(res) => {res.get::<Value, _>("result")}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Database error on Reading `{}` from table `{}`: <{}>: {}",
|
||||
"adjoint posts data",
|
||||
"meta",
|
||||
post_id.to_string(),
|
||||
e.to_string()
|
||||
);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json::from(json!({"error": e.to_string()})),
|
||||
));
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
let mut res = post.as_object().unwrap().to_owned();
|
||||
res.insert("tags".to_string(), tags.into());
|
||||
res.insert("meta".to_string(), meta);
|
||||
res.insert("adj".to_string(), adjacent);
|
||||
|
||||
Ok(Json::from(json!(res)))
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
use std::error::Error;
|
||||
|
||||
use crate::constants::blog::PAGE_SIZE;
|
||||
use constants::blog::PAGE_SIZE;
|
||||
|
||||
use axum::{extract::Query, extract::State, http::StatusCode, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod get_page_count;
|
||||
pub mod get_post;
|
||||
pub mod get_post_list;
|
||||
File renamed without changes.
+9
-9
@@ -1,10 +1,10 @@
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::blog::cron::check_update::utils::get_md_file_basename;
|
||||
use crate::blog::cron::check_update::{error::PostIdxError, utils::parse_date};
|
||||
use markdown::MdFile;
|
||||
|
||||
use crate::modules::blog::components::check_update::utils::get_md_file_basename;
|
||||
use crate::modules::blog::components::check_update::{error::PostIdxError, utils::parse_date};
|
||||
use sqlx::{query, PgPool, Pool, Postgres, Row};
|
||||
use tokio::runtime::Runtime;
|
||||
use tracing::{info, warn};
|
||||
@@ -16,7 +16,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
||||
return Err(PostIdxError::MissingField {
|
||||
field: "title".to_string(),
|
||||
filename: post.filename.clone(),
|
||||
});
|
||||
})
|
||||
}
|
||||
Some(title) => title.as_str().unwrap_or_else(|| {
|
||||
warn!("invalid title for `{}`", &post.filename);
|
||||
@@ -39,7 +39,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
||||
return Err(PostIdxError::MissingField {
|
||||
field: "category".to_string(),
|
||||
filename: post.filename.clone(),
|
||||
});
|
||||
})
|
||||
}
|
||||
Some(title) => title.as_str().unwrap_or_else(|| {
|
||||
warn!("invalid category for `{}`", &post.filename);
|
||||
@@ -56,7 +56,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
||||
err_field: last_update.to_string(),
|
||||
filename: post.filename.clone(),
|
||||
msg: e.to_string(),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -68,7 +68,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
||||
return Err(PostIdxError::MissingField {
|
||||
field: "date".to_string(),
|
||||
filename: post.filename.clone(),
|
||||
});
|
||||
})
|
||||
}
|
||||
Some(first_update) => match parse_date(first_update.as_str().unwrap()) {
|
||||
Ok(date) => date,
|
||||
@@ -78,7 +78,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
||||
err_field: first_update.to_string(),
|
||||
filename: post.filename.clone(),
|
||||
msg: e.to_string(),
|
||||
});
|
||||
})
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -135,7 +135,7 @@ async fn tag(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
||||
return Err(PostIdxError::MissingField {
|
||||
field: "tags".to_string(),
|
||||
filename: post.filename.clone(),
|
||||
});
|
||||
})
|
||||
}
|
||||
Some(val) => val,
|
||||
};
|
||||
@@ -171,7 +171,7 @@ async fn tag(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
||||
db_table: "Tag".to_string(),
|
||||
id: post_id.to_string(),
|
||||
msg: e.to_string(),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::env;
|
||||
use std::fs::remove_dir_all;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{
|
||||
blog::cron::check_update::{
|
||||
index::drop_index,
|
||||
utils::{expand_path, get_md_file_basename, index_files, search_md},
|
||||
},
|
||||
utils::git,
|
||||
};
|
||||
use constants::config::general::{default_value, var_name};
|
||||
use macros::panic_with_log;
|
||||
|
||||
use git2::{Delta, Repository};
|
||||
use sqlx::{Pool, Postgres};
|
||||
use tracing::{error, info, warn, Level};
|
||||
|
||||
mod error;
|
||||
mod index;
|
||||
mod utils;
|
||||
|
||||
pub fn task(db_connection: &Pool<Postgres>) {
|
||||
// init
|
||||
let git_work_dir = {
|
||||
let path = if let Ok(default_log_path) = env::var(var_name::WORK_DIR) {
|
||||
expand_path(default_log_path)
|
||||
} else {
|
||||
let fallback_path = expand_path(default_value::WORK_DIR.to_string());
|
||||
warn!(
|
||||
"unset env var `{}`, using default path: {}/blog_git",
|
||||
var_name::WORK_DIR,
|
||||
fallback_path
|
||||
);
|
||||
fallback_path
|
||||
};
|
||||
Path::new(&path).join("blog_git")
|
||||
};
|
||||
|
||||
// clone & check update
|
||||
|
||||
let git_url = match env::var(var_name::BLOG_GIT_URL) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
panic_with_log!(
|
||||
Level::ERROR,
|
||||
"failed to read `{}`: {}",
|
||||
var_name::BLOG_GIT_URL,
|
||||
e
|
||||
)
|
||||
}
|
||||
};
|
||||
let git_remote_name = env::var(var_name::BLOG_GIT_REMOTE_NAME)
|
||||
.unwrap_or(default_value::BLOG_GIT_REMOTE_NAME.to_string());
|
||||
let git_remote_branch = env::var(var_name::BLOG_GIT_REMOTE_BRANCH)
|
||||
.unwrap_or(default_value::BLOG_GIT_REMOTE_BRANCH.to_string());
|
||||
|
||||
match Repository::open(&git_work_dir) {
|
||||
Ok(repo) => {
|
||||
// prevent git remote url mismatch (clean cache)
|
||||
match repo.find_remote(&git_remote_name) {
|
||||
Ok(remote) => {
|
||||
if remote.url().is_some_and(|url| url != git_url) {
|
||||
remove_dir_all(&git_work_dir).unwrap();
|
||||
warn!("blog content git({}) url mismatch, reinitialized", &git_url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
remove_dir_all(&git_work_dir).unwrap();
|
||||
error!(
|
||||
"unexpected error on indexing blog content git({}), reinitialized: {}",
|
||||
&git_url, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match git::sync(&git_remote_name, &git_remote_branch, &repo) {
|
||||
Ok(updates) => {
|
||||
if !updates.is_empty() {
|
||||
// incremental update
|
||||
info!(
|
||||
"blog content git({}) update detected: {:?}",
|
||||
&git_url, updates
|
||||
);
|
||||
|
||||
let mut update_md: Vec<PathBuf> = vec![];
|
||||
|
||||
for update in updates {
|
||||
if [Delta::Deleted, Delta::Untracked].contains(&update.status) {
|
||||
if let Some(path) = &update.old_path {
|
||||
if drop_index(
|
||||
&get_md_file_basename(
|
||||
path.file_name().unwrap().to_str().unwrap(),
|
||||
),
|
||||
db_connection,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
info!(
|
||||
"blog content git({}) file `{}` is detected as deleted/untracked, index dropped",
|
||||
&git_url,
|
||||
&path.display()
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
"blog content git({}) file `{}` is detected as deleted/untracked, failed to drop index",
|
||||
&git_url,
|
||||
&path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if let Some(path) = &update.new_path {
|
||||
if path.components().any(|comp| comp.as_os_str() == "posts")
|
||||
&& path.extension().is_some_and(|ext| ext == "md")
|
||||
{
|
||||
update_md.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
index_files(&update_md, db_connection);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"failed to sync blog content git({}) updates: {}",
|
||||
&git_url, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
match Repository::clone(&git_url, &git_work_dir) {
|
||||
Ok(_repo) => {
|
||||
// full repo index
|
||||
let files = search_md(git_work_dir.join("posts"));
|
||||
index_files(&files, db_connection);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("failed to clone blog content git({}): {}", &git_url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use dirs::home_dir;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::blog::cron::check_update::index::index_file;
|
||||
use chrono::{DateTime, FixedOffset, ParseResult};
|
||||
use constants::config::general::{default_value, var_name};
|
||||
use constants::time::DEFAULT_DATE_FORMAT;
|
||||
|
||||
use futures::executor::block_on;
|
||||
use futures::future::join_all;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::spawn;
|
||||
use tracing::{error, warn};
|
||||
|
||||
pub fn search_md(entry: PathBuf) -> Vec<PathBuf> {
|
||||
if entry.ends_with(".git") {
|
||||
return vec![];
|
||||
}
|
||||
if entry.is_dir() {
|
||||
if let Ok(files) = entry.read_dir() {
|
||||
files
|
||||
.flat_map(|file| {
|
||||
let file_path = file.unwrap().path();
|
||||
search_md(file_path)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else if entry.extension().is_some_and(|ext| ext == "md") {
|
||||
vec![entry]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn index_files(files: &[PathBuf], db_conn: &Pool<Postgres>) {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let files_clone = files.to_owned();
|
||||
let handlers = files_clone
|
||||
.into_iter()
|
||||
.map(|file: PathBuf| {
|
||||
let conn_clone = db_conn.clone();
|
||||
spawn(async move {
|
||||
if let Err(e) = index_file(&file, &conn_clone).await {
|
||||
error!("failed to index markdown file `{}`: {}", file.display(), e);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
block_on(join_all(handlers));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn expand_path(path: String) -> String {
|
||||
if path.starts_with('~') {
|
||||
home_dir()
|
||||
.map(|dir| {
|
||||
dir.join(path.strip_prefix("~/").unwrap())
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.unwrap()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
warn!(
|
||||
"failed to parse env var `{}`, using default value `{}`",
|
||||
var_name::WORK_DIR,
|
||||
default_value::WORK_DIR
|
||||
);
|
||||
default_value::WORK_DIR.to_string()
|
||||
})
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_date(date_str: &str) -> ParseResult<DateTime<FixedOffset>> {
|
||||
DateTime::parse_from_str(format!("{}T120000", date_str).as_str(), DEFAULT_DATE_FORMAT)
|
||||
}
|
||||
|
||||
pub fn get_md_file_basename(f_name: &str) -> String {
|
||||
f_name.replace(".md", "")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::scheduler::tasks::basic::BasicTask;
|
||||
use crate::scheduler::tasks::Task;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
pub mod check_update;
|
||||
|
||||
pub fn get_tasks(db_connection: Pool<Postgres>) -> Vec<Box<dyn Task>> {
|
||||
let db_conn_clone = db_connection.clone();
|
||||
vec![
|
||||
BasicTask::new(move || check_update::task(&db_conn_clone), "0/15 * * * * *")
|
||||
.unwrap()
|
||||
.to_task(),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
mod api;
|
||||
pub mod cron;
|
||||
|
||||
use crate::blog::api::{
|
||||
get_page_count::get_page_count, get_post::get_post, get_post_list::get_post_list,
|
||||
};
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
pub fn get_router() -> Router<Pool<Postgres>> {
|
||||
Router::new()
|
||||
.route("/get_post_list", get(get_post_list))
|
||||
.route("/get_page_count", get(get_page_count))
|
||||
.route("/get_post/:post_id", get(get_post))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::blog;
|
||||
use crate::scheduler::Scheduler;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
pub fn start(db_pool: Pool<Postgres>) {
|
||||
let scheduler = Scheduler::new();
|
||||
let _ = vec![blog::cron::get_tasks(db_pool.clone())]
|
||||
.into_iter()
|
||||
.map(|list| scheduler.insert_list(list))
|
||||
.collect::<Vec<_>>();
|
||||
scheduler.start().unwrap()
|
||||
}
|
||||
@@ -2,8 +2,7 @@ use std::env;
|
||||
use std::error::Error;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::constants::config::database::{default_value, var_name};
|
||||
|
||||
use constants::config::database::{default_value, var_name};
|
||||
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
||||
use sqlx::{ConnectOptions, Pool, Postgres};
|
||||
use tracing::info;
|
||||
@@ -13,7 +12,7 @@ pub async fn init() -> Result<Pool<Postgres>, Box<dyn Error>> {
|
||||
.unwrap()
|
||||
.disable_statement_logging();
|
||||
Ok(PgPoolOptions::new()
|
||||
.max_connections(1) // todo: temporary solution, to be fixed
|
||||
.max_connections(1) // todo: temporal solution, to be fixed
|
||||
.connect_with(db_connect_option)
|
||||
.await?)
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
use std::path::Path;
|
||||
use std::{env, io};
|
||||
|
||||
use crate::constants::config::general::{default_value, var_name};
|
||||
|
||||
use chrono::Local;
|
||||
use constants::config::general::{default_value, var_name};
|
||||
use dirs::home_dir;
|
||||
use tracing::info;
|
||||
use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling};
|
||||
use tracing_subscriber::{
|
||||
Layer, Registry, filter::LevelFilter, fmt, fmt::MakeWriter, fmt::format::Writer,
|
||||
fmt::time::FormatTime, layer::SubscriberExt,
|
||||
filter::LevelFilter, fmt, fmt::format::Writer, fmt::time::FormatTime, fmt::MakeWriter,
|
||||
layer::SubscriberExt, Layer, Registry,
|
||||
};
|
||||
|
||||
struct LocalTimer;
|
||||
@@ -19,7 +18,7 @@ impl FormatTime for LocalTimer {
|
||||
write!(
|
||||
w,
|
||||
"{}",
|
||||
Local::now().format(crate::constants::time::DEFAULT_TIME_FORMAT)
|
||||
Local::now().format(constants::time::DEFAULT_TIME_FORMAT)
|
||||
)
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -1,13 +1,13 @@
|
||||
mod cron;
|
||||
mod database;
|
||||
pub mod logger;
|
||||
mod misc;
|
||||
mod schema;
|
||||
pub mod server;
|
||||
|
||||
use macros::panic_with_log;
|
||||
use std::{env, panic};
|
||||
|
||||
use crate::modules::{ModuleTree, blog::Blog};
|
||||
use macros::error_panic;
|
||||
|
||||
use tracing::info;
|
||||
use tracing::{info, Level};
|
||||
|
||||
pub fn load_env() {
|
||||
if let Some(env_file) = env::args().nth(1) {
|
||||
@@ -34,17 +34,25 @@ pub async fn start() {
|
||||
pool
|
||||
}
|
||||
Err(e) => {
|
||||
error_panic!("database connection pool failed to initialize: {}", e);
|
||||
panic_with_log!(
|
||||
Level::ERROR,
|
||||
"database connection pool failed to initialize: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// // schema init
|
||||
// schema::init(&db_pool).await;
|
||||
// schema init
|
||||
schema::init(&db_pool).await;
|
||||
|
||||
ModuleTree::new()
|
||||
.add_module(Blog {
|
||||
db_handler: db_pool,
|
||||
})
|
||||
.blocking_serve()
|
||||
.await
|
||||
// load cron task
|
||||
cron::start(db_pool.clone());
|
||||
|
||||
// api serve
|
||||
match server::start(db_pool).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
panic_with_log!(Level::ERROR, "server panicked: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use sqlx::{query, Pool, Postgres};
|
||||
use tracing::info;
|
||||
|
||||
const POST_BASE_MODEL: &str = r##"
|
||||
CREATE TABLE IF NOT EXISTS Post
|
||||
(
|
||||
post_id UUID PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(255) NOT NULL,
|
||||
filename TEXT UNIQUE NOT NULL,
|
||||
summary TEXT,
|
||||
sub_title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
last_update TIMESTAMPTZ,
|
||||
first_update TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
"##;
|
||||
|
||||
// base model, will add more cols at runtime
|
||||
const POST_META_MODEL: &str = r##"
|
||||
CREATE TABLE IF NOT EXISTS Meta
|
||||
(
|
||||
post_id UUID PRIMARY KEY
|
||||
);
|
||||
"##;
|
||||
|
||||
const POST_HASH_MODEL: &str = r##"
|
||||
CREATE TABLE IF NOT EXISTS Hash
|
||||
(
|
||||
post_id UUID PRIMARY KEY,
|
||||
hash VARCHAR(44)
|
||||
);
|
||||
"##;
|
||||
|
||||
const TAG_MODEL: &str = r##"
|
||||
CREATE TABLE IF NOT EXISTS Tag
|
||||
(
|
||||
post_id UUID NOT NULL,
|
||||
tag VARCHAR(255) NOT NULL
|
||||
);
|
||||
"##;
|
||||
|
||||
pub async fn init(db_connection: &Pool<Postgres>) {
|
||||
query(POST_BASE_MODEL).execute(db_connection).await.unwrap();
|
||||
query(POST_META_MODEL).execute(db_connection).await.unwrap();
|
||||
query(TAG_MODEL).execute(db_connection).await.unwrap();
|
||||
query(POST_HASH_MODEL).execute(db_connection).await.unwrap();
|
||||
info!("database schema initialized");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::env;
|
||||
use std::error::Error;
|
||||
use std::num::ParseIntError;
|
||||
use std::process::exit;
|
||||
|
||||
use crate::blog;
|
||||
use axum::Router;
|
||||
use constants::config::server::{default_value, var_name};
|
||||
use macros::panic_with_log;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use tracing::{error, info, warn, Level};
|
||||
|
||||
pub async fn start(db_pool: Pool<Postgres>) -> Result<(), Box<dyn Error>> {
|
||||
let app = Router::new()
|
||||
.nest("/blog", blog::get_router())
|
||||
.with_state(db_pool);
|
||||
|
||||
let port: u32 = match env::var(var_name::PORT) {
|
||||
Ok(value) => value.parse().unwrap_or_else(|e: ParseIntError| {
|
||||
warn!(
|
||||
"failed to parse `{}`, using default port: {}",
|
||||
var_name::PORT,
|
||||
e.to_string()
|
||||
);
|
||||
default_value::PORT
|
||||
}),
|
||||
Err(_) => default_value::PORT,
|
||||
};
|
||||
|
||||
let listener =
|
||||
match tokio::net::TcpListener::bind(format!("{}:{}", default_value::HOST, port)).await {
|
||||
Ok(listener) => {
|
||||
info!("server started on {}:{}", default_value::HOST, port);
|
||||
listener
|
||||
}
|
||||
Err(e) => {
|
||||
panic_with_log!(
|
||||
Level::ERROR,
|
||||
"failed to start server on {}:{}: {}",
|
||||
default_value::HOST,
|
||||
port,
|
||||
e.to_string()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
axum::serve(listener, app).await.unwrap_or_else(|e| {
|
||||
error!("failed to start axum: {}", e.to_string());
|
||||
exit(1);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
mod blog;
|
||||
mod init;
|
||||
mod scheduler;
|
||||
mod utils;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init::load_env();
|
||||
|
||||
let _guards = init::logger::init();
|
||||
|
||||
init::start().await;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use crate::{
|
||||
scheduler::scheduler_error::SchedulerError,
|
||||
scheduler::scheduler_error::SchedulerError::IdAlreadyRunning,
|
||||
scheduler::scheduler_error::SchedulerError::IdNotFound, scheduler::sleeper::SkippableSleeper,
|
||||
scheduler::tasks::Task,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::RwLock;
|
||||
use std::{collections::HashMap, sync::Arc, thread};
|
||||
use tracing::log::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod scheduler_error;
|
||||
mod sleeper;
|
||||
mod task_func;
|
||||
pub mod tasks;
|
||||
|
||||
struct TaskStatus {
|
||||
sleeper: Option<Arc<SkippableSleeper>>,
|
||||
is_alive: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Scheduler {
|
||||
tasks: Arc<RwLock<HashMap<Uuid, Arc<Box<dyn Task>>>>>,
|
||||
running: Arc<RwLock<HashMap<Uuid, Arc<TaskStatus>>>>,
|
||||
}
|
||||
|
||||
impl Scheduler {
|
||||
/// Start all tasks in the scheduler
|
||||
pub fn start(&self) -> Result<(), SchedulerError> {
|
||||
for (k, _v) in self.tasks.read()?.iter() {
|
||||
self.run_non_blocking(k)?
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Terminate all tasks in the scheduler
|
||||
pub fn halt() {}
|
||||
|
||||
fn run_task(
|
||||
running: Arc<RwLock<HashMap<Uuid, Arc<TaskStatus>>>>,
|
||||
id: Uuid,
|
||||
task: Arc<Box<dyn Task>>,
|
||||
) -> Result<(), SchedulerError> {
|
||||
if running.read()?.get(&id).is_some() {
|
||||
return Err(IdAlreadyRunning);
|
||||
} else {
|
||||
running.write()?.insert(
|
||||
id,
|
||||
TaskStatus {
|
||||
sleeper: None,
|
||||
is_alive: AtomicBool::new(true),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
loop {
|
||||
if let Some(next_execution) = task.get_next_execution() {
|
||||
// guarantee is alive
|
||||
let task_status = {
|
||||
let running_reading_guard = running.read()?;
|
||||
running_reading_guard.get(&id).cloned().clone()
|
||||
};
|
||||
if let Some(task_status) = task_status {
|
||||
if !&task_status.is_alive.load(Ordering::SeqCst) {
|
||||
{
|
||||
let mut running_lock_guard = running.write()?;
|
||||
running_lock_guard.remove(&id);
|
||||
}
|
||||
return Ok(());
|
||||
} else {
|
||||
let sleeper = Arc::from(SkippableSleeper::from(next_execution));
|
||||
{
|
||||
let mut running_lock_guard = running.write()?;
|
||||
running_lock_guard.insert(
|
||||
id,
|
||||
TaskStatus {
|
||||
sleeper: Some(sleeper.clone()),
|
||||
is_alive: AtomicBool::new(true),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
sleeper.start();
|
||||
task.call();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("unexpected error ");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a task with `id`
|
||||
pub fn run(&self, id: &Uuid) -> Result<(), SchedulerError> {
|
||||
let id = id.to_owned();
|
||||
if let Some(task) = self.tasks.read()?.get(&id) {
|
||||
Self::run_task(self.running.clone(), id, task.clone())
|
||||
} else {
|
||||
Err(IdNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_non_blocking(&self, id: &Uuid) -> Result<(), SchedulerError> {
|
||||
let id = id.to_owned();
|
||||
if let Some(task) = self.tasks.read()?.get(&id) {
|
||||
let running_clone = self.running.clone();
|
||||
let task_clone = task.clone();
|
||||
thread::spawn(move || Self::run_task(running_clone, id, task_clone));
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_running(&self, id: &Uuid) -> Option<Arc<TaskStatus>> {
|
||||
let ret = self.running.read().unwrap().get(id).cloned();
|
||||
ret
|
||||
}
|
||||
|
||||
/// Stop a task with `id`
|
||||
pub fn stop(&self, id: &Uuid) -> Result<(), SchedulerError> {
|
||||
match self.get_running(id) {
|
||||
None => Err(IdNotFound),
|
||||
Some(status) => {
|
||||
status.is_alive.store(false, Ordering::SeqCst);
|
||||
if let Some(s) = &status.sleeper {
|
||||
s.skip();
|
||||
} else {
|
||||
return Err(IdAlreadyRunning);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now(&self, id: &Uuid) -> Result<(), SchedulerError> {
|
||||
match self.get_running(id) {
|
||||
None => Err(IdNotFound),
|
||||
Some(status) => {
|
||||
if let Some(s) = &status.sleeper {
|
||||
s.skip()
|
||||
} else {
|
||||
return Err(IdAlreadyRunning);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&self, task: Box<dyn Task>) -> Result<Uuid, SchedulerError> {
|
||||
let id = Uuid::new_v4();
|
||||
self.tasks.write()?.insert(id, Arc::new(task));
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn insert_list(&self, tasks: Vec<Box<dyn Task>>) -> Result<Vec<Uuid>, SchedulerError> {
|
||||
tasks.into_iter().map(|t| self.insert(t)).collect()
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::scheduler::tasks::basic::BasicTask;
|
||||
use crate::scheduler::Scheduler;
|
||||
use std::error::Error;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_run() -> Result<(), Box<dyn Error>> {
|
||||
let scheduler = Scheduler::default();
|
||||
let task = BasicTask::new(|| println!("test print"), "* * * * * *")?;
|
||||
let id = scheduler.insert(Box::new(task))?;
|
||||
for i in 1..=2 {
|
||||
println!("Loop {} start", i);
|
||||
println!("Run {}", i);
|
||||
scheduler.run_non_blocking(&id)?;
|
||||
thread::sleep(Duration::from_secs(4));
|
||||
scheduler.stop(&id)?;
|
||||
println!("Stop {}", i);
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
println!("Loop {} finished", i);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+4
-9
@@ -1,13 +1,8 @@
|
||||
use std::{
|
||||
error::Error,
|
||||
fmt::{Debug, Display, Formatter},
|
||||
sync::PoisonError,
|
||||
};
|
||||
use crate::scheduler::scheduler_error::SchedulerError::MutexPoison;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Debug, Display, Formatter};
|
||||
use std::sync::PoisonError;
|
||||
|
||||
use crate::scheduler::error::SchedulerError::MutexPoison;
|
||||
|
||||
// todo: refactor with thiserror
|
||||
#[allow(dead_code)]
|
||||
pub enum SchedulerError {
|
||||
IdNotFound,
|
||||
IdAlreadyRunning,
|
||||
@@ -0,0 +1,77 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SkippableSleeper {
|
||||
pub duration: Duration,
|
||||
state: Arc<(Mutex<bool>, Condvar)>,
|
||||
}
|
||||
|
||||
impl SkippableSleeper {
|
||||
pub fn new(duration: Duration) -> Self {
|
||||
SkippableSleeper {
|
||||
duration,
|
||||
state: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
}
|
||||
}
|
||||
|
||||
// Blocks until the duration has passed or skip() is called
|
||||
pub fn start(&self) {
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut received = lock.lock().unwrap();
|
||||
let state_clone = Arc::clone(&self.state);
|
||||
let duration_clone = self.duration;
|
||||
thread::spawn(move || {
|
||||
thread::sleep(duration_clone);
|
||||
let (lock, cvar) = &*state_clone;
|
||||
let mut received = lock.lock().unwrap();
|
||||
*received = true;
|
||||
cvar.notify_one();
|
||||
});
|
||||
while !*received {
|
||||
received = cvar.wait(received).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Skip the blocking delay
|
||||
pub fn skip(&self) {
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut received = lock.lock().unwrap();
|
||||
*received = true;
|
||||
cvar.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DateTime<Utc>> for SkippableSleeper {
|
||||
fn from(value: DateTime<Utc>) -> Self {
|
||||
let from_utc_timestamp = value.timestamp() as u64;
|
||||
let curr_utc_timestamp = Utc::now().timestamp() as u64;
|
||||
SkippableSleeper::new(Duration::from_secs(from_utc_timestamp - curr_utc_timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::scheduler::sleeper::SkippableSleeper;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn usage() {
|
||||
let sleeper = SkippableSleeper::new(Duration::new(10, 0));
|
||||
thread::spawn({
|
||||
let sleeper = sleeper.clone();
|
||||
move || {
|
||||
println!("Sleeping...");
|
||||
sleeper.start();
|
||||
println!("Woke up!");
|
||||
}
|
||||
});
|
||||
println!("Start Sleeping...");
|
||||
thread::sleep(Duration::from_secs(4));
|
||||
println!("Skipping sleep...");
|
||||
sleeper.skip();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub trait TaskFunc: Fn() + Send + Sync + 'static {}
|
||||
|
||||
impl<T> TaskFunc for T where T: Fn() + Send + Sync + Clone + 'static {}
|
||||
+8
-8
@@ -1,9 +1,9 @@
|
||||
use std::{error::Error, pin::Pin, str::FromStr};
|
||||
|
||||
use crate::scheduler::{task_func::TaskFunc, tasks::CronTask};
|
||||
|
||||
use crate::scheduler::task_func::TaskFunc;
|
||||
use crate::scheduler::tasks::Task;
|
||||
use chrono::{DateTime, Utc};
|
||||
use cron::Schedule;
|
||||
use std::error::Error;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub struct BasicTask {
|
||||
schedule: Schedule,
|
||||
@@ -21,17 +21,17 @@ impl BasicTask {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_task(self) -> Box<dyn CronTask> {
|
||||
pub fn to_task(self) -> Box<dyn Task> {
|
||||
Box::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl CronTask for BasicTask {
|
||||
impl Task for BasicTask {
|
||||
fn get_next_execution(&self) -> Option<DateTime<Utc>> {
|
||||
self.schedule.upcoming(Utc).next()
|
||||
}
|
||||
|
||||
fn call(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async { (self.func)() })
|
||||
fn call(&self) {
|
||||
(self.func)()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
pub mod basic;
|
||||
|
||||
pub trait Task: Send + Sync {
|
||||
fn get_next_execution(&self) -> Option<DateTime<Utc>>;
|
||||
fn call(&self);
|
||||
}
|
||||
@@ -23,6 +23,8 @@ fn do_fetch<'a>(
|
||||
) -> Result<AnnotatedCommit<'a>, git2::Error> {
|
||||
let mut fetch_option = FetchOptions::new();
|
||||
|
||||
// Always fetch all tags.
|
||||
// Perform a download and also update tips
|
||||
fetch_option.download_tags(AutotagOption::All);
|
||||
remote.fetch(refs, Some(&mut fetch_option), None)?;
|
||||
|
||||
@@ -43,7 +45,13 @@ fn fast_forward(
|
||||
// println!("{}", msg);
|
||||
lb.set_target(rc.id(), &msg)?;
|
||||
repo.set_head(&name)?;
|
||||
repo.checkout_head(Some(build::CheckoutBuilder::default().force()))?;
|
||||
repo.checkout_head(Some(
|
||||
build::CheckoutBuilder::default()
|
||||
// For some reason, the force is required to make the working directory actually get updated
|
||||
// I suspect we should be adding some logic to handle dirty working directory states,
|
||||
// but this is just an example so maybe not.
|
||||
.force(),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -65,12 +73,13 @@ fn normal_merge(
|
||||
return Ok(());
|
||||
}
|
||||
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
|
||||
|
||||
// now create the merge commit
|
||||
let msg = format!("merge({}) -> {}", remote.id(), local.id());
|
||||
let sig = repo.signature()?;
|
||||
let local_commit = repo.find_commit(local.id())?;
|
||||
let remote_commit = repo.find_commit(remote.id())?;
|
||||
|
||||
// Do our merge commit and set the current branch head to that commit.
|
||||
let _merge_commit = repo.commit(
|
||||
Some("HEAD"),
|
||||
&sig,
|
||||
@@ -79,6 +88,7 @@ fn normal_merge(
|
||||
&result_tree,
|
||||
&[&local_commit, &remote_commit],
|
||||
)?;
|
||||
// Set working tree to match head.
|
||||
repo.checkout_head(None)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -88,16 +98,24 @@ fn do_merge<'a>(
|
||||
remote_branch: &str,
|
||||
fetch_commit: AnnotatedCommit<'a>,
|
||||
) -> Result<Vec<FileDelta>, git2::Error> {
|
||||
// 1. do a merge analysis
|
||||
let analysis = repo.merge_analysis(&[&fetch_commit])?;
|
||||
|
||||
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
|
||||
|
||||
// 2. Do the appropriate merge
|
||||
if analysis.0.is_fast_forward() {
|
||||
// println!("Doing a fast-forward");
|
||||
// do a fast-forward
|
||||
let ref_name = format!("refs/heads/{}", remote_branch);
|
||||
match repo.find_reference(&ref_name) {
|
||||
Ok(mut r) => {
|
||||
fast_forward(repo, &mut r, &fetch_commit)?;
|
||||
}
|
||||
Err(_) => {
|
||||
// The branch doesn't exist, so set the reference to the
|
||||
// commit directly. Usually this is because you are pulling
|
||||
// into an empty repository.
|
||||
repo.reference(
|
||||
&ref_name,
|
||||
fetch_commit.id(),
|
||||
@@ -114,8 +132,14 @@ fn do_merge<'a>(
|
||||
}
|
||||
};
|
||||
} else if analysis.0.is_normal() {
|
||||
// do a normal merge
|
||||
normal_merge(repo, &head_commit, &fetch_commit)?;
|
||||
}
|
||||
// else {
|
||||
// // already up-to-date
|
||||
// println!("Nothing to do...");
|
||||
// }
|
||||
|
||||
calculate_diff(repo, &head_commit, &fetch_commit)
|
||||
}
|
||||
|
||||
@@ -132,12 +156,23 @@ fn calculate_diff(
|
||||
|
||||
if let Err(e) = diff.foreach(
|
||||
&mut |d, _| {
|
||||
// let delta = d.status();
|
||||
//
|
||||
// let n_path = d.new_file().path();
|
||||
// let o_path = d.old_file().path();
|
||||
//
|
||||
// if let Some(path) = n_path {
|
||||
// updated_files.insert(path.to_owned());
|
||||
// }
|
||||
// if let Some(path) = o_path {
|
||||
// updated_files.insert(path.to_owned());
|
||||
// }
|
||||
updated_files.push(FileDelta {
|
||||
old_path: d.old_file().path().map(PathBuf::from),
|
||||
new_path: d.new_file().path().map(PathBuf::from),
|
||||
status: d.status(),
|
||||
});
|
||||
true
|
||||
true // Continue
|
||||
},
|
||||
None,
|
||||
None,
|
||||
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "gateway"
|
||||
name = "constants"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "macros"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1.40"
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#[macro_export]
|
||||
macro_rules! dev_consume {
|
||||
($($var:ident),+) => {
|
||||
$(let _ = $var;)*
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/// Includes a UTF-8 encoded file as a string
|
||||
///
|
||||
/// The file is located relative to `CARGO_MANIFEST_DIR`.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use macros::include_str_from_crate_root;
|
||||
///
|
||||
/// let str: &str = include_str_from_crate_root!("/query/scheme.sql");
|
||||
///
|
||||
// todo: impl
|
||||
// const PATH_PREFIX: &str = "/src";
|
||||
// let str: &str = include_str_from_crate_root!("{}/query/scheme.sql", PATH_PREFIX);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! include_str_from_crate_root {
|
||||
// ($path:literal $(, $($arg:tt)*)?) => {
|
||||
// ($path:expr, $($args:tt)*) => {
|
||||
// include_str!(concat!(env!("CARGO_MANIFEST_DIR"), format_args!($path, $($args)*)))
|
||||
// };
|
||||
($path:expr) => {
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), $path))
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1 @@
|
||||
mod dev;
|
||||
mod include_str;
|
||||
mod log;
|
||||
@@ -6,7 +6,7 @@
|
||||
/// use macros::panic_with_log;
|
||||
/// use tracing::Level;
|
||||
///
|
||||
/// panic_with_log!(Level::ERROR, "test panicked: {}", 42);
|
||||
/// panic_with_log!(Level::ERROR, "Test panicked: {}", 42);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! panic_with_log {
|
||||
@@ -18,25 +18,6 @@ macro_rules! panic_with_log {
|
||||
}};
|
||||
}
|
||||
|
||||
/// log the message with `Level::ERROR` and exit(1)
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
///
|
||||
/// use macros::error_panic;
|
||||
///
|
||||
/// error_panic!("test panicked: {}", 42);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! error_panic {
|
||||
($msg:literal $(, $($arg:tt)*)?) => {{
|
||||
use std::process;
|
||||
use tracing::{event, Level};
|
||||
event!(Level::ERROR, $msg $(, $($arg)*)?);
|
||||
process::exit(1);
|
||||
}};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
@@ -44,7 +25,7 @@ mod tests {
|
||||
use std::io;
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::{Registry, fmt};
|
||||
use tracing_subscriber::{fmt, Registry};
|
||||
|
||||
let format = tracing_subscriber::fmt::format()
|
||||
.with_level(true)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
[package]
|
||||
name = "markdown"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] }
|
||||
serde = { version = "1.0.209", features = ["derive"] }
|
||||
serde_json = "1.0.128"
|
||||
regex = "1.10.4"
|
||||
yaml-rust2 = "0.10.0"
|
||||
yaml-rust2 = "0.9.0"
|
||||
sha2 = "0.10.8"
|
||||
base64 = "0.22.1"
|
||||
thiserror = "2.0.3"
|
||||
@@ -8,7 +8,7 @@ use std::str::FromStr;
|
||||
use crate::error::Error;
|
||||
use crate::utils::front_matter::{parse_front_matter, split_md_front_matter};
|
||||
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
macros = { path = "../macros" }
|
||||
markdown = { path = "../markdown" }
|
||||
|
||||
axum = { version = "0.8.1", features = ["macros"] }
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
git2 = "0.20.0"
|
||||
chrono = { version = "0.4.39", features = ["serde"] }
|
||||
cron = "0.15.0"
|
||||
sqlx = { version = "0.8.3", features = ["runtime-tokio", "tls-native-tls", "postgres", "chrono"] }
|
||||
serde_json = "1.0.133"
|
||||
serde = { version = "1.0.215", features = ["derive"] }
|
||||
tracing-subscriber = "0.3.18"
|
||||
tracing-appender = "0.2.3"
|
||||
dirs = "6.0.0"
|
||||
thiserror = "2.0.11"
|
||||
tokio = { version = "1.42.0", features = ["rt", "rt-multi-thread", "macros", "sync"] }
|
||||
dotenv = "0.15.0"
|
||||
uuid = { version = "1.13.2", features = ["v4"] }
|
||||
futures = "0.3.31"
|
||||
|
||||
[build-dependencies]
|
||||
git2 = "0.20.0"
|
||||
chrono = "0.4.39"
|
||||
@@ -1,16 +0,0 @@
|
||||
use chrono::Utc;
|
||||
use git2::Repository;
|
||||
|
||||
fn main() {
|
||||
let repo = Repository::open("../..").expect("failed to open repository");
|
||||
let commit = repo
|
||||
.head()
|
||||
.expect("failed to get HEAD")
|
||||
.peel_to_commit()
|
||||
.expect("failed to get HEAD commit");
|
||||
let hash = commit.id().to_string();
|
||||
let utc_timestamp = Utc::now().date_naive().format("%Y%m%d").to_string();
|
||||
let pkg_version = env!("CARGO_PKG_VERSION");
|
||||
let build_id = format!("{}:{}@{}", pkg_version, &hash[..8], utc_timestamp);
|
||||
println!("cargo:rustc-env=BUILD_ID={}", build_id);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
#[allow(dead_code)]
|
||||
mod constants;
|
||||
|
||||
mod scheduler;
|
||||
|
||||
mod init;
|
||||
mod modules;
|
||||
mod utils;
|
||||
|
||||
use crate::init::{logger, start};
|
||||
|
||||
const BUILD_ID: &str = env!("BUILD_ID");
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("{}", BUILD_ID);
|
||||
|
||||
init::load_env();
|
||||
let _guards = logger::init();
|
||||
|
||||
start().await;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
use crate::constants::blog::PAGE_SIZE;
|
||||
|
||||
use axum::{Json, extract::State};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{PgPool, Row, query};
|
||||
use tracing::error;
|
||||
|
||||
const GET_PAGE_COUNT_QUERY: &str = include_str!("post_page_count.sql");
|
||||
|
||||
pub async fn handler(State(db_connection): State<PgPool>) -> Result<Json<Value>, Json<Value>> {
|
||||
match query(GET_PAGE_COUNT_QUERY)
|
||||
.bind(PAGE_SIZE as i64)
|
||||
.fetch_one(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(res) => {
|
||||
let res: i32 = res.get("count");
|
||||
Ok(Json::from(json!({"res": res})))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("failed to get blog page count: {}", e.to_string());
|
||||
Err(Json::from(json!({"error": e.to_string()})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
SELECT cast(ceil(count(*) / cast($1 AS FLOAT)) AS INTEGER) AS count
|
||||
FROM posts;
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -1,34 +0,0 @@
|
||||
use macros::dev_consume;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
|
||||
const GET_POST_BY_I64_ID: &str = include_str!("get_post_by_i64_id.sql");
|
||||
const GET_POST_BY_STR_ID: &str = include_str!("get_post_by_str_id.sql");
|
||||
|
||||
pub async fn handler(
|
||||
State(db_connection): State<PgPool>,
|
||||
Path(identifier): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
if let Ok(id) = identifier.parse::<i64>() {
|
||||
select_post_with_i64_id(&db_connection, id).await
|
||||
} else {
|
||||
select_post_with_str_id(&db_connection, &identifier).await
|
||||
};
|
||||
Json::from(json!({"res": "ok"})).into_response()
|
||||
}
|
||||
|
||||
async fn select_post_with_i64_id(db: &PgPool, id: i64) {
|
||||
dev_consume!(db, id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn select_post_with_str_id(db: &PgPool, id: &str) {
|
||||
dev_consume!(db, id);
|
||||
todo!()
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod get_page_count;
|
||||
|
||||
// todo: remove exception tag
|
||||
#[allow(dead_code)]
|
||||
pub mod get_post;
|
||||
// pub mod get_post_list;
|
||||
@@ -1,78 +0,0 @@
|
||||
use crate::constants::config::general::{default_value, var_name};
|
||||
use macros::panic_with_log;
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::modules::blog::components::check_update::{
|
||||
// index::drop_index,
|
||||
// utils::{expand_path, index_files, search_md},
|
||||
utils::expand_path,
|
||||
};
|
||||
use crate::scheduler::task_func::AsyncTaskFunc;
|
||||
use sqlx::PgPool;
|
||||
use tracing::{info, warn, Level};
|
||||
|
||||
// mod error;
|
||||
// mod index;
|
||||
mod utils;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
git_work_dir: PathBuf,
|
||||
git_url: String,
|
||||
git_remote_name: String,
|
||||
git_remote_branch: String,
|
||||
}
|
||||
|
||||
pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc {
|
||||
// init
|
||||
let git_work_dir = {
|
||||
let path = if let Ok(default_log_path) = env::var(var_name::WORK_DIR) {
|
||||
expand_path(default_log_path)
|
||||
} else {
|
||||
let fallback_path = expand_path(default_value::WORK_DIR.to_string());
|
||||
warn!(
|
||||
"unset env var `{}`, using default path: {}/blog_git",
|
||||
var_name::WORK_DIR,
|
||||
fallback_path
|
||||
);
|
||||
fallback_path
|
||||
};
|
||||
Path::new(&path).join("blog_git")
|
||||
};
|
||||
|
||||
// clone & check update
|
||||
|
||||
let git_url = match env::var(var_name::BLOG_GIT_URL) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
panic_with_log!(
|
||||
Level::ERROR,
|
||||
"failed to read `{}`: {}",
|
||||
var_name::BLOG_GIT_URL,
|
||||
e
|
||||
)
|
||||
}
|
||||
};
|
||||
let git_remote_name = env::var(var_name::BLOG_GIT_REMOTE_NAME)
|
||||
.unwrap_or(default_value::BLOG_GIT_REMOTE_NAME.to_string());
|
||||
let git_remote_branch = env::var(var_name::BLOG_GIT_REMOTE_BRANCH)
|
||||
.unwrap_or(default_value::BLOG_GIT_REMOTE_BRANCH.to_string());
|
||||
|
||||
let arc_args = Arc::new(Args {
|
||||
git_work_dir,
|
||||
git_url,
|
||||
git_remote_name,
|
||||
git_remote_branch,
|
||||
});
|
||||
|
||||
move || Box::pin(test(arc_args.clone()))
|
||||
}
|
||||
|
||||
async fn test(args: Arc<Args>) {
|
||||
info!("test");
|
||||
dbg!(&args);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
use crate::constants::{
|
||||
config::general::{default_value, var_name},
|
||||
time::DEFAULT_DATE_FORMAT,
|
||||
};
|
||||
|
||||
// use crate::modules::blog::components::check_update::index::index_file;
|
||||
use chrono::{DateTime, FixedOffset, ParseResult};
|
||||
use dirs::home_dir;
|
||||
use tracing::warn;
|
||||
|
||||
// pub fn search_md(entry: PathBuf) -> Vec<PathBuf> {
|
||||
// if entry.ends_with(".git") {
|
||||
// return vec![];
|
||||
// }
|
||||
// if entry.is_dir() {
|
||||
// if let Ok(files) = entry.read_dir() {
|
||||
// files
|
||||
// .flat_map(|file| {
|
||||
// let file_path = file.unwrap().path();
|
||||
// search_md(file_path)
|
||||
// })
|
||||
// .collect()
|
||||
// } else {
|
||||
// vec![]
|
||||
// }
|
||||
// } else if entry.extension().is_some_and(|ext| ext == "md") {
|
||||
// vec![entry]
|
||||
// } else {
|
||||
// vec![]
|
||||
// }
|
||||
// }
|
||||
|
||||
// pub fn index_files(files: &[PathBuf], db_conn: &Pool<Postgres>) {
|
||||
// let rt = Runtime::new().unwrap();
|
||||
// rt.block_on(async {
|
||||
// let files_clone = files.to_owned();
|
||||
// let handlers = files_clone
|
||||
// .into_iter()
|
||||
// .map(|file: PathBuf| {
|
||||
// let conn_clone = db_conn.clone();
|
||||
// spawn(async move {
|
||||
// if let Err(e) = index_file(&file, &conn_clone).await {
|
||||
// error!("failed to index markdown file `{}`: {}", file.display(), e);
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
// .collect::<Vec<_>>();
|
||||
//
|
||||
// block_on(join_all(handlers));
|
||||
// });
|
||||
// }
|
||||
|
||||
pub fn expand_path(path: String) -> String {
|
||||
if path.starts_with('~') {
|
||||
home_dir()
|
||||
.map(|dir| {
|
||||
dir.join(path.strip_prefix("~/").unwrap())
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.unwrap()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
warn!(
|
||||
"failed to parse env var `{}`, using default value `{}`",
|
||||
var_name::WORK_DIR,
|
||||
default_value::WORK_DIR
|
||||
);
|
||||
default_value::WORK_DIR.to_string()
|
||||
})
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_date(date_str: &str) -> ParseResult<DateTime<FixedOffset>> {
|
||||
DateTime::parse_from_str(format!("{}T120000", date_str).as_str(), DEFAULT_DATE_FORMAT)
|
||||
}
|
||||
|
||||
// pub fn get_md_file_basename(f_name: &str) -> String {
|
||||
// f_name.replace(".md", "")
|
||||
// }
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod check_update;
|
||||
mod rag;
|
||||
Whitespace-only changes.
@@ -1,64 +0,0 @@
|
||||
mod api;
|
||||
mod components;
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
blog::{
|
||||
api::{
|
||||
get_page_count,
|
||||
get_post,
|
||||
// get_post_list::get_post_list,
|
||||
},
|
||||
// cron::check_update,
|
||||
},
|
||||
Module,
|
||||
},
|
||||
scheduler::tasks::CronTask,
|
||||
};
|
||||
|
||||
use crate::modules::blog::components::check_update;
|
||||
use crate::scheduler::tasks::async_basic::AsyncBasic;
|
||||
use crate::scheduler::tasks::basic::BasicTask;
|
||||
use axum::{routing::get, Router};
|
||||
use sqlx::PgPool;
|
||||
|
||||
const MOUNT_POINT: &str = "/blog";
|
||||
|
||||
pub struct Blog {
|
||||
pub db_handler: PgPool,
|
||||
}
|
||||
|
||||
impl Module for Blog {
|
||||
fn get_server_router(&self) -> Router {
|
||||
let db_handler = self.db_handler.clone();
|
||||
// todo: move resource init to new func
|
||||
// todo: remove debug comment
|
||||
Router::new()
|
||||
// .route("/get_post_list", get(get_post_list))
|
||||
.route("/get_page_count", get(get_page_count::handler))
|
||||
.route("/get_post/{identifier}", get(get_post::handler))
|
||||
.with_state(db_handler)
|
||||
}
|
||||
|
||||
fn get_mount_point(&self) -> &'static str {
|
||||
MOUNT_POINT
|
||||
}
|
||||
|
||||
fn get_cron_tasks(&self) -> Vec<(&str, Box<dyn CronTask>)> {
|
||||
let db_handler = self.db_handler.clone();
|
||||
vec![
|
||||
(
|
||||
"Blog Check Update",
|
||||
AsyncBasic::new(check_update::task(db_handler.clone()), "* * * * * *")
|
||||
.unwrap()
|
||||
.to_task(),
|
||||
),
|
||||
(
|
||||
"Sync test task",
|
||||
BasicTask::new(|| println!("test lol"), "* * * * * *")
|
||||
.unwrap()
|
||||
.to_task(),
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
CREATE TABLE tags
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(20) UNIQUE NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE categories
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(20) UNIQUE NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE posts
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
identifier VARCHAR(50) UNIQUE NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
subtitle VARCHAR(255) NOT NULL,
|
||||
published_date DATE NOT NULL,
|
||||
last_update DATE,
|
||||
content TEXT NOT NULL,
|
||||
|
||||
category SERIAL REFERENCES categories (id) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE post_tag
|
||||
(
|
||||
post SERIAL REFERENCES posts (id),
|
||||
tag SERIAL REFERENCES tags (id)
|
||||
);
|
||||
|
||||
CREATE TABLE metadata
|
||||
(
|
||||
pid SERIAL REFERENCES posts (id) NOT NULL,
|
||||
meta_key VARCHAR(50) NOT NULL,
|
||||
meta_value TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT meta_pk PRIMARY KEY (pid, meta_key)
|
||||
);
|
||||
@@ -1,82 +0,0 @@
|
||||
use std::{env, num::ParseIntError};
|
||||
|
||||
use crate::{
|
||||
constants::config::server::{default_value, var_name},
|
||||
scheduler::{tasks::CronTask, Scheduler},
|
||||
};
|
||||
use macros::error_panic;
|
||||
|
||||
use axum::Router;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub mod blog;
|
||||
|
||||
// todo: merge `get_server_router` & `get_mount_point`, make Option
|
||||
pub trait Module {
|
||||
fn get_server_router(&self) -> Router;
|
||||
|
||||
fn get_mount_point(&self) -> &'static str;
|
||||
|
||||
fn get_cron_tasks(&self) -> Vec<(&str, Box<dyn CronTask>)>;
|
||||
}
|
||||
|
||||
pub struct ModuleTree {
|
||||
pub modules: Vec<Box<dyn Module>>,
|
||||
}
|
||||
|
||||
impl ModuleTree {
|
||||
pub fn new() -> Self {
|
||||
ModuleTree { modules: vec![] }
|
||||
}
|
||||
|
||||
pub fn add_module(&mut self, module: impl Module + 'static) -> &mut Self {
|
||||
self.modules.push(Box::new(module));
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn blocking_serve(&self) {
|
||||
let mut scheduler = Scheduler::new();
|
||||
let mut app = Router::new();
|
||||
|
||||
let port: u32 = match env::var(var_name::PORT) {
|
||||
Ok(value) => value.parse().unwrap_or_else(|e: ParseIntError| {
|
||||
warn!(
|
||||
"failed to parse `{}`, using default port: {}",
|
||||
var_name::PORT,
|
||||
e.to_string()
|
||||
);
|
||||
default_value::PORT
|
||||
}),
|
||||
Err(_) => default_value::PORT,
|
||||
};
|
||||
|
||||
let listener = match TcpListener::bind(format!("{}:{}", default_value::HOST, port)).await {
|
||||
Ok(listener) => {
|
||||
info!("server starting on {}:{}", default_value::HOST, port);
|
||||
listener
|
||||
}
|
||||
Err(e) => {
|
||||
error_panic!(
|
||||
"failed to start server on {}:{}: {}",
|
||||
default_value::HOST,
|
||||
port,
|
||||
e.to_string()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for module in &self.modules {
|
||||
let _ = scheduler.insert_list(module.get_cron_tasks());
|
||||
app = app.nest(module.get_mount_point(), module.get_server_router());
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
scheduler.start().await;
|
||||
});
|
||||
|
||||
axum::serve(listener, app).await.unwrap_or_else(|e| {
|
||||
error_panic!("failed to start axum: {}", e.to_string());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use crate::scheduler::{error::SchedulerError, tasks::CronTask, utils::execute};
|
||||
|
||||
use futures::future::join_all;
|
||||
use tracing::warn;
|
||||
|
||||
pub mod error;
|
||||
pub mod task_func;
|
||||
pub mod tasks;
|
||||
mod utils;
|
||||
|
||||
pub type TaskPool = HashMap<String, Arc<Box<dyn CronTask>>>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Scheduler {
|
||||
task_pool: TaskPool,
|
||||
}
|
||||
|
||||
impl Scheduler {
|
||||
/// Start all tasks in the scheduler
|
||||
pub async fn start(&self) {
|
||||
dbg!("entering");
|
||||
|
||||
let fut = self
|
||||
.task_pool
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(name, task)| execute(name, task))
|
||||
.collect::<Vec<_>>();
|
||||
// self.runner.run_all().await
|
||||
join_all(fut).await;
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, task: (&str, Box<dyn CronTask>)) -> Result<&Self, SchedulerError> {
|
||||
let (k, v) = task;
|
||||
if self.task_pool.insert(k.into(), v.into()).is_some() {
|
||||
warn!("task list duplicated key detected: {}", k);
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn insert_list(
|
||||
&mut self,
|
||||
tasks: Vec<(&str, Box<dyn CronTask>)>,
|
||||
) -> Result<&Self, SchedulerError> {
|
||||
for task in tasks {
|
||||
self.insert(task)?;
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
use std::pin::Pin;
|
||||
|
||||
pub trait TaskFunc: Fn() + Send + Sync + 'static {}
|
||||
|
||||
impl<T> TaskFunc for T where T: Fn() + Send + Sync + Clone + 'static {}
|
||||
|
||||
pub trait AsyncTaskFunc:
|
||||
Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync + 'static
|
||||
{
|
||||
}
|
||||
|
||||
impl<T> AsyncTaskFunc for T where
|
||||
T: Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync + Clone + 'static
|
||||
{
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
use std::{error::Error, pin::Pin, str::FromStr};
|
||||
|
||||
use crate::{scheduler::task_func::AsyncTaskFunc, scheduler::tasks::CronTask};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use cron::Schedule;
|
||||
|
||||
pub struct AsyncBasic {
|
||||
schedule: Schedule,
|
||||
func: Box<dyn AsyncTaskFunc>,
|
||||
}
|
||||
|
||||
impl AsyncBasic {
|
||||
pub fn new<F>(func: F, cron_expr: &str) -> Result<Self, Box<dyn Error>>
|
||||
where
|
||||
F: AsyncTaskFunc,
|
||||
{
|
||||
Ok(AsyncBasic {
|
||||
schedule: Schedule::from_str(cron_expr)?,
|
||||
func: Box::new(func),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_task(self) -> Box<dyn CronTask> {
|
||||
Box::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl CronTask for AsyncBasic {
|
||||
fn get_next_execution(&self) -> Option<DateTime<Utc>> {
|
||||
self.schedule.upcoming(Utc).next()
|
||||
}
|
||||
|
||||
fn call(&self) -> Pin<Box<(dyn Future<Output = ()> + Send)>> {
|
||||
Box::pin((self.func)())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
use std::pin::Pin;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
pub mod async_basic;
|
||||
pub mod basic;
|
||||
|
||||
pub trait CronTask: Send + Sync {
|
||||
fn get_next_execution(&self) -> Option<DateTime<Utc>>;
|
||||
fn call(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use crate::scheduler::{error::SchedulerError, tasks::CronTask};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use tokio::time::{sleep, Sleep};
|
||||
|
||||
pub fn duration_from_now(future: DateTime<Utc>) -> Duration {
|
||||
future
|
||||
.signed_duration_since(Utc::now())
|
||||
.to_std()
|
||||
.unwrap_or(Duration::ZERO)
|
||||
}
|
||||
|
||||
pub fn sleeper_from_now(future: DateTime<Utc>) -> Sleep {
|
||||
sleep(duration_from_now(future))
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn execute(name: String, func: Arc<Box<dyn CronTask>>) -> Result<(), SchedulerError> {
|
||||
// todo: stats
|
||||
while let Some(next_execution) = func.get_next_execution() {
|
||||
let sleeper = sleeper_from_now(next_execution);
|
||||
tokio::pin!(sleeper);
|
||||
tokio::select! {
|
||||
_ = sleeper => {
|
||||
func.call().await
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.venv/
|
||||
|
||||
.idea/
|
||||
|
||||
.DS_store
|
||||
|
||||
__pycache__
|
||||
|
||||
.cache
|
||||
|
||||
content/*
|
||||
@@ -1,30 +0,0 @@
|
||||
## Module Structure
|
||||
|
||||
```shell
|
||||
.
|
||||
├── README.md
|
||||
├── cli
|
||||
├── content
|
||||
│ ├── pages
|
||||
│ │ └── about
|
||||
│ │ └── <static_page>.md
|
||||
│ └── posts
|
||||
│ └── <year>
|
||||
│ └── <identifier>
|
||||
│ ├── assets
|
||||
│ │ └── <assets>
|
||||
│ ├── translate_<lang>.md
|
||||
│ └── index.md
|
||||
├── requirements.txt
|
||||
├── templates
|
||||
│ ├── <template>.py
|
||||
│ └── ...
|
||||
└── utils
|
||||
└── ...
|
||||
```
|
||||
|
||||
### About `posts`
|
||||
|
||||
- Will render to `https://<DOMAIN>/blog/post/<identifier>`.
|
||||
- Files under `assets/` (where images etc. located) should be pushed to OSS.
|
||||
- For identifiers, use prefix `-` to indicate private (hidden) visibility. EX: `-hello-world`.
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
|
||||
import rich
|
||||
|
||||
from templates.post import BlogPost
|
||||
from utils.cache import init_cache
|
||||
from utils.index import build_post_idx
|
||||
|
||||
|
||||
def main():
|
||||
# if args.new:
|
||||
# new_post()
|
||||
init_cache()
|
||||
build_post_idx()
|
||||
bp = BlogPost()
|
||||
bp.init()
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("-n", "--new", help="Create a post with build-in template", type=str)
|
||||
args = parser.parse_args(namespace=rich)
|
||||
# todo: cli
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,4 +0,0 @@
|
||||
rich~=13.9.4
|
||||
prompt_toolkit~=3.0.50
|
||||
pypinyin~=0.53.0
|
||||
python-frontmatter~=1.1.0
|
||||
@@ -1,87 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from datetime import datetime as dt
|
||||
from typing import Dict, Optional
|
||||
|
||||
from templates.utils import Template, create_all_files
|
||||
from utils.autocompletion import input_autocompletion, fuzzy_pinyin_matcher
|
||||
from utils.cache import get_tags_cache, get_categories_cache
|
||||
from utils.lang import eprint, wprint
|
||||
|
||||
POST_TEMPLATE = '''
|
||||
---
|
||||
title: {{ title }}
|
||||
subtitle: {{ subtitle }}
|
||||
published_date: {{ published_date }}
|
||||
last_updated: {{ last_update }}
|
||||
category: {{ category }}
|
||||
tags: {{ tags }}
|
||||
uuid: {{ uuid }}
|
||||
---
|
||||
|
||||
WIP
|
||||
|
||||
'''
|
||||
|
||||
|
||||
def get_title():
|
||||
while True:
|
||||
i = input("title > ")
|
||||
if i.strip() == "":
|
||||
eprint("empty title")
|
||||
continue
|
||||
else:
|
||||
return i.strip()
|
||||
|
||||
|
||||
def get_subtitle():
|
||||
return input("subtitle > ")
|
||||
|
||||
|
||||
def get_published_date():
|
||||
while True:
|
||||
d = input("published_date (YYYY-MM-DD) > ")
|
||||
if d == "":
|
||||
wprint("using today's date")
|
||||
return dt.today().strftime("%Y-%m-%d")
|
||||
else:
|
||||
try:
|
||||
_validate = dt.strptime(d, "%Y-%m-%d")
|
||||
return d
|
||||
except ValueError:
|
||||
eprint("invalid date")
|
||||
continue
|
||||
|
||||
|
||||
def get_tags():
|
||||
# todo: multi tags support
|
||||
return input_autocompletion("tags > ", get_tags_cache(), fuzzy_pinyin_matcher)
|
||||
|
||||
|
||||
def get_category():
|
||||
while True:
|
||||
i = input_autocompletion("category > ", get_categories_cache(), fuzzy_pinyin_matcher)
|
||||
if i.strip() == "":
|
||||
eprint("empty category")
|
||||
continue
|
||||
else:
|
||||
return i.strip()
|
||||
|
||||
|
||||
class BlogPost(Template):
|
||||
WORK_PATH: str = "content/posts/{year}/{identifier}/"
|
||||
ITEMS: Dict[str, Optional[str]] = {"index.md": POST_TEMPLATE, "assets/": None}
|
||||
|
||||
def init(self):
|
||||
year = date.today().year
|
||||
|
||||
index_md = POST_TEMPLATE.format(
|
||||
title=get_title(),
|
||||
subtitle=get_subtitle(),
|
||||
published_date=get_published_date(),
|
||||
last_update=dt.today().strftime("%Y-%m-%d"),
|
||||
tags=get_tags(),
|
||||
category=get_category(),
|
||||
uuid=uuid.uuid4()
|
||||
)
|
||||
create_all_files(self.WORK_PATH.format(year=year, identifier="test"), self.ITEMS)
|
||||
@@ -1,17 +0,0 @@
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
class Template(ABC):
|
||||
@abstractmethod
|
||||
def init(self):
|
||||
pass
|
||||
|
||||
|
||||
def create_all_files(relative_work_path: str, items: Dict[str, Optional[str]]):
|
||||
absolute_path = os.path.abspath(relative_work_path)
|
||||
for item in items:
|
||||
if item.endswith("/"):
|
||||
print(absolute_path + item)
|
||||
# todo: create files
|
||||
@@ -1,31 +0,0 @@
|
||||
class Ansi:
|
||||
HEADER = '\033[95m'
|
||||
BLUE = '\033[94m'
|
||||
GREEN = '\033[92m'
|
||||
WARNING = '\033[93m'
|
||||
FAIL = '\033[91m'
|
||||
BOLD = '\033[1m'
|
||||
UNDERLINE = '\033[4m'
|
||||
END = '\033[0m'
|
||||
|
||||
def disable(self) -> None:
|
||||
self.HEADER = ''
|
||||
self.BLUE = ''
|
||||
self.GREEN = ''
|
||||
self.WARNING = ''
|
||||
self.FAIL = ''
|
||||
self.BOLD = ''
|
||||
self.UNDERLINE = ''
|
||||
self.END = ''
|
||||
|
||||
@staticmethod
|
||||
def blend(color: list[str], text: str) -> str:
|
||||
return f"{"".join(color)}{text}{Ansi.END}"
|
||||
|
||||
@staticmethod
|
||||
def bold(text: str) -> str:
|
||||
return f"{Ansi.BOLD}{text}{Ansi.END}"
|
||||
|
||||
@staticmethod
|
||||
def underline(text: str) -> str:
|
||||
return f"{Ansi.UNDERLINE}{text}{Ansi.END}"
|
||||
@@ -1,46 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from typing import List, Iterable
|
||||
|
||||
import prompt_toolkit
|
||||
from prompt_toolkit.completion import Completer, CompleteEvent, Completion
|
||||
from prompt_toolkit.document import Document
|
||||
from pypinyin import lazy_pinyin, Style
|
||||
|
||||
|
||||
class AutoCompleter(Completer):
|
||||
def __init__(self, candidates: List[str], matching_algorithm: Callable[[str, str], bool]):
|
||||
self.candidates = candidates
|
||||
self.matching_algorithm = matching_algorithm
|
||||
|
||||
def get_completions(self, document: Document, complete_event: CompleteEvent) -> Iterable[Completion]:
|
||||
curr_word = document.text_before_cursor
|
||||
for c in self.candidates:
|
||||
if self.matching_algorithm(curr_word, c):
|
||||
yield Completion(c, start_position=-len(curr_word))
|
||||
|
||||
|
||||
def exact_matcher(word: str, candidate: str, case_sensitive: bool = False) -> bool:
|
||||
if case_sensitive:
|
||||
return word == candidate
|
||||
else:
|
||||
return word.lower() == candidate.lower()
|
||||
|
||||
|
||||
def fuzzy_matcher(word: str, candidate: str) -> bool:
|
||||
return word.lower() in candidate.lower()
|
||||
|
||||
|
||||
def fuzzy_pinyin_matcher(word: str, candidate: str) -> bool:
|
||||
try:
|
||||
word_matcher = ''.join(lazy_pinyin(word, style=Style.NORMAL))
|
||||
candidate_matcher = ''.join(lazy_pinyin(candidate, style=Style.NORMAL))
|
||||
return candidate_matcher.startswith(word_matcher)
|
||||
|
||||
except e:
|
||||
return fuzzy_matcher(word, candidate)
|
||||
|
||||
|
||||
def input_autocompletion(prompt: str, candidates: List[str],
|
||||
matching_algorithm: Callable[[str, str], bool] = exact_matcher) -> str:
|
||||
completer = AutoCompleter(candidates, matching_algorithm)
|
||||
return prompt_toolkit.prompt(prompt, completer=completer, complete_in_thread=True)
|
||||
@@ -1,49 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
from typing import TextIO
|
||||
|
||||
CACHE_DIR = ".cache"
|
||||
ITEMS = {"register": "register.json"}
|
||||
|
||||
|
||||
def overwrite_json_file(file: TextIO, json_data: dict):
|
||||
file.seek(0)
|
||||
file.write(json.dumps(json_data, indent=4, ensure_ascii=False))
|
||||
file.truncate()
|
||||
|
||||
|
||||
def init_cache():
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
os.makedirs(CACHE_DIR)
|
||||
for item in ITEMS.values():
|
||||
item_path = os.path.join(CACHE_DIR, item)
|
||||
if not os.path.exists(item_path):
|
||||
with open(item_path, 'w') as cache_file:
|
||||
match item:
|
||||
case "register.json":
|
||||
overwrite_json_file(cache_file, {})
|
||||
break
|
||||
|
||||
|
||||
def write_tags_cache(tags: list[str]):
|
||||
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r+') as tags_cache_file:
|
||||
cache = json.load(tags_cache_file)
|
||||
cache["tags"] = tags
|
||||
overwrite_json_file(tags_cache_file, cache)
|
||||
|
||||
|
||||
def write_category_cache(categories: list[str]):
|
||||
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r+') as categories_cache_file:
|
||||
cache = json.load(categories_cache_file)
|
||||
cache["categories"] = categories
|
||||
overwrite_json_file(categories_cache_file, cache)
|
||||
|
||||
|
||||
def get_tags_cache() -> list[str]:
|
||||
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r') as tags_cache_file:
|
||||
return json.load(tags_cache_file)["tags"]
|
||||
|
||||
|
||||
def get_categories_cache() -> list[str]:
|
||||
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r') as categories_cache_file:
|
||||
return json.load(categories_cache_file)["categories"]
|
||||
@@ -1,27 +0,0 @@
|
||||
import os
|
||||
|
||||
import frontmatter
|
||||
|
||||
from utils.cache import write_tags_cache, write_category_cache
|
||||
from utils.lang import list_exclude
|
||||
|
||||
EXCLUDE = [".DS_Store"]
|
||||
POSTS_DIR = "content/posts"
|
||||
|
||||
|
||||
def build_post_idx():
|
||||
tags = set()
|
||||
categories = set()
|
||||
for year in list_exclude(os.listdir(POSTS_DIR), EXCLUDE):
|
||||
for post_dir in list_exclude(os.listdir(os.path.join(POSTS_DIR, year)), EXCLUDE):
|
||||
target_file = os.path.join(POSTS_DIR, year, post_dir, "index.md")
|
||||
with open(target_file, "r") as f:
|
||||
post = frontmatter.loads(f.read())
|
||||
try:
|
||||
for tag in post.metadata["tags"]:
|
||||
tags.add(tag)
|
||||
categories.add(post.metadata["category"])
|
||||
except KeyError:
|
||||
print(f"failed to parse {target_file}")
|
||||
write_tags_cache(list(tags))
|
||||
write_category_cache(list(categories))
|
||||
@@ -1,13 +0,0 @@
|
||||
from utils.ansi import Ansi
|
||||
|
||||
|
||||
def list_exclude(source: list, exclude: list):
|
||||
return filter(lambda item: item not in exclude, source)
|
||||
|
||||
|
||||
def eprint(msg: str):
|
||||
print(Ansi.blend([Ansi.FAIL], "error: ") + msg)
|
||||
|
||||
|
||||
def wprint(msg: str):
|
||||
print(Ansi.blend([Ansi.WARNING], "warn: ") + msg)
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, CardBody } from '@nextui-org/card';
|
||||
import Headers from '@/app/(pages)/blog/post/[id]/Headers';
|
||||
import Footer from '@/app/(pages)/blog/post/[id]/Footer';
|
||||
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
|
||||
|
||||
type Props = {
|
||||
post: any
|
||||
}
|
||||
|
||||
async function PostCard(props: Props) {
|
||||
let post = props.post;
|
||||
return (
|
||||
<div>
|
||||
<Card className={ `max-w-full relative` }>
|
||||
|
||||
{/* card headers */ }
|
||||
<Headers title={ post.title }
|
||||
subtitle={ post.sub_title }
|
||||
summary={ post.summary }
|
||||
category={ post.category }
|
||||
tags={ post.tags }
|
||||
first_update={ post.first_update }
|
||||
header_img={ post.meta.header_img }
|
||||
/>
|
||||
|
||||
{/* card body */ }
|
||||
<CardBody>
|
||||
<div className={ 'p-5' }>
|
||||
<MarkdownRenderer>
|
||||
{ post.content }
|
||||
</MarkdownRenderer>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
|
||||
{/* card footer */ }
|
||||
<Footer
|
||||
adjacent_posts={ post.adj }
|
||||
perm_link={ post.meta.link }
|
||||
author={ post.meta.author }
|
||||
author_link={ post.meta.author_link }
|
||||
first_update={ post.first_update }
|
||||
last_update={ post.last_update }
|
||||
cc={ post.meta.cc }
|
||||
post_id={ post.meta.id }
|
||||
title={ post.title }
|
||||
/>
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PostCard;
|
||||
@@ -1,70 +0,0 @@
|
||||
import React, { cache } from 'react';
|
||||
import PostCard from '@/app/(pages)/blog/post/[id]/PostCard';
|
||||
import ButtonGoBack from '@/app/(pages)/blog/post/[id]/ButtonGoBack';
|
||||
import * as motion from 'motion/react-client';
|
||||
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
|
||||
import { Metadata } from 'next';
|
||||
import { SITE_CONFIG } from '@/src/constants';
|
||||
|
||||
const fetchPost = cache(async (id: string) => {
|
||||
const res = await fetchNocturneApi(`/blog/get_post/${ id }`);
|
||||
// throw Error('Not Found');
|
||||
return await res.json();
|
||||
});
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: { params: Promise<{ id: string }> })
|
||||
: Promise<Metadata> {
|
||||
try {
|
||||
const title = (await fetchPost((await params).id)).title as string;
|
||||
return {
|
||||
title: `${ title } - ${ SITE_CONFIG.name }`
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
title: 'Unexpected Error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function BlogReader({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
try {
|
||||
let post = await fetchPost((await params).id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={ `max-w-full` }
|
||||
initial={ { y: 20, opacity: 0 } }
|
||||
animate={ { y: 0, opacity: 1 } }
|
||||
transition={ { ease: 'easeInOut', duration: 0.5 } }
|
||||
>
|
||||
<div className={ `max-w-full` }>
|
||||
<PostCard post={ post } />
|
||||
</div>
|
||||
<div className={ 'mt-3' }>
|
||||
<ButtonGoBack />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
} catch (e: any) {
|
||||
return (
|
||||
<div className={ `flex h-full w-full justify-center items-center` }>
|
||||
<div className={ `flex-row` }>
|
||||
<p className={ `text-xl font-bold` }>
|
||||
{ 'Unexpected Error:' }
|
||||
</p>
|
||||
<p className={ `font-bold` }>
|
||||
{ e.message }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default BlogReader;
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
import * as motion from 'motion/react-client';
|
||||
|
||||
function Layout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
initial={ { opacity: 0 } }
|
||||
animate={ { opacity: 1 } }
|
||||
transition={ { ease: 'easeInOut', duration: 0.3, delay: 0.5 } }
|
||||
className={ `justify-center flex flex-row w-full max-w-full` }>
|
||||
<Navbar />
|
||||
<div className={ `flex flex-col flex-1 max-w-[1024px] min-w-0` }>
|
||||
{ children }
|
||||
<Footer />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
@@ -1,43 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Activity } from '@/src/HomePageActivities/activity';
|
||||
import { getGhActivities } from '@/src/HomePageActivities/ghActivity';
|
||||
import * as motion from 'motion/react-client';
|
||||
|
||||
const Activities = () => {
|
||||
|
||||
const [ activities, setActivities ] = useState<Activity[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getGhActivities().then(data => setActivities(data));
|
||||
}, []);
|
||||
|
||||
if (!activities) {
|
||||
return (
|
||||
<div>
|
||||
<p>loading</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<ul className={ 'flex flex-col gap-y-3 items-end' }>
|
||||
{ activities.map((activity, key) => (
|
||||
<motion.div
|
||||
key={ key }
|
||||
initial={ { opacity: 0, y: 20 } }
|
||||
animate={ { opacity: 1, y: 0 } }
|
||||
transition={ { ease: 'easeInOut', duration: 0.2 } }
|
||||
>
|
||||
<li className={ 'backdrop-blur bg-white/75 dark:bg-black/45 rounded-xl w-fit' }>
|
||||
{ activity.toElement() }
|
||||
</li>
|
||||
</motion.div>
|
||||
)) }
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export default Activities;
|
||||
@@ -1,10 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import { SITE_CONFIG } from '@/src/constants';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `About - ${ SITE_CONFIG.name }`
|
||||
};
|
||||
|
||||
function Layout({
|
||||
children
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
|
||||
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
|
||||
import { fetchText } from '@/app/fetch';
|
||||
|
||||
const Me = async () => {
|
||||
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
|
||||
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
|
||||
import { fetchText } from '@/app/fetch';
|
||||
|
||||
const Website = async () => {
|
||||
File renamed without changes.
File renamed without changes.
@@ -3,7 +3,7 @@
|
||||
import React from 'react';
|
||||
import { Pagination as NextUIPagination } from '@nextui-org/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
|
||||
import { useScreenSizeTrigger } from '@/app/public/screenSizeTrigger';
|
||||
|
||||
const Pagination = ({ total }: {
|
||||
total: number,
|
||||
@@ -1,12 +1,11 @@
|
||||
'use server';
|
||||
|
||||
import React from 'react';
|
||||
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
|
||||
import BlogPostCard from '@/app/(pages)/blog/BlogPostCard';
|
||||
import { fetchNocturneApi } from '@/app/blog/api';
|
||||
import { MotionDiv } from '@/app/public/MotionDiv';
|
||||
import BlogPostCard from '@/app/blog/BlogPostCard';
|
||||
import { Card, CardBody } from '@nextui-org/card';
|
||||
|
||||
import * as motion from 'motion/react-client';
|
||||
|
||||
const PostList = async ({ page }: { page: number }) => {
|
||||
try {
|
||||
const res = await fetchNocturneApi(`/blog/get_post_list?page=${ page }`);
|
||||
@@ -17,13 +16,13 @@ const PostList = async ({ page }: { page: number }) => {
|
||||
<ul className={ 'flex flex-col items-center w-full space-y-7' }>
|
||||
{ posts.map((post: JSON, key: number) => (
|
||||
<li key={ key } className={ 'w-full' }>
|
||||
<motion.div
|
||||
<MotionDiv
|
||||
initial={ { y: 20, opacity: 0 } }
|
||||
animate={ { y: 0, opacity: 1 } }
|
||||
transition={ { ease: 'easeInOut', duration: 0.5, delay: key * .2 + .25 } }
|
||||
>
|
||||
<BlogPostCard post={ post } />
|
||||
</motion.div>
|
||||
</MotionDiv>
|
||||
</li>)
|
||||
) }
|
||||
</ul>
|
||||
File renamed without changes.
@@ -1,10 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import { SITE_CONFIG } from '@/src/constants';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Blog - ${ SITE_CONFIG.name }`
|
||||
};
|
||||
|
||||
function Layout({
|
||||
children
|
||||
@@ -1,6 +1,6 @@
|
||||
import PostList from '@/app/(pages)/blog/PostList';
|
||||
import Pagination from '@/app/(pages)/blog/Pagination';
|
||||
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
|
||||
import PostList from '@/app/blog/PostList';
|
||||
import Pagination from '@/app/blog/Pagination';
|
||||
import { fetchNocturneApi } from '@/app/blog/api';
|
||||
|
||||
async function Blog({
|
||||
searchParams
|
||||
File renamed without changes.
+13
-21
@@ -1,32 +1,24 @@
|
||||
import React from 'react';
|
||||
import MinifyPostCard from '@/app/(pages)/blog/post/[id]/MinifyPostCard';
|
||||
import MinifyPostCard from '@/app/blog/post/[id]/MinifyPostCard';
|
||||
import Link from 'next/link';
|
||||
import { FaCreativeCommons } from 'react-icons/fa6';
|
||||
|
||||
type Props = {
|
||||
// post: any
|
||||
post_id: string;
|
||||
adjacent_posts: any
|
||||
perm_link: string | undefined;
|
||||
author: string | undefined;
|
||||
author_link: string | undefined;
|
||||
first_update: string;
|
||||
last_update: string | undefined;
|
||||
cc: string | undefined;
|
||||
title: string;
|
||||
post: any
|
||||
}
|
||||
|
||||
const Footer = (props: Props) => {
|
||||
const { adjacent_posts, perm_link, post_id, first_update, last_update, title } = props;
|
||||
const { post } = props;
|
||||
|
||||
const prev_post = adjacent_posts.prev;
|
||||
const next_post = adjacent_posts.next;
|
||||
const link = perm_link ?? `https://lumither.com/blog/post/${ post_id }`;
|
||||
const author = props.author ?? 'Tao';
|
||||
const author_link = props.author_link ?? '/about/me';
|
||||
const post_date = new Date(first_update);
|
||||
const update_date: Date | undefined = last_update ? new Date(last_update) : undefined;
|
||||
const cc = props.cc ?? 'by-nc-sa';
|
||||
const adjacent_posts = post['adj'];
|
||||
const prev_post = adjacent_posts['prev'];
|
||||
const next_post = adjacent_posts['next'];
|
||||
const link = post['meta']['link'] ?? `https://lumither.com/blog/post/${ post['meta']['id'] }`;
|
||||
const author = post['meta']['author'] ?? 'Tao';
|
||||
const author_link = post['meta']['author_link'] ?? '/about';
|
||||
const post_date = new Date(post['first_update']);
|
||||
const update_date: Date | undefined = post['last_update'] ? new Date(post['last_update']) : undefined;
|
||||
const cc = post['meta']['cc'] ?? 'by-nc-sa';
|
||||
|
||||
|
||||
return (
|
||||
@@ -43,7 +35,7 @@ const Footer = (props: Props) => {
|
||||
|
||||
<div className={ 'flex flex-col space-y-4 relative' }>
|
||||
<div className={ 'z-[2]' }>
|
||||
<p className={ 'text-xl font-bold' }>{ title }</p>
|
||||
<p className={ 'text-xl font-bold' }>{ post['meta']['title'] }</p>
|
||||
<Link href={ link }>
|
||||
<p className={ 'text-sm underline decoration-indigo-500 dark:decoration-sky-500' }>{ link }</p>
|
||||
</Link>
|
||||
+23
-23
@@ -3,57 +3,57 @@ import Image from 'next/image';
|
||||
import { Chip } from '@nextui-org/react';
|
||||
import { MdCalendarMonth } from 'react-icons/md';
|
||||
|
||||
// type Props = {
|
||||
// post: any
|
||||
// };
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
summary: string | undefined;
|
||||
category: string;
|
||||
tags: string[];
|
||||
first_update: string;
|
||||
header_img: string | undefined;
|
||||
}
|
||||
post: any
|
||||
};
|
||||
|
||||
|
||||
const Headers = (post: Props) => {
|
||||
const Headers = (props: Props) => {
|
||||
let post = props.post;
|
||||
let title = post['title'];
|
||||
let sub_title = post['sub_title'];
|
||||
// let summary = (post as any)['summary'];
|
||||
let category = post['category'];
|
||||
let tags = post['tags'];
|
||||
// let last_update = new Date((post as any)['last_update']);
|
||||
let first_update = new Date(post['first_update']);
|
||||
// let post_id = (post as any)['post_id'];
|
||||
let header_img = post['meta']['header_img'] as string | null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ post.header_img && <div className={ `w-full overflow h-96 relative` }>
|
||||
{ header_img && <div className={ `w-full overflow h-96 relative` }>
|
||||
<Image
|
||||
alt={ 'cover picture' }
|
||||
src={ post.header_img }
|
||||
src={ header_img }
|
||||
className={ `w-full object-cover rounded-b-none z-[5]` }
|
||||
fill
|
||||
></Image>
|
||||
<Image
|
||||
alt={ 'cover picture' }
|
||||
src={ post.header_img }
|
||||
src={ header_img }
|
||||
className={ `w-full relative scale-110 blur-3xl object-cover rounded-b-none` }
|
||||
fill
|
||||
></Image>
|
||||
</div> }
|
||||
|
||||
<div
|
||||
className={ post.header_img ?
|
||||
className={ header_img ?
|
||||
'mx-8 -mt-16 backdrop-blur transition bg-zinc-300/80 dark:bg-gray-600/25 rounded-2xl z-30'
|
||||
: 'mx-6 mt-6'
|
||||
}>
|
||||
<div className={ post.header_img ? `p-4` : 'p-4 pt-2' }>
|
||||
<div className={ header_img ? `p-4` : 'p-4 pt-2' }>
|
||||
<Chip className={ `mb-2` } radius={ `sm` } color={ `secondary` }
|
||||
variant={ `solid` }>{ post.category }</Chip>
|
||||
<p className={ `text-4xl font-bold` }> { post.title }</p>
|
||||
<p className={ `text-xl` }> { post.subtitle }</p>
|
||||
variant={ `solid` }>{ category }</Chip>
|
||||
<p className={ `text-4xl font-bold` }> { title }</p>
|
||||
<p className={ `text-xl` }> { sub_title }</p>
|
||||
</div>
|
||||
|
||||
<div className={ `mx-2 -mt-2 mb-2 flex flex-row flex-wrap justify-between w-full` }>
|
||||
<div>
|
||||
<ul className={ `flex flex-row flex-wrap` }>
|
||||
{
|
||||
post.tags.map((tag: string, key: any) => (
|
||||
tags.map((tag: string, key: any) => (
|
||||
<li key={ key } className={ `mx-1 mb-1` }>
|
||||
<Chip>{ tag }</Chip>
|
||||
</li>
|
||||
@@ -64,7 +64,7 @@ const Headers = (post: Props) => {
|
||||
<div className={ `flex flex-row place-content-end mb-1 mr-6 items-center mx-1` }>
|
||||
<MdCalendarMonth />
|
||||
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
|
||||
{ (new Date(post.first_update)).toLocaleString('default', {
|
||||
{ first_update.toLocaleString('default', {
|
||||
month: `long`,
|
||||
day: `numeric`,
|
||||
year: `numeric`
|
||||
+4
-4
@@ -43,10 +43,10 @@ const MinifyPostCard = (props: Props) => {
|
||||
}
|
||||
{ !img_url &&
|
||||
<div className={ 'h-full grid p-6 place-content-center' }>
|
||||
<div className={ 'flex flex-col max-h-fit text-left' }>
|
||||
<p className={ 'text-3xl font-bold text-balance' }>{ post['title'] }</p>
|
||||
<div className={ 'w-1/4 h-[2px] my-2 bg-zinc-500 dark:bg-zinc-400' }></div>
|
||||
<p className={ 'text-xl text-pretty break-words' }>{ post['sub_title'] }</p>
|
||||
<div className={ 'flex flex-row items-center text-center max-h-fit' }>
|
||||
<p className={ 'text-3xl font-bold' }>{ post['title'] }</p>
|
||||
<div className={ 'h-full w-1 mx-2 bg-zinc-500 dark:bg-zinc-400' }></div>
|
||||
<p className={ 'text-xl' }>{ post['sub_title'] }</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Card, CardBody } from '@nextui-org/card';
|
||||
import Headers from '@/app/blog/post/[id]/Headers';
|
||||
import Footer from '@/app/blog/post/[id]/Footer';
|
||||
import { fetchNocturneApi } from '@/app/blog/api';
|
||||
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
}
|
||||
|
||||
async function PostCard(props: Props) {
|
||||
|
||||
|
||||
try {
|
||||
const res = await fetchNocturneApi(`/blog/get_post/${ props.id }`);
|
||||
const ret: any = await res.json();
|
||||
return (
|
||||
<div>
|
||||
<Card className={ `max-w-full relative` }>
|
||||
|
||||
{/* card headers */ }
|
||||
<Headers post={ ret } />
|
||||
|
||||
{/* card body */ }
|
||||
<CardBody>
|
||||
<div className={ 'p-5' }>
|
||||
<MarkdownRenderer>
|
||||
{ ret['content'] }
|
||||
</MarkdownRenderer>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
|
||||
{/* card footer */ }
|
||||
<Footer post={ ret } />
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
} catch (e: any) {
|
||||
return <div>
|
||||
<Card className={ `w-full` }>
|
||||
<CardBody>
|
||||
Fatal: { e.toString() }
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default PostCard;
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PostCard from '@/app/blog/post/[id]/PostCard';
|
||||
import { MotionDiv } from '@/app/public/MotionDiv';
|
||||
import ButtonGoBack from '@/app/blog/post/[id]/ButtonGoBack';
|
||||
|
||||
async function BlogReader({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
|
||||
return (
|
||||
<MotionDiv
|
||||
className={ `max-w-full` }
|
||||
initial={ { y: 20, opacity: 0 } }
|
||||
animate={ { y: 0, opacity: 1 } }
|
||||
transition={ { ease: 'easeInOut', duration: 0.5 } }
|
||||
>
|
||||
<div className={ `max-w-full` }>
|
||||
<PostCard id={ (await params).id } />
|
||||
</div>
|
||||
<div className={ 'mt-3' }>
|
||||
<ButtonGoBack />
|
||||
</div>
|
||||
</MotionDiv>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlogReader;
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
IoFlask,
|
||||
IoHome,
|
||||
IoInformationCircle,
|
||||
IoLink,
|
||||
IoLogoGithub,
|
||||
IoMail,
|
||||
IoPencil,
|
||||
IoSearch
|
||||
} from 'react-icons/io5';
|
||||
|
||||
export const entries: { display_name: string; href: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
icon: <IoHome size={ `20px` }></IoHome>,
|
||||
display_name: 'Home',
|
||||
href: '/'
|
||||
},
|
||||
{
|
||||
icon: <IoInformationCircle size={ `20px` }></IoInformationCircle>,
|
||||
display_name: 'About',
|
||||
href: '/about'
|
||||
},
|
||||
{
|
||||
icon: <IoPencil size={ `20px` }></IoPencil>,
|
||||
display_name: 'Blog',
|
||||
href: '/blog'
|
||||
},
|
||||
{
|
||||
icon: <IoLink size={ `20px` }></IoLink>,
|
||||
display_name: 'Friends',
|
||||
href: '/friends'
|
||||
},
|
||||
{
|
||||
icon: <IoSearch size={ `20px` }></IoSearch>,
|
||||
display_name: 'Search',
|
||||
href: '/search'
|
||||
}, {
|
||||
icon: <IoFlask size={ '20px' } />,
|
||||
display_name: 'Lab',
|
||||
href: 'https://lab.lmt.moe'
|
||||
}
|
||||
];
|
||||
|
||||
export const connections: { label: string, href: string, icon: React.ReactNode }[] = [
|
||||
{
|
||||
label: 'Github',
|
||||
href: 'https://github.com/Lumither',
|
||||
icon: <IoLogoGithub size={ `30px` } />
|
||||
},
|
||||
{
|
||||
label: 'email',
|
||||
href: 'mailto:lumither@outlook.com',
|
||||
icon: <IoMail size={ `30px` } />
|
||||
}
|
||||
];
|
||||
@@ -1,10 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import { SITE_CONFIG } from '@/src/constants';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Friends - ${ SITE_CONFIG.name }`
|
||||
};
|
||||
|
||||
function Layout({
|
||||
children
|
||||
File renamed without changes.
+12
-7
@@ -2,14 +2,13 @@ import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import React from 'react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import { SITE_CONFIG } from '@/src/constants';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
siteName: SITE_CONFIG.name
|
||||
},
|
||||
title: SITE_CONFIG.name,
|
||||
description: SITE_CONFIG.desc
|
||||
title: 'Lumitherの酒馆',
|
||||
description: '一间位于时间夹缝中的酒馆,静候下一位旅者的到来...'
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -21,7 +20,13 @@ export default function RootLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={ `transition duration-300 dark:bg-[#282830] min-h-screen` }>
|
||||
<NextThemesProvider attribute="class" defaultTheme="system">
|
||||
{ children }
|
||||
<div className={ `justify-center flex flex-row w-full max-w-full` }>
|
||||
<Navbar />
|
||||
<div className={ `flex flex-col flex-1 max-w-[1024px] min-w-0` }>
|
||||
{ children }
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</NextThemesProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user