mirror of
https://github.com/Lumither/nocturne.git
synced 2026-10-17 10:26:21 +00:00
Compare commits
19
Commits
release
...
1038351f4a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1038351f4a | ||
|
|
b748764d79 | ||
|
|
ac5f30151f | ||
|
|
d54996b690 | ||
|
|
7b84a63a14 | ||
|
|
7cc44522fa | ||
|
|
2045d380c1 | ||
|
|
dc781bc2ef | ||
|
|
2e32297d5e | ||
|
|
5e8b1ff35d | ||
|
|
0c6325d5f2 | ||
|
|
ca86dd236e | ||
|
|
8358a8ae33 | ||
|
|
84ec754c65 | ||
|
|
bd90cd94d1 | ||
|
|
5da9ca3ae1 | ||
|
|
eef32ad695 | ||
|
|
acd0525513 | ||
|
|
8938c96409 |
No files matched your search
@@ -8,3 +8,6 @@
|
|||||||
|
|
||||||
# exclude post repo
|
# exclude post repo
|
||||||
/blog_posts_git
|
/blog_posts_git
|
||||||
|
|
||||||
|
# rustc ICE dump
|
||||||
|
rustc-ice-*.txt
|
||||||
@@ -3,14 +3,7 @@ ifeq ($(ARCH), arm64)
|
|||||||
ARCH := aarch64
|
ARCH := aarch64
|
||||||
endif
|
endif
|
||||||
|
|
||||||
all: release
|
.PHONY: up
|
||||||
|
|
||||||
release:
|
|
||||||
docker compose -f prod.compose.yaml build --build-arg ARCH=$(ARCH)
|
|
||||||
|
|
||||||
dev:
|
|
||||||
docker-compose -f dev.compose.yaml up --build
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
docker-compose -f prod.compose.yaml -f dev.compose.yaml rm -fsv
|
echo "todo"
|
||||||
|
|
||||||
@@ -1,36 +1,16 @@
|
|||||||
# `Nocturne: personal website & CMS`
|
# `Nocturne: Personal Website`
|
||||||
|
|
||||||
This is the code repo of my personal website, and [here](https://github.com/Lumither/blog-posts/) is the link of the
|
This is the code repo of my [personal website](https://lumither.com),
|
||||||
content repo.
|
and [here](https://github.com/Lumither/blog-posts/) is the link to the
|
||||||
|
content (blog posts etc.) repo.
|
||||||
|
|
||||||
Powered by Next.js and Rust, built with passion and love
|
Powered by Next.js and Rust, built with passion and love
|
||||||
|
|
||||||
<p align="center">
|
> [!NOTE]
|
||||||
<img src="https://avatars.githubusercontent.com/u/46409277?v=4" alt="GitHub Avatar"/>
|
> This system is not production ready, and contains bugs, (security risk potentially).
|
||||||
</p>
|
> 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...
|
||||||
+2
-3
@@ -1,8 +1,7 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "3"
|
||||||
members = [
|
members = [
|
||||||
"api_server",
|
"server",
|
||||||
"constants",
|
|
||||||
"macros",
|
"macros",
|
||||||
"markdown",
|
"markdown",
|
||||||
]
|
]
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[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"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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()})))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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,3 +0,0 @@
|
|||||||
pub mod get_page_count;
|
|
||||||
pub mod get_post;
|
|
||||||
pub mod get_post_list;
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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", "")
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
mod blog;
|
|
||||||
mod init;
|
|
||||||
mod scheduler;
|
|
||||||
mod utils;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
init::load_env();
|
|
||||||
|
|
||||||
let _guards = init::logger::init();
|
|
||||||
|
|
||||||
init::start().await;
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub trait TaskFunc: Fn() + Send + Sync + 'static {}
|
|
||||||
|
|
||||||
impl<T> TaskFunc for T where T: Fn() + Send + Sync + Clone + 'static {}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
pub mod basic;
|
|
||||||
|
|
||||||
pub trait Task: Send + Sync {
|
|
||||||
fn get_next_execution(&self) -> Option<DateTime<Utc>>;
|
|
||||||
fn call(&self);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "macros"
|
name = "macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#[macro_export]
|
||||||
|
macro_rules! dev_consume {
|
||||||
|
($($var:ident),+) => {
|
||||||
|
$(let _ = $var;)*
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/// 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 +1,3 @@
|
|||||||
|
mod dev;
|
||||||
|
mod include_str;
|
||||||
mod log;
|
mod log;
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
/// use macros::panic_with_log;
|
/// use macros::panic_with_log;
|
||||||
/// use tracing::Level;
|
/// use tracing::Level;
|
||||||
///
|
///
|
||||||
/// panic_with_log!(Level::ERROR, "Test panicked: {}", 42);
|
/// panic_with_log!(Level::ERROR, "test panicked: {}", 42);
|
||||||
/// ```
|
/// ```
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! panic_with_log {
|
macro_rules! panic_with_log {
|
||||||
@@ -18,6 +18,25 @@ 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
@@ -25,7 +44,7 @@ mod tests {
|
|||||||
use std::io;
|
use std::io;
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
use tracing_subscriber::prelude::*;
|
use tracing_subscriber::prelude::*;
|
||||||
use tracing_subscriber::{fmt, Registry};
|
use tracing_subscriber::{Registry, fmt};
|
||||||
|
|
||||||
let format = tracing_subscriber::fmt::format()
|
let format = tracing_subscriber::fmt::format()
|
||||||
.with_level(true)
|
.with_level(true)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "markdown"
|
name = "markdown"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] }
|
uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] }
|
||||||
serde = { version = "1.0.209", features = ["derive"] }
|
serde = { version = "1.0.209", features = ["derive"] }
|
||||||
serde_json = "1.0.128"
|
serde_json = "1.0.128"
|
||||||
regex = "1.10.4"
|
regex = "1.10.4"
|
||||||
yaml-rust2 = "0.9.0"
|
yaml-rust2 = "0.10.0"
|
||||||
sha2 = "0.10.8"
|
sha2 = "0.10.8"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
thiserror = "2.0.3"
|
thiserror = "2.0.3"
|
||||||
@@ -2,12 +2,6 @@ use thiserror::Error;
|
|||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
#[error("missing front-matter field: id")]
|
|
||||||
MissingID,
|
|
||||||
|
|
||||||
#[error("invalid file id: {}", msg)]
|
|
||||||
InvalidID { msg: String },
|
|
||||||
|
|
||||||
#[error("invalid front-matter: {}", msg)]
|
#[error("invalid front-matter: {}", msg)]
|
||||||
InvalidFrontMatter { msg: String },
|
InvalidFrontMatter { msg: String },
|
||||||
|
|
||||||
|
|||||||
+17
-28
@@ -1,23 +1,20 @@
|
|||||||
mod error;
|
mod error;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use std::fs;
|
use std::{fs, path::Path, str::FromStr};
|
||||||
use std::path::Path;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use crate::error::Error;
|
use crate::{
|
||||||
use crate::utils::front_matter::{parse_front_matter, split_md_front_matter};
|
error::Error,
|
||||||
|
utils::front_matter::{parse_front_matter, split_md_front_matter},
|
||||||
|
};
|
||||||
|
|
||||||
use base64::{engine::general_purpose, Engine};
|
use base64::{Engine, engine::general_purpose};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Serialize)]
|
#[derive(Default, Debug, Serialize)]
|
||||||
pub struct MdFile {
|
pub struct MdFile {
|
||||||
pub file_id: Uuid,
|
|
||||||
pub filename: String,
|
|
||||||
pub meta: Value,
|
pub meta: Value,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub hash: String,
|
pub hash: String,
|
||||||
@@ -29,30 +26,22 @@ impl MdFile {
|
|||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(e) => return Err(Error::FSError { msg: e.to_string() }),
|
Err(e) => return Err(Error::FSError { msg: e.to_string() }),
|
||||||
};
|
};
|
||||||
let raw_file_bytes = raw_file.as_bytes().to_vec();
|
Self::from_str(&raw_file)
|
||||||
let (front_matter_str, content) = split_md_front_matter(raw_file);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for MdFile {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let bytes = s.as_bytes().to_vec();
|
||||||
|
let (front_matter_str, content) = split_md_front_matter(s);
|
||||||
let front_matter = match parse_front_matter(&front_matter_str) {
|
let front_matter = match parse_front_matter(&front_matter_str) {
|
||||||
Ok(front_matter) => front_matter,
|
Ok(front_matter) => front_matter,
|
||||||
Err(e) => return Err(Error::InvalidFrontMatter { msg: e.to_string() }),
|
Err(e) => return Err(Error::InvalidFrontMatter { msg: e.to_string() }),
|
||||||
};
|
};
|
||||||
let hash = calculate_hash_base64(&raw_file_bytes);
|
let hash = calculate_hash_base64(&bytes);
|
||||||
let file_id = match front_matter["id"].as_str() {
|
|
||||||
Some(id) => match Uuid::from_str(id) {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(e) => return Err(Error::InvalidID { msg: e.to_string() }),
|
|
||||||
},
|
|
||||||
None => return Err(Error::MissingID),
|
|
||||||
};
|
|
||||||
let file_name = Path::new(file_path)
|
|
||||||
.file_name()
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(MdFile {
|
Ok(MdFile {
|
||||||
file_id,
|
|
||||||
filename: file_name,
|
|
||||||
meta: front_matter,
|
meta: front_matter,
|
||||||
content,
|
content,
|
||||||
hash,
|
hash,
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
use crate::utils::yaml::yaml_to_serde;
|
use crate::utils::yaml::yaml_to_serde;
|
||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::error::Error;
|
|
||||||
use yaml_rust2::YamlLoader;
|
use yaml_rust2::YamlLoader;
|
||||||
|
|
||||||
pub fn split_md_front_matter(raw_file: String) -> (String, String) {
|
pub fn split_md_front_matter(raw_file: &str) -> (String, String) {
|
||||||
let re = Regex::new(r"(?s)^(---\n.*?\n---\n)(.*)").unwrap();
|
let re = Regex::new(r"(?s)^(---\n.*?\n---\n)(.*)").unwrap();
|
||||||
if let Some(captures) = re.captures(raw_file.as_str()) {
|
if let Some(captures) = re.captures(raw_file) {
|
||||||
let front_matter = captures.get(1).map_or("", |m| m.as_str()).to_string();
|
let front_matter = captures.get(1).map_or("", |m| m.as_str()).to_string();
|
||||||
let main_content = captures.get(2).map_or("", |m| m.as_str()).to_string();
|
let main_content = captures.get(2).map_or("", |m| m.as_str()).to_string();
|
||||||
(front_matter, main_content)
|
(front_matter, main_content)
|
||||||
} else {
|
} else {
|
||||||
(String::new(), raw_file)
|
(String::new(), raw_file.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +45,7 @@ tags: [ "test1", "test2" ]
|
|||||||
|
|
||||||
test body
|
test body
|
||||||
|
|
||||||
"##
|
"##,
|
||||||
.to_string(),
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
front_matter,
|
front_matter,
|
||||||
@@ -64,7 +65,7 @@ tags: [ "test1", "test2" ]
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_front_matter_parser() {
|
fn test_front_matter_parser() {
|
||||||
dbg!(parse_front_matter(
|
parse_front_matter(
|
||||||
r##"---
|
r##"---
|
||||||
title: test_title
|
title: test_title
|
||||||
date: 0000-00-00+0000
|
date: 0000-00-00+0000
|
||||||
@@ -75,7 +76,7 @@ summary: summary
|
|||||||
tags: [ "test1", "test2" ]
|
tags: [ "test1", "test2" ]
|
||||||
---
|
---
|
||||||
"##,
|
"##,
|
||||||
))
|
)
|
||||||
.expect("Failed to parse front matter");
|
.expect("Failed to parse front matter");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[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"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -2,7 +2,8 @@ use std::env;
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use constants::config::database::{default_value, var_name};
|
use crate::constants::config::database::{default_value, var_name};
|
||||||
|
|
||||||
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
||||||
use sqlx::{ConnectOptions, Pool, Postgres};
|
use sqlx::{ConnectOptions, Pool, Postgres};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -12,7 +13,7 @@ pub async fn init() -> Result<Pool<Postgres>, Box<dyn Error>> {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.disable_statement_logging();
|
.disable_statement_logging();
|
||||||
Ok(PgPoolOptions::new()
|
Ok(PgPoolOptions::new()
|
||||||
.max_connections(1) // todo: temporal solution, to be fixed
|
.max_connections(1) // todo: temporary solution, to be fixed
|
||||||
.connect_with(db_connect_option)
|
.connect_with(db_connect_option)
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::{env, io};
|
use std::{env, io};
|
||||||
|
|
||||||
|
use crate::constants::config::general::{default_value, var_name};
|
||||||
|
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use constants::config::general::{default_value, var_name};
|
|
||||||
use dirs::home_dir;
|
use dirs::home_dir;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling};
|
use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling};
|
||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
filter::LevelFilter, fmt, fmt::format::Writer, fmt::time::FormatTime, fmt::MakeWriter,
|
Layer, Registry, filter::LevelFilter, fmt, fmt::MakeWriter, fmt::format::Writer,
|
||||||
layer::SubscriberExt, Layer, Registry,
|
fmt::time::FormatTime, layer::SubscriberExt,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LocalTimer;
|
struct LocalTimer;
|
||||||
@@ -18,7 +19,7 @@ impl FormatTime for LocalTimer {
|
|||||||
write!(
|
write!(
|
||||||
w,
|
w,
|
||||||
"{}",
|
"{}",
|
||||||
Local::now().format(constants::time::DEFAULT_TIME_FORMAT)
|
Local::now().format(crate::constants::time::DEFAULT_TIME_FORMAT)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File renamed without changes.
@@ -1,13 +1,13 @@
|
|||||||
mod cron;
|
|
||||||
mod database;
|
mod database;
|
||||||
pub mod logger;
|
pub mod logger;
|
||||||
mod misc;
|
mod misc;
|
||||||
mod schema;
|
|
||||||
pub mod server;
|
|
||||||
|
|
||||||
use macros::panic_with_log;
|
|
||||||
use std::{env, panic};
|
use std::{env, panic};
|
||||||
use tracing::{info, Level};
|
|
||||||
|
use crate::modules::{ModuleTree, blog::Blog};
|
||||||
|
use macros::error_panic;
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
pub fn load_env() {
|
pub fn load_env() {
|
||||||
if let Some(env_file) = env::args().nth(1) {
|
if let Some(env_file) = env::args().nth(1) {
|
||||||
@@ -34,25 +34,17 @@ pub async fn start() {
|
|||||||
pool
|
pool
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
panic_with_log!(
|
error_panic!("database connection pool failed to initialize: {}", e);
|
||||||
Level::ERROR,
|
|
||||||
"database connection pool failed to initialize: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// schema init
|
// // schema init
|
||||||
schema::init(&db_pool).await;
|
// schema::init(&db_pool).await;
|
||||||
|
|
||||||
// load cron task
|
ModuleTree::new()
|
||||||
cron::start(db_pool.clone());
|
.add_module(Blog {
|
||||||
|
db_handler: db_pool,
|
||||||
// api serve
|
})
|
||||||
match server::start(db_pool).await {
|
.blocking_serve()
|
||||||
Ok(_) => {}
|
.await
|
||||||
Err(e) => {
|
|
||||||
panic_with_log!(Level::ERROR, "server panicked: {}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#[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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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()})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
SELECT cast(ceil(count(*) / cast($1 AS FLOAT)) AS INTEGER) AS count
|
||||||
|
FROM posts;
|
||||||
File renamed without changes.
Whitespace-only changes.
@@ -0,0 +1,34 @@
|
|||||||
|
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
-1
@@ -1,6 +1,6 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
use constants::blog::PAGE_SIZE;
|
use crate::constants::blog::PAGE_SIZE;
|
||||||
|
|
||||||
use axum::{extract::Query, extract::State, http::StatusCode, Json};
|
use axum::{extract::Query, extract::State, http::StatusCode, Json};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod get_page_count;
|
||||||
|
|
||||||
|
// todo: remove exception tag
|
||||||
|
#[allow(dead_code)]
|
||||||
|
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::error::Error;
|
||||||
use std::path::Path;
|
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 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 sqlx::{query, PgPool, Pool, Postgres, Row};
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -16,7 +16,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
|||||||
return Err(PostIdxError::MissingField {
|
return Err(PostIdxError::MissingField {
|
||||||
field: "title".to_string(),
|
field: "title".to_string(),
|
||||||
filename: post.filename.clone(),
|
filename: post.filename.clone(),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
Some(title) => title.as_str().unwrap_or_else(|| {
|
Some(title) => title.as_str().unwrap_or_else(|| {
|
||||||
warn!("invalid title for `{}`", &post.filename);
|
warn!("invalid title for `{}`", &post.filename);
|
||||||
@@ -39,7 +39,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
|||||||
return Err(PostIdxError::MissingField {
|
return Err(PostIdxError::MissingField {
|
||||||
field: "category".to_string(),
|
field: "category".to_string(),
|
||||||
filename: post.filename.clone(),
|
filename: post.filename.clone(),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
Some(title) => title.as_str().unwrap_or_else(|| {
|
Some(title) => title.as_str().unwrap_or_else(|| {
|
||||||
warn!("invalid category for `{}`", &post.filename);
|
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(),
|
err_field: last_update.to_string(),
|
||||||
filename: post.filename.clone(),
|
filename: post.filename.clone(),
|
||||||
msg: e.to_string(),
|
msg: e.to_string(),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -68,7 +68,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
|||||||
return Err(PostIdxError::MissingField {
|
return Err(PostIdxError::MissingField {
|
||||||
field: "date".to_string(),
|
field: "date".to_string(),
|
||||||
filename: post.filename.clone(),
|
filename: post.filename.clone(),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
Some(first_update) => match parse_date(first_update.as_str().unwrap()) {
|
Some(first_update) => match parse_date(first_update.as_str().unwrap()) {
|
||||||
Ok(date) => date,
|
Ok(date) => date,
|
||||||
@@ -78,7 +78,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
|||||||
err_field: first_update.to_string(),
|
err_field: first_update.to_string(),
|
||||||
filename: post.filename.clone(),
|
filename: post.filename.clone(),
|
||||||
msg: e.to_string(),
|
msg: e.to_string(),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -135,7 +135,7 @@ async fn tag(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
|||||||
return Err(PostIdxError::MissingField {
|
return Err(PostIdxError::MissingField {
|
||||||
field: "tags".to_string(),
|
field: "tags".to_string(),
|
||||||
filename: post.filename.clone(),
|
filename: post.filename.clone(),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
Some(val) => val,
|
Some(val) => val,
|
||||||
};
|
};
|
||||||
@@ -171,7 +171,7 @@ async fn tag(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
|
|||||||
db_table: "Tag".to_string(),
|
db_table: "Tag".to_string(),
|
||||||
id: post_id.to_string(),
|
id: post_id.to_string(),
|
||||||
msg: e.to_string(),
|
msg: e.to_string(),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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", "")
|
||||||
|
// }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod check_update;
|
||||||
|
mod rag;
|
||||||
Whitespace-only changes.
@@ -0,0 +1,64 @@
|
|||||||
|
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(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-4
@@ -1,8 +1,13 @@
|
|||||||
use crate::scheduler::scheduler_error::SchedulerError::MutexPoison;
|
use std::{
|
||||||
use std::error::Error;
|
error::Error,
|
||||||
use std::fmt::{Debug, Display, Formatter};
|
fmt::{Debug, Display, Formatter},
|
||||||
use std::sync::PoisonError;
|
sync::PoisonError,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::scheduler::error::SchedulerError::MutexPoison;
|
||||||
|
|
||||||
|
// todo: refactor with thiserror
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum SchedulerError {
|
pub enum SchedulerError {
|
||||||
IdNotFound,
|
IdNotFound,
|
||||||
IdAlreadyRunning,
|
IdAlreadyRunning,
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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)())
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -1,9 +1,9 @@
|
|||||||
use crate::scheduler::task_func::TaskFunc;
|
use std::{error::Error, pin::Pin, str::FromStr};
|
||||||
use crate::scheduler::tasks::Task;
|
|
||||||
|
use crate::scheduler::{task_func::TaskFunc, tasks::CronTask};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use cron::Schedule;
|
use cron::Schedule;
|
||||||
use std::error::Error;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
pub struct BasicTask {
|
pub struct BasicTask {
|
||||||
schedule: Schedule,
|
schedule: Schedule,
|
||||||
@@ -21,17 +21,17 @@ impl BasicTask {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_task(self) -> Box<dyn Task> {
|
pub fn to_task(self) -> Box<dyn CronTask> {
|
||||||
Box::new(self)
|
Box::new(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Task for BasicTask {
|
impl CronTask for BasicTask {
|
||||||
fn get_next_execution(&self) -> Option<DateTime<Utc>> {
|
fn get_next_execution(&self) -> Option<DateTime<Utc>> {
|
||||||
self.schedule.upcoming(Utc).next()
|
self.schedule.upcoming(Utc).next()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn call(&self) {
|
fn call(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||||
(self.func)()
|
Box::pin(async { (self.func)() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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 + '_>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -23,8 +23,6 @@ fn do_fetch<'a>(
|
|||||||
) -> Result<AnnotatedCommit<'a>, git2::Error> {
|
) -> Result<AnnotatedCommit<'a>, git2::Error> {
|
||||||
let mut fetch_option = FetchOptions::new();
|
let mut fetch_option = FetchOptions::new();
|
||||||
|
|
||||||
// Always fetch all tags.
|
|
||||||
// Perform a download and also update tips
|
|
||||||
fetch_option.download_tags(AutotagOption::All);
|
fetch_option.download_tags(AutotagOption::All);
|
||||||
remote.fetch(refs, Some(&mut fetch_option), None)?;
|
remote.fetch(refs, Some(&mut fetch_option), None)?;
|
||||||
|
|
||||||
@@ -45,13 +43,7 @@ fn fast_forward(
|
|||||||
// println!("{}", msg);
|
// println!("{}", msg);
|
||||||
lb.set_target(rc.id(), &msg)?;
|
lb.set_target(rc.id(), &msg)?;
|
||||||
repo.set_head(&name)?;
|
repo.set_head(&name)?;
|
||||||
repo.checkout_head(Some(
|
repo.checkout_head(Some(build::CheckoutBuilder::default().force()))?;
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,13 +65,12 @@ fn normal_merge(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
|
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 msg = format!("merge({}) -> {}", remote.id(), local.id());
|
||||||
let sig = repo.signature()?;
|
let sig = repo.signature()?;
|
||||||
let local_commit = repo.find_commit(local.id())?;
|
let local_commit = repo.find_commit(local.id())?;
|
||||||
let remote_commit = repo.find_commit(remote.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(
|
let _merge_commit = repo.commit(
|
||||||
Some("HEAD"),
|
Some("HEAD"),
|
||||||
&sig,
|
&sig,
|
||||||
@@ -88,7 +79,6 @@ fn normal_merge(
|
|||||||
&result_tree,
|
&result_tree,
|
||||||
&[&local_commit, &remote_commit],
|
&[&local_commit, &remote_commit],
|
||||||
)?;
|
)?;
|
||||||
// Set working tree to match head.
|
|
||||||
repo.checkout_head(None)?;
|
repo.checkout_head(None)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -98,24 +88,16 @@ fn do_merge<'a>(
|
|||||||
remote_branch: &str,
|
remote_branch: &str,
|
||||||
fetch_commit: AnnotatedCommit<'a>,
|
fetch_commit: AnnotatedCommit<'a>,
|
||||||
) -> Result<Vec<FileDelta>, git2::Error> {
|
) -> Result<Vec<FileDelta>, git2::Error> {
|
||||||
// 1. do a merge analysis
|
|
||||||
let analysis = repo.merge_analysis(&[&fetch_commit])?;
|
let analysis = repo.merge_analysis(&[&fetch_commit])?;
|
||||||
|
|
||||||
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
|
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
|
||||||
|
|
||||||
// 2. Do the appropriate merge
|
|
||||||
if analysis.0.is_fast_forward() {
|
if analysis.0.is_fast_forward() {
|
||||||
// println!("Doing a fast-forward");
|
|
||||||
// do a fast-forward
|
|
||||||
let ref_name = format!("refs/heads/{}", remote_branch);
|
let ref_name = format!("refs/heads/{}", remote_branch);
|
||||||
match repo.find_reference(&ref_name) {
|
match repo.find_reference(&ref_name) {
|
||||||
Ok(mut r) => {
|
Ok(mut r) => {
|
||||||
fast_forward(repo, &mut r, &fetch_commit)?;
|
fast_forward(repo, &mut r, &fetch_commit)?;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
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(
|
repo.reference(
|
||||||
&ref_name,
|
&ref_name,
|
||||||
fetch_commit.id(),
|
fetch_commit.id(),
|
||||||
@@ -132,14 +114,8 @@ fn do_merge<'a>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else if analysis.0.is_normal() {
|
} else if analysis.0.is_normal() {
|
||||||
// do a normal merge
|
|
||||||
normal_merge(repo, &head_commit, &fetch_commit)?;
|
normal_merge(repo, &head_commit, &fetch_commit)?;
|
||||||
}
|
}
|
||||||
// else {
|
|
||||||
// // already up-to-date
|
|
||||||
// println!("Nothing to do...");
|
|
||||||
// }
|
|
||||||
|
|
||||||
calculate_diff(repo, &head_commit, &fetch_commit)
|
calculate_diff(repo, &head_commit, &fetch_commit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,23 +132,12 @@ fn calculate_diff(
|
|||||||
|
|
||||||
if let Err(e) = diff.foreach(
|
if let Err(e) = diff.foreach(
|
||||||
&mut |d, _| {
|
&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 {
|
updated_files.push(FileDelta {
|
||||||
old_path: d.old_file().path().map(PathBuf::from),
|
old_path: d.old_file().path().map(PathBuf::from),
|
||||||
new_path: d.new_file().path().map(PathBuf::from),
|
new_path: d.new_file().path().map(PathBuf::from),
|
||||||
status: d.status(),
|
status: d.status(),
|
||||||
});
|
});
|
||||||
true // Continue
|
true
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
File renamed without changes.
@@ -0,0 +1,11 @@
|
|||||||
|
.venv/
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
.DS_store
|
||||||
|
|
||||||
|
__pycache__
|
||||||
|
|
||||||
|
.cache
|
||||||
|
|
||||||
|
content/*
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## 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`.
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
rich~=13.9.4
|
||||||
|
prompt_toolkit~=3.0.50
|
||||||
|
pypinyin~=0.53.0
|
||||||
|
python-frontmatter~=1.1.0
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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}"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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))
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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,4 +1,10 @@
|
|||||||
import React from 'react';
|
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({
|
function Layout({
|
||||||
children
|
children
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
|
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
|
||||||
import { fetchText } from '@/app/fetch';
|
import { fetchText } from '@/app/fetch';
|
||||||
|
|
||||||
const Me = async () => {
|
const Me = async () => {
|
||||||
File renamed without changes.
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
|
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
|
||||||
import { fetchText } from '@/app/fetch';
|
import { fetchText } from '@/app/fetch';
|
||||||
|
|
||||||
const Website = async () => {
|
const Website = async () => {
|
||||||
File renamed without changes.
File renamed without changes.
@@ -3,7 +3,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pagination as NextUIPagination } from '@nextui-org/react';
|
import { Pagination as NextUIPagination } from '@nextui-org/react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useScreenSizeTrigger } from '@/app/public/screenSizeTrigger';
|
import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
|
||||||
|
|
||||||
const Pagination = ({ total }: {
|
const Pagination = ({ total }: {
|
||||||
total: number,
|
total: number,
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { fetchNocturneApi } from '@/app/blog/api';
|
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
|
||||||
import { MotionDiv } from '@/app/public/MotionDiv';
|
import BlogPostCard from '@/app/(pages)/blog/BlogPostCard';
|
||||||
import BlogPostCard from '@/app/blog/BlogPostCard';
|
|
||||||
import { Card, CardBody } from '@nextui-org/card';
|
import { Card, CardBody } from '@nextui-org/card';
|
||||||
|
|
||||||
|
import * as motion from 'motion/react-client';
|
||||||
|
|
||||||
const PostList = async ({ page }: { page: number }) => {
|
const PostList = async ({ page }: { page: number }) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetchNocturneApi(`/blog/get_post_list?page=${ page }`);
|
const res = await fetchNocturneApi(`/blog/get_post_list?page=${ page }`);
|
||||||
@@ -16,13 +17,13 @@ const PostList = async ({ page }: { page: number }) => {
|
|||||||
<ul className={ 'flex flex-col items-center w-full space-y-7' }>
|
<ul className={ 'flex flex-col items-center w-full space-y-7' }>
|
||||||
{ posts.map((post: JSON, key: number) => (
|
{ posts.map((post: JSON, key: number) => (
|
||||||
<li key={ key } className={ 'w-full' }>
|
<li key={ key } className={ 'w-full' }>
|
||||||
<MotionDiv
|
<motion.div
|
||||||
initial={ { y: 20, opacity: 0 } }
|
initial={ { y: 20, opacity: 0 } }
|
||||||
animate={ { y: 0, opacity: 1 } }
|
animate={ { y: 0, opacity: 1 } }
|
||||||
transition={ { ease: 'easeInOut', duration: 0.5, delay: key * .2 + .25 } }
|
transition={ { ease: 'easeInOut', duration: 0.5, delay: key * .2 + .25 } }
|
||||||
>
|
>
|
||||||
<BlogPostCard post={ post } />
|
<BlogPostCard post={ post } />
|
||||||
</MotionDiv>
|
</motion.div>
|
||||||
</li>)
|
</li>)
|
||||||
) }
|
) }
|
||||||
</ul>
|
</ul>
|
||||||
File renamed without changes.
@@ -1,4 +1,10 @@
|
|||||||
import React from 'react';
|
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({
|
function Layout({
|
||||||
children
|
children
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import PostList from '@/app/blog/PostList';
|
import PostList from '@/app/(pages)/blog/PostList';
|
||||||
import Pagination from '@/app/blog/Pagination';
|
import Pagination from '@/app/(pages)/blog/Pagination';
|
||||||
import { fetchNocturneApi } from '@/app/blog/api';
|
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
|
||||||
|
|
||||||
async function Blog({
|
async function Blog({
|
||||||
searchParams
|
searchParams
|
||||||
File renamed without changes.
+21
-13
@@ -1,24 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import MinifyPostCard from '@/app/blog/post/[id]/MinifyPostCard';
|
import MinifyPostCard from '@/app/(pages)/blog/post/[id]/MinifyPostCard';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { FaCreativeCommons } from 'react-icons/fa6';
|
import { FaCreativeCommons } from 'react-icons/fa6';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
post: any
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Footer = (props: Props) => {
|
const Footer = (props: Props) => {
|
||||||
const { post } = props;
|
const { adjacent_posts, perm_link, post_id, first_update, last_update, title } = props;
|
||||||
|
|
||||||
const adjacent_posts = post['adj'];
|
const prev_post = adjacent_posts.prev;
|
||||||
const prev_post = adjacent_posts['prev'];
|
const next_post = adjacent_posts.next;
|
||||||
const next_post = adjacent_posts['next'];
|
const link = perm_link ?? `https://lumither.com/blog/post/${ post_id }`;
|
||||||
const link = post['meta']['link'] ?? `https://lumither.com/blog/post/${ post['meta']['id'] }`;
|
const author = props.author ?? 'Tao';
|
||||||
const author = post['meta']['author'] ?? 'Tao';
|
const author_link = props.author_link ?? '/about/me';
|
||||||
const author_link = post['meta']['author_link'] ?? '/about';
|
const post_date = new Date(first_update);
|
||||||
const post_date = new Date(post['first_update']);
|
const update_date: Date | undefined = last_update ? new Date(last_update) : undefined;
|
||||||
const update_date: Date | undefined = post['last_update'] ? new Date(post['last_update']) : undefined;
|
const cc = props.cc ?? 'by-nc-sa';
|
||||||
const cc = post['meta']['cc'] ?? 'by-nc-sa';
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,7 +43,7 @@ const Footer = (props: Props) => {
|
|||||||
|
|
||||||
<div className={ 'flex flex-col space-y-4 relative' }>
|
<div className={ 'flex flex-col space-y-4 relative' }>
|
||||||
<div className={ 'z-[2]' }>
|
<div className={ 'z-[2]' }>
|
||||||
<p className={ 'text-xl font-bold' }>{ post['meta']['title'] }</p>
|
<p className={ 'text-xl font-bold' }>{ title }</p>
|
||||||
<Link href={ link }>
|
<Link href={ link }>
|
||||||
<p className={ 'text-sm underline decoration-indigo-500 dark:decoration-sky-500' }>{ link }</p>
|
<p className={ 'text-sm underline decoration-indigo-500 dark:decoration-sky-500' }>{ link }</p>
|
||||||
</Link>
|
</Link>
|
||||||
+23
-23
@@ -3,57 +3,57 @@ import Image from 'next/image';
|
|||||||
import { Chip } from '@nextui-org/react';
|
import { Chip } from '@nextui-org/react';
|
||||||
import { MdCalendarMonth } from 'react-icons/md';
|
import { MdCalendarMonth } from 'react-icons/md';
|
||||||
|
|
||||||
|
// type Props = {
|
||||||
|
// post: any
|
||||||
|
// };
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
post: any
|
title: string;
|
||||||
};
|
subtitle: string;
|
||||||
|
summary: string | undefined;
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
first_update: string;
|
||||||
|
header_img: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const Headers = (props: Props) => {
|
const Headers = (post: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{ header_img && <div className={ `w-full overflow h-96 relative` }>
|
{ post.header_img && <div className={ `w-full overflow h-96 relative` }>
|
||||||
<Image
|
<Image
|
||||||
alt={ 'cover picture' }
|
alt={ 'cover picture' }
|
||||||
src={ header_img }
|
src={ post.header_img }
|
||||||
className={ `w-full object-cover rounded-b-none z-[5]` }
|
className={ `w-full object-cover rounded-b-none z-[5]` }
|
||||||
fill
|
fill
|
||||||
></Image>
|
></Image>
|
||||||
<Image
|
<Image
|
||||||
alt={ 'cover picture' }
|
alt={ 'cover picture' }
|
||||||
src={ header_img }
|
src={ post.header_img }
|
||||||
className={ `w-full relative scale-110 blur-3xl object-cover rounded-b-none` }
|
className={ `w-full relative scale-110 blur-3xl object-cover rounded-b-none` }
|
||||||
fill
|
fill
|
||||||
></Image>
|
></Image>
|
||||||
</div> }
|
</div> }
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={ header_img ?
|
className={ post.header_img ?
|
||||||
'mx-8 -mt-16 backdrop-blur transition bg-zinc-300/80 dark:bg-gray-600/25 rounded-2xl z-30'
|
'mx-8 -mt-16 backdrop-blur transition bg-zinc-300/80 dark:bg-gray-600/25 rounded-2xl z-30'
|
||||||
: 'mx-6 mt-6'
|
: 'mx-6 mt-6'
|
||||||
}>
|
}>
|
||||||
<div className={ header_img ? `p-4` : 'p-4 pt-2' }>
|
<div className={ post.header_img ? `p-4` : 'p-4 pt-2' }>
|
||||||
<Chip className={ `mb-2` } radius={ `sm` } color={ `secondary` }
|
<Chip className={ `mb-2` } radius={ `sm` } color={ `secondary` }
|
||||||
variant={ `solid` }>{ category }</Chip>
|
variant={ `solid` }>{ post.category }</Chip>
|
||||||
<p className={ `text-4xl font-bold` }> { title }</p>
|
<p className={ `text-4xl font-bold` }> { post.title }</p>
|
||||||
<p className={ `text-xl` }> { sub_title }</p>
|
<p className={ `text-xl` }> { post.subtitle }</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={ `mx-2 -mt-2 mb-2 flex flex-row flex-wrap justify-between w-full` }>
|
<div className={ `mx-2 -mt-2 mb-2 flex flex-row flex-wrap justify-between w-full` }>
|
||||||
<div>
|
<div>
|
||||||
<ul className={ `flex flex-row flex-wrap` }>
|
<ul className={ `flex flex-row flex-wrap` }>
|
||||||
{
|
{
|
||||||
tags.map((tag: string, key: any) => (
|
post.tags.map((tag: string, key: any) => (
|
||||||
<li key={ key } className={ `mx-1 mb-1` }>
|
<li key={ key } className={ `mx-1 mb-1` }>
|
||||||
<Chip>{ tag }</Chip>
|
<Chip>{ tag }</Chip>
|
||||||
</li>
|
</li>
|
||||||
@@ -64,7 +64,7 @@ const Headers = (props: Props) => {
|
|||||||
<div className={ `flex flex-row place-content-end mb-1 mr-6 items-center mx-1` }>
|
<div className={ `flex flex-row place-content-end mb-1 mr-6 items-center mx-1` }>
|
||||||
<MdCalendarMonth />
|
<MdCalendarMonth />
|
||||||
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
|
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
|
||||||
{ first_update.toLocaleString('default', {
|
{ (new Date(post.first_update)).toLocaleString('default', {
|
||||||
month: `long`,
|
month: `long`,
|
||||||
day: `numeric`,
|
day: `numeric`,
|
||||||
year: `numeric`
|
year: `numeric`
|
||||||
+4
-4
@@ -43,10 +43,10 @@ const MinifyPostCard = (props: Props) => {
|
|||||||
}
|
}
|
||||||
{ !img_url &&
|
{ !img_url &&
|
||||||
<div className={ 'h-full grid p-6 place-content-center' }>
|
<div className={ 'h-full grid p-6 place-content-center' }>
|
||||||
<div className={ 'flex flex-row items-center text-center max-h-fit' }>
|
<div className={ 'flex flex-col max-h-fit text-left' }>
|
||||||
<p className={ 'text-3xl font-bold' }>{ post['title'] }</p>
|
<p className={ 'text-3xl font-bold text-balance' }>{ post['title'] }</p>
|
||||||
<div className={ 'h-full w-1 mx-2 bg-zinc-500 dark:bg-zinc-400' }></div>
|
<div className={ 'w-1/4 h-[2px] my-2 bg-zinc-500 dark:bg-zinc-400' }></div>
|
||||||
<p className={ 'text-xl' }>{ post['sub_title'] }</p>
|
<p className={ 'text-xl text-pretty break-words' }>{ post['sub_title'] }</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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,4 +1,10 @@
|
|||||||
import React from 'react';
|
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({
|
function Layout({
|
||||||
children
|
children
|
||||||
File renamed without changes.
@@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
File renamed without changes.
Whitespace-only changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user