14 Commits
55 changed files with 1231 additions and 412 deletions

No files matched your search

+5 -1
View File
@@ -6,7 +6,11 @@
.idea .idea
.vscode .vscode
# exclude post repo # macOS system files
.DS_Store
**/.DS_Store
# exclude content repo
/blog_posts_git /blog_posts_git
# rustc ICE dump # rustc ICE dump
+4
View File
@@ -20,10 +20,14 @@ tracing-appender = "0.2.3"
dirs = "6.0.0" dirs = "6.0.0"
thiserror = "2.0.11" thiserror = "2.0.11"
tokio = { version = "1.42.0", features = ["rt", "rt-multi-thread", "macros", "sync"] } tokio = { version = "1.42.0", features = ["rt", "rt-multi-thread", "macros", "sync"] }
regex = "1.11.1"
dotenv = "0.15.0" dotenv = "0.15.0"
uuid = { version = "1.13.2", features = ["v4"] } uuid = { version = "1.13.2", features = ["v4"] }
futures = "0.3.31" futures = "0.3.31"
[dev-dependencies]
tempfile = "3.19.1"
[build-dependencies] [build-dependencies]
git2 = "0.20.0" git2 = "0.20.0"
chrono = "0.4.39" chrono = "0.4.39"
@@ -1,25 +0,0 @@
use crate::constants::blog::PAGE_SIZE;
use axum::{Json, extract::State};
use serde_json::{Value, json};
use sqlx::{PgPool, Row, query};
use tracing::error;
const GET_PAGE_COUNT_QUERY: &str = include_str!("post_page_count.sql");
pub async fn handler(State(db_connection): State<PgPool>) -> Result<Json<Value>, Json<Value>> {
match query(GET_PAGE_COUNT_QUERY)
.bind(PAGE_SIZE as i64)
.fetch_one(&db_connection)
.await
{
Ok(res) => {
let res: i32 = res.get("count");
Ok(Json::from(json!({"res": res})))
}
Err(e) => {
error!("failed to get blog page count: {}", e.to_string());
Err(Json::from(json!({"error": e.to_string()})))
}
}
}
@@ -1,2 +0,0 @@
SELECT cast(ceil(count(*) / cast($1 AS FLOAT)) AS INTEGER) AS count
FROM posts;
@@ -1 +0,0 @@
SELECT
@@ -1,35 +0,0 @@
use macros::dev_consume;
use axum::{
Json,
extract::{Path, State},
response::IntoResponse,
};
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
const GET_POST_BY_UUID: &str = include_str!("get_post_by_uuid.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::<Uuid>() {
select_post_by_uuid(&db_connection, id).await
} else {
select_post_by_str_id(&db_connection, &identifier).await
};
Json::from(json!({"res": "ok"})).into_response()
}
async fn select_post_by_uuid(db: &PgPool, id: Uuid) {
dev_consume!(db, id);
todo!()
}
async fn select_post_by_str_id(db: &PgPool, id: &str) {
dev_consume!(db, id);
todo!()
}
@@ -1,130 +0,0 @@
use std::error::Error;
use crate::constants::blog::PAGE_SIZE;
use axum::{extract::Query, extract::State, http::StatusCode, Json};
use chrono::{DateTime, Utc};
use futures::future::join_all;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use sqlx::{postgres::PgRow, query, PgPool, Row};
use tracing::error;
use uuid::Uuid;
#[derive(Deserialize)]
pub struct Params {
page: Option<u32>,
}
#[axum::debug_handler]
pub async fn get_post_list(
State(db_connection): State<PgPool>,
Query(params): Query<Params>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let search_param_page = params.page.unwrap_or(1);
if search_param_page < 1 {
return Err((
StatusCode::BAD_REQUEST,
Json::from(json!({"error": "`page` should be greater than 0"})),
));
}
let offset = PAGE_SIZE * (search_param_page - 1);
let post_list = sqlx::query("SELECT * FROM post ORDER BY first_update DESC LIMIT $1 OFFSET $2")
.bind(PAGE_SIZE as i32)
.bind(offset as i32)
.fetch_all(&db_connection)
.await;
let post_list = match post_list {
Ok(post_list) => post_list,
Err(e) => {
error!(
"Database query error on fetching post list: {}",
e.to_string()
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
));
}
};
let mut res = Vec::new();
let posts: Vec<_> = post_list
.into_iter()
.map(|post| async { analyze_post(&db_connection, post).await })
.collect();
let posts = join_all(posts).await;
for post in posts {
match post {
Ok(p) => res.push(p),
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!(("error", e.to_string()))),
))
}
}
}
// res.insert("posts".to_string(), Value::from(posts));
Ok(Json::from(Value::from(res)))
}
async fn analyze_post(db_connection: &PgPool, post: PgRow) -> Result<Value, Box<dyn Error + Send>> {
let post_id: Uuid = post.get("post_id");
let tags = fetch_post_tags(db_connection, post_id).await;
let meta = fetch_post_meta(db_connection, post_id).await.unwrap();
let header_img = meta.get("header_img");
let last_update = meta.get("last_update");
Ok(json!({
"post_id": post_id.to_string(),
"title": post.get::<String, _>("title"),
"category": post.get::<String, _>("category"),
"header_img": header_img,
"sub_title": post.get::<String, _>("sub_title"),
"summary": post.get::<String, _>("summary"),
"last_update": last_update,
"first_update": post.get::<DateTime<Utc>, _>("first_update"),
"tags": tags,
}))
}
async fn fetch_post_meta(db_connection: &PgPool, post_id: Uuid) -> Option<Value> {
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) => Some(value.get::<Value, _>("meta")),
Err(e) => {
error!(
"Database error on Reading `{}` from table `{}`: <{}>: {}",
"post meta data",
"meta",
post_id.to_string(),
e.to_string()
);
None
}
}
}
async fn fetch_post_tags(db_connection: &PgPool, post_id: Uuid) -> Vec<String> {
match query("SELECT tag FROM Tag WHERE post_id = $1")
.bind(post_id)
.fetch_all(db_connection)
.await
{
Ok(res) => res.iter().map(|tag| tag.get::<String, _>("tag")).collect(),
Err(_) => vec![],
}
}
+1 -4
View File
@@ -1,6 +1,3 @@
pub mod get_page_count;
// todo: remove exception tag // todo: remove exception tag
#[allow(dead_code)] #[allow(dead_code)]
pub mod get_post; pub mod posts;
// pub mod get_post_list;
@@ -0,0 +1,110 @@
use crate::{
constants::blog::PAGE_SIZE,
err_resp_log,
utils::axum_response::{err_resp, succ_resp},
};
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{FromRow, PgPool, query_as};
use uuid::Uuid;
#[derive(FromRow, Serialize)]
struct PostBriefModel {
id: Uuid,
identifier: String,
title: String,
subtitle: String,
tags: Vec<String>,
category: String,
date_created: NaiveDate,
date_updated: Option<NaiveDate>,
header_img: Option<String>,
}
#[derive(Deserialize, Serialize)]
pub struct Pagination {
page: Option<u32>,
page_size: Option<u32>,
}
#[derive(Serialize)]
pub struct RespPagination {
page: u32,
page_size: u32,
page_count: u32,
}
#[derive(Serialize)]
struct Response {
posts: Vec<PostBriefModel>,
pagination: RespPagination,
}
#[derive(FromRow)]
struct UuidRow {
id: Uuid,
}
const GET_POST_WITH_BRIEF_MODEL: &str = include_str!("get_post_with_brief_model.sql");
pub async fn handler(
State(db): State<PgPool>,
Query(pagination): Query<Pagination>,
) -> impl IntoResponse {
let page = pagination.page.unwrap_or(1);
let page_size = pagination.page_size.unwrap_or(PAGE_SIZE);
let page_count = match sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM posts")
.fetch_one(&db)
.await
{
Ok(post_count) => ((post_count as f64) / (page_size as f64)).ceil() as u32,
Err(e) => return err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let pagination = RespPagination {
page,
page_size,
page_count,
};
if page > page_count {
return err_resp(
StatusCode::BAD_REQUEST,
"page number should not be greater than page count",
Some(json!({"pagination": pagination})),
);
}
let offset = page_size * (page - 1);
let ids = match query_as::<_, UuidRow>(
"SELECT id FROM posts ORDER BY date_created DESC LIMIT $1 OFFSET $2",
)
.bind(page_size as i32)
.bind(offset as i32)
.fetch_all(&db)
.await
{
Ok(res) => res.into_iter().map(|item| item.id).collect::<Vec<_>>(),
Err(e) => return err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let posts = match query_as::<_, PostBriefModel>(GET_POST_WITH_BRIEF_MODEL)
.bind(&ids)
.fetch_all(&db)
.await
{
Ok(posts) => posts,
Err(e) => return err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let resp = Response { posts, pagination };
succ_resp(StatusCode::OK, serde_json::to_value(resp).unwrap())
}
@@ -0,0 +1,132 @@
use crate::{err_resp_log, utils::axum_response::succ_resp};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::NaiveDate;
use futures::future::OptionFuture;
use serde_json::Value;
use sqlx::{FromRow, PgPool, query_as};
use uuid::Uuid;
const SELECT_POST_BY_UUID: &str = include_str!("get_post_by_uuid.sql");
const SELECT_POST_BY_STR_ID: &str = include_str!("get_post_by_str_id.sql");
const SELECT_POST_ADJACENT_IDS: &str = include_str!("get_post_adjacent_ids.sql");
const SELECT_POST_WITH_ADJACENT_MODEL: &str = include_str!("get_post_with_adjacent_model.sql");
#[derive(serde::Serialize, FromRow)]
struct SelectedPost {
id: Uuid,
identifier: String,
title: String,
subtitle: String,
status: String,
date_created: NaiveDate,
date_updated: Option<NaiveDate>,
category: String,
tags: Vec<String>,
content: String,
metadata: Option<Value>,
}
#[derive(serde::Serialize, FromRow)]
struct AdjacentPost {
id: Uuid,
identifier: String,
title: String,
subtitle: String,
date_created: NaiveDate,
category: String,
tags: Vec<String>,
header_img: Option<String>,
}
#[derive(FromRow, Debug)]
struct AdjacentIds {
prev: Option<Uuid>,
next: Option<Uuid>,
}
#[derive(serde::Serialize, Default)]
struct AdjacentPosts {
prev: Option<AdjacentPost>,
next: Option<AdjacentPost>,
}
#[derive(serde::Serialize)]
struct ResponseModel {
post: SelectedPost,
adjacent: AdjacentPosts,
}
pub async fn handler(
State(db_connection): State<PgPool>,
Path(identifier): Path<String>,
) -> impl IntoResponse {
let post = match if let Ok(id) = identifier.parse::<Uuid>() {
select_post_by_uuid(&db_connection, id).await
} else {
select_post_by_str_id(&db_connection, &identifier).await
} {
Ok(post) => post,
Err(e) => {
return match e {
sqlx::Error::RowNotFound => {
err_resp_log!(StatusCode::NOT_FOUND, "no matching post")
}
_ => err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
}
};
let adj_ids = query_as::<_, AdjacentIds>(SELECT_POST_ADJACENT_IDS)
.bind(post.id)
.fetch_one(&db_connection)
.await
.unwrap();
let adjacent_prev = OptionFuture::from(
adj_ids
.prev
.map(|id| select_post_with_adjacent_model(&db_connection, id)),
)
.await
.flatten();
let adjacent_next = OptionFuture::from(
adj_ids
.next
.map(|id| select_post_with_adjacent_model(&db_connection, id)),
)
.await
.flatten();
let adjacent = AdjacentPosts {
prev: adjacent_prev,
next: adjacent_next,
};
let response = ResponseModel { post, adjacent };
succ_resp(StatusCode::OK, serde_json::to_value(response).unwrap())
}
async fn select_post_by_uuid(db: &PgPool, id: Uuid) -> Result<SelectedPost, sqlx::Error> {
query_as::<_, SelectedPost>(SELECT_POST_BY_UUID)
.bind(id)
.fetch_one(db)
.await
}
async fn select_post_by_str_id(db: &PgPool, id: &str) -> Result<SelectedPost, sqlx::Error> {
query_as::<_, SelectedPost>(SELECT_POST_BY_STR_ID)
.bind(id)
.fetch_one(db)
.await
}
async fn select_post_with_adjacent_model(db: &PgPool, id: Uuid) -> Option<AdjacentPost> {
query_as(SELECT_POST_WITH_ADJACENT_MODEL)
.bind(id)
.fetch_optional(db)
.await
.unwrap()
}
@@ -0,0 +1,13 @@
WITH target_create_date AS (SELECT p.date_created AS date FROM posts p WHERE p.id = $1)
SELECT (SELECT p.id
FROM posts p
WHERE p.date_created < t.date
ORDER BY p.date_created DESC
LIMIT 1) AS prev,
(SELECT p.id
FROM posts p
WHERE p.date_created > t.date
ORDER BY p.date_created
LIMIT 1) AS next
FROM target_create_date t
@@ -0,0 +1,19 @@
SELECT p.id,
p.identifier,
p.title,
p.subtitle,
p.status,
p.date_created,
p.date_updated,
c.name AS category,
(SELECT array_agg(t.name)
FROM tags t
JOIN post_tag pt ON pt.tag = t.id
WHERE pt.post = p.id) AS tags,
p.content,
(SELECT json_object_agg(m.meta_key, m.meta_value)
FROM metadata m
WHERE m.pid = p.id) AS metadata
FROM posts p
JOIN categories c ON c.id = p.category
WHERE p.identifier = $1;
@@ -0,0 +1,19 @@
SELECT p.id,
p.identifier,
p.title,
p.subtitle,
p.status,
p.date_created,
p.date_updated,
c.name AS category,
(SELECT array_agg(t.name)
FROM tags t
JOIN post_tag pt ON pt.tag = t.id
WHERE pt.post = p.id) AS tags,
p.content,
(SELECT json_object_agg(m.meta_key, m.meta_value)
FROM metadata m
WHERE m.pid = p.id) AS metadata
FROM posts p
JOIN categories c ON c.id = p.category
WHERE p.id = $1;
@@ -0,0 +1,17 @@
SELECT p.id,
identifier,
title,
subtitle,
date_created,
c.name AS category,
(SELECT array_agg(t.name)
FROM tags t
JOIN post_tag pt ON pt.tag = t.id
WHERE pt.post = p.id) AS tags,
(SELECT m.meta_value
FROM metadata m
WHERE m.pid = p.id
AND m.meta_key = 'header_img') AS header_img
FROM posts p
JOIN public.categories c on c.id = p.category
WHERE p.id = $1
@@ -0,0 +1,19 @@
SELECT p.id,
identifier,
title,
subtitle,
date_created,
date_updated,
c.name AS category,
(SELECT array_agg(t.name)
FROM tags t
JOIN post_tag pt ON pt.tag = t.id
WHERE pt.post = p.id) AS tags,
(SELECT m.meta_value
FROM metadata m
WHERE m.pid = p.id
AND m.meta_key = 'header_img') AS header_img
FROM posts p
JOIN public.categories c on c.id = p.category
WHERE p.id = ANY ($1)
ORDER BY p.date_created DESC;
@@ -0,0 +1,3 @@
pub mod get;
pub mod get_identifier;
pub mod pagination;
@@ -0,0 +1,52 @@
use crate::{constants::blog::PAGE_SIZE, err_resp_log, utils::axum_response::succ_resp};
use axum::{extract::State, http::StatusCode, response::Response};
use serde_json::{Value, json};
use sqlx::{FromRow, PgPool, query_as};
#[derive(FromRow)]
struct DBPaginationRow {
page_count: i32,
post_count: i64,
}
struct PaginationResponse {
page_count: i32,
post_count: i64,
}
impl From<DBPaginationRow> for PaginationResponse {
fn from(value: DBPaginationRow) -> Self {
Self {
page_count: value.page_count,
post_count: value.post_count,
}
}
}
impl From<PaginationResponse> for Value {
fn from(value: PaginationResponse) -> Self {
json!({
"page_count": value.page_count,
"post_count": value.post_count
})
}
}
pub async fn handler(State(db_connection): State<PgPool>) -> Response<String> {
match query_as::<_, DBPaginationRow>(
r#"
SELECT
cast(ceil(count(*) / cast($1 AS FLOAT)) AS INTEGER) AS page_count,
count(*) AS post_count
FROM posts;
"#,
)
.bind(PAGE_SIZE as i64)
.fetch_one(&db_connection)
.await
{
Ok(res) => succ_resp(StatusCode::OK, PaginationResponse::from(res).into()),
Err(e) => err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -5,10 +5,10 @@ use crate::modules::blog::components::check_update::{
}; };
use futures::{ use futures::{
future::{join_all, BoxFuture},
FutureExt, FutureExt,
future::{BoxFuture, join_all},
}; };
use sqlx::{query, PgPool, Postgres, Row, Transaction}; use sqlx::{PgPool, Postgres, Row, Transaction, query};
use tracing::error; use tracing::error;
const DELETE_POST_BY_ID: &str = include_str!("sql/delete_post_by_id.sql"); const DELETE_POST_BY_ID: &str = include_str!("sql/delete_post_by_id.sql");
@@ -16,7 +16,9 @@ const GET_TAG_ID: &str = include_str!("sql/get_tag_id.sql");
const GET_CATEGORY_ID: &str = include_str!("sql/get_category_id.sql"); const GET_CATEGORY_ID: &str = include_str!("sql/get_category_id.sql");
const CREATE_RETURN_TAG_ID: &str = include_str!("sql/create_return_tag_id.sql"); const CREATE_RETURN_TAG_ID: &str = include_str!("sql/create_return_tag_id.sql");
const CREATE_RETURN_CATEGORY_ID: &str = include_str!("sql/create_return_category_id.sql"); const CREATE_RETURN_CATEGORY_ID: &str = include_str!("sql/create_return_category_id.sql");
const CREATE_POST: &str = include_str!("sql/create_post.sql"); const CREATE_POST_GENERAL: &str = include_str!("sql/create_post_general.sql");
const CREATE_POST_TAGS: &str = include_str!("sql/create_post_tags.sql");
const CREATE_POST_META: &str = include_str!("sql/create_post_meta.sql");
pub async fn apply_deltas(db_conn: &PgPool, changes: Vec<Change>) { pub async fn apply_deltas(db_conn: &PgPool, changes: Vec<Change>) {
let handlers = changes let handlers = changes
@@ -47,7 +49,7 @@ async fn handle_move(db: &PgPool, mv: Move) {
db, db,
Delete { Delete {
uuid: mv.uuid, uuid: mv.uuid,
path: mv.from, path: Some(mv.from),
}, },
) )
.await; .await;
@@ -67,7 +69,7 @@ async fn handle_update(db: &PgPool, update: Update) {
db, db,
Delete { Delete {
uuid: update.uuid, uuid: update.uuid,
path: update.path.clone(), path: Some(update.path.clone()),
}, },
) )
.await; .await;
@@ -117,6 +119,19 @@ async fn handle_create(db: &PgPool, create: Create) {
} }
}; };
const KEY_TO_SKIP: &[&str] = &[
"category", "status", "id", "date", "update", "tags", "title", "subtitle",
];
let (post_meta_key, post_meta_value): (Vec<String>, Vec<String>) =
if let Some(kv) = create.payload.meta.as_object() {
kv.iter()
.filter(|(k, _)| !KEY_TO_SKIP.contains(&&***k))
.map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string()))
.collect()
} else {
(vec![], vec![])
};
let meta = create.payload.meta; let meta = create.payload.meta;
let post_id = create.uuid; let post_id = create.uuid;
let post_identifier = &identifier; let post_identifier = &identifier;
@@ -143,8 +158,9 @@ async fn handle_create(db: &PgPool, create: Create) {
.unwrap_or(None); .unwrap_or(None);
let post_status = meta.get("status").map(|v| v.as_str()).unwrap_or(None); let post_status = meta.get("status").map(|v| v.as_str()).unwrap_or(None);
let post_content = create.payload.content; let post_content = create.payload.content;
let post_hash = create.payload.hash;
if let Err(e) = query(CREATE_POST) if let Err(e) = query(CREATE_POST_GENERAL)
.bind(post_id) .bind(post_id)
.bind(post_identifier) .bind(post_identifier)
.bind(post_title) .bind(post_title)
@@ -153,7 +169,17 @@ async fn handle_create(db: &PgPool, create: Create) {
.bind(post_date_updated) .bind(post_date_updated)
.bind(post_status) .bind(post_status)
.bind(post_content) .bind(post_content)
.bind(post_hash)
.bind(category_id) .bind(category_id)
.execute(&mut *tx)
.await
{
error!("failed to insert post {}: {}", &identifier, e);
return;
}
if let Err(e) = query(CREATE_POST_TAGS)
.bind(post_id)
.bind(tag_ids) .bind(tag_ids)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
@@ -162,6 +188,17 @@ async fn handle_create(db: &PgPool, create: Create) {
return; return;
} }
if let Err(e) = query(CREATE_POST_META)
.bind(post_id)
.bind(post_meta_key)
.bind(post_meta_value)
.execute(&mut *tx)
.await
{
error!("failed to insert post {}: {}", &identifier, e);
return;
}
tx.commit().await.unwrap(); tx.commit().await.unwrap();
} }
@@ -17,7 +17,7 @@ pub enum Change {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Delete { pub struct Delete {
pub uuid: Uuid, pub uuid: Uuid,
pub path: PathBuf, pub path: Option<PathBuf>,
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -56,7 +56,7 @@ impl CreateDelete2UpdateSlot {
match (&self.create, &self.delete) { match (&self.create, &self.delete) {
(Some(create), Some(delete)) => Some(Change::Move(Move { (Some(create), Some(delete)) => Some(Change::Move(Move {
uuid: create.uuid, uuid: create.uuid,
from: delete.path.clone(), from: delete.path.clone()?,
to: create.path.clone(), to: create.path.clone(),
payload: create.payload.clone(), payload: create.payload.clone(),
})), })),
@@ -1,28 +1,23 @@
use std::{ pub mod apply;
env, pub mod changes;
path::{Path, PathBuf}, mod error;
sync::Arc, mod pull;
}; pub mod utils;
use std::{env, path::PathBuf, sync::Arc};
use crate::{ use crate::{
constants::config::general::{default_value, var_name}, constants::config::general::{default_value, var_name},
modules::blog::components::check_update::{ modules::blog::components::{
apply::apply_deltas, pull::fetch_deltas, utils::expand_path, check_update::{apply::apply_deltas, pull::fetch_deltas},
static_rsc::GIT_WORK_DIR,
}, },
scheduler::task_func::AsyncTaskFunc, scheduler::task_func::AsyncTaskFunc,
}; };
use macros::panic_with_log; use macros::panic_with_log;
use sqlx::PgPool; use sqlx::PgPool;
use tracing::{log::error, trace, warn, Level}; use tracing::{Level, log::error, trace};
// mod error;
// mod index;
mod apply;
mod changes;
mod error;
mod pull;
mod utils;
#[derive(Debug)] #[derive(Debug)]
struct Args { struct Args {
@@ -34,22 +29,6 @@ struct Args {
} }
pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc { 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 // clone & check update
let git_url = match env::var(var_name::BLOG_GIT_URL) { let git_url = match env::var(var_name::BLOG_GIT_URL) {
Ok(url) => url, Ok(url) => url,
@@ -69,7 +48,7 @@ pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc {
let arc_args = Arc::new(Args { let arc_args = Arc::new(Args {
db_connection, db_connection,
git_work_dir, git_work_dir: GIT_WORK_DIR.clone(),
git_url, git_url,
git_remote_name, git_remote_name,
git_remote_branch, git_remote_branch,
@@ -1,9 +1,13 @@
use std::{collections::HashMap, fs::remove_dir_all, path::PathBuf}; use std::{collections::HashMap, fs::remove_dir_all, path::Path, path::PathBuf};
use crate::{ use crate::{
modules::blog::components::check_update::{ modules::blog::components::{
changes::{Change, Create, CreateDelete2UpdateSlot, Delete, Update}, check_update::{
error::Error, changes::{Change, Create, CreateDelete2UpdateSlot, Delete, Update},
error::Error,
utils::extract_post_id,
},
static_rsc::BLOG_POST_PATH_PATTERN,
}, },
utils::{git, git::FileDelta}, utils::{git, git::FileDelta},
}; };
@@ -64,18 +68,21 @@ pub async fn fetch_deltas(
// process deleted deltas before merging commit to prevent "file not found" os error // process deleted deltas before merging commit to prevent "file not found" os error
for delta in commit_deltas.into_iter() { for delta in commit_deltas.into_iter() {
// todo: limit scanning scope to post only OR extract merging logic to external component let relative_file_path = delta
.new_path
.clone()
.expect("cannot parse modified post `new_path`");
if !should_delta_tracked(&relative_file_path) {
continue;
}
if delta.status == Delta::Deleted { if delta.status == Delta::Deleted {
let file_path = &git_work_dir.join( let file_path = &git_work_dir.join(relative_file_path);
delta
.new_path
.expect("cannot parse modified post `new_path`"),
);
if let Some(id) = extract_post_id(&MdFile::from_file(file_path)?) { if let Some(id) = extract_post_id(&MdFile::from_file(file_path)?) {
changes.push(Change::Delete(Delete { changes.push(Change::Delete(Delete {
uuid: id, uuid: id,
path: file_path.clone(), path: Some(file_path.clone()),
})) }))
} else { } else {
error!("failed to parse uuid for file {}", file_path.display()); error!("failed to parse uuid for file {}", file_path.display());
@@ -131,18 +138,11 @@ pub async fn fetch_deltas(
} }
// merge adds and deletes deltas to moves // merge adds and deletes deltas to moves
let changes = dbg!(merge_changes(changes)); let changes = merge_changes(changes);
Ok(changes) Ok(changes)
} }
fn extract_post_id(post: &MdFile) -> Option<Uuid> {
match post.meta["uuid"].as_str() {
Some(id) => Uuid::parse_str(id).ok(),
None => None,
}
}
fn merge_changes(changes: Vec<Change>) -> Vec<Change> { fn merge_changes(changes: Vec<Change>) -> Vec<Change> {
let mut slots: HashMap<Uuid, CreateDelete2UpdateSlot> = HashMap::new(); let mut slots: HashMap<Uuid, CreateDelete2UpdateSlot> = HashMap::new();
let mut merged: Vec<Change> = Vec::new(); let mut merged: Vec<Change> = Vec::new();
@@ -169,3 +169,7 @@ fn merge_changes(changes: Vec<Change>) -> Vec<Change> {
merged merged
} }
fn should_delta_tracked(path: &Path) -> bool {
BLOG_POST_PATH_PATTERN.is_match(path.to_str().unwrap())
}
@@ -1,7 +0,0 @@
INSERT INTO posts
(id, identifier, title, subtitle, date_created, date_updated, status, content, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
INSERT INTO post_tag (post, tag)
SELECT $1, tag
FROM UNNEST($10) AS t(tag);
@@ -0,0 +1,3 @@
INSERT INTO posts
(id, identifier, title, subtitle, date_created, date_updated, status, content, hash, category)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 'draft'), $8, $9, $10);
@@ -0,0 +1,3 @@
INSERT INTO metadata (pid, meta_key, meta_value)
SELECT $1, key, value
FROM UNNEST($2, $3) AS k(key, value);
@@ -0,0 +1,3 @@
INSERT INTO post_tag (post, tag)
SELECT $1, tag
FROM UNNEST($2) AS t(tag);
@@ -7,7 +7,9 @@ use crate::constants::{
use chrono::NaiveDate; use chrono::NaiveDate;
use dirs::home_dir; use dirs::home_dir;
use markdown::MdFile;
use tracing::warn; use tracing::warn;
use uuid::Uuid;
pub fn expand_path(path: String) -> String { pub fn expand_path(path: String) -> String {
if path.starts_with('~') { if path.starts_with('~') {
@@ -31,6 +33,13 @@ pub fn expand_path(path: String) -> String {
} }
} }
pub fn extract_post_id(post: &MdFile) -> Option<Uuid> {
match post.meta["id"].as_str() {
Some(id) => Uuid::parse_str(id).ok(),
None => None,
}
}
pub fn parse_post_identifier(path: &Path) -> Option<String> { pub fn parse_post_identifier(path: &Path) -> Option<String> {
if let Some(comp) = path.components().nth_back(1) { if let Some(comp) = path.components().nth_back(1) {
comp.as_os_str().to_str().map(|s| s.to_string()) comp.as_os_str().to_str().map(|s| s.to_string())
@@ -0,0 +1,130 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
str::FromStr,
};
use crate::modules::blog::components::{
check_update::{
changes::{Change, Create, Delete, Update},
utils::extract_post_id,
},
consistency_guard::{error::Error, utils::rec_find_dir_entries},
static_rsc::BLOG_POST_PATH_PATTERN,
};
use markdown::MdFile;
use sqlx::{FromRow, PgPool, query_as};
use tracing::error;
use uuid::Uuid;
const FETCH_POST_HASH: &str = include_str!("sql/fetch_post_hash.sql");
#[derive(Debug, FromRow)]
struct PostIdHash {
id: Uuid,
hash: String,
}
struct PostPath {
id: Uuid,
path: PathBuf,
payload: MdFile,
}
#[derive(Default)]
struct DeltaSlot {
local: Option<PostPath>,
db: Option<PostIdHash>,
}
impl DeltaSlot {
pub fn to_change(&self) -> Option<Change> {
match (&self.local, &self.db) {
(Some(local), Some(db)) => {
if local.payload.hash == db.hash {
None
} else {
Some(Change::Update(Update {
uuid: local.id,
path: local.path.clone(),
payload: local.payload.clone(),
}))
}
}
(Some(local), None) => Some(Change::Create(Create {
uuid: local.id,
path: local.path.clone(),
payload: local.payload.clone(),
})),
(None, Some(db)) => Some(Change::Delete(Delete {
uuid: db.id,
path: None,
})),
(None, None) => None,
}
}
}
pub async fn compare_db_local_post_hash(
db: &PgPool,
git_work_dir: &Path,
) -> Result<Vec<Change>, Error> {
let db_post_hashes = query_as::<_, PostIdHash>(FETCH_POST_HASH)
.fetch_all(db)
.await?;
let local_posts = fetch_local_posts(git_work_dir)?;
calculate_post_changes(local_posts, db_post_hashes)
}
fn fetch_local_posts(git_work_dir: &Path) -> Result<Vec<PostPath>, Error> {
let mut post_hashes = vec![];
let entries = rec_find_dir_entries(&git_work_dir.join("posts"))?;
for entry in entries {
let relative_pth = entry.strip_prefix(git_work_dir).unwrap();
if !BLOG_POST_PATH_PATTERN.is_match(relative_pth.to_str().unwrap()) {
continue;
}
let post_str = fs::read_to_string(&entry)?;
let post = MdFile::from_str(&post_str)?;
let post_id = match extract_post_id(&post) {
None => {
error!(
"failed to parse uuid for file {}, skipping",
&entry.display()
);
continue;
}
Some(v) => v,
};
post_hashes.push(PostPath {
id: post_id,
path: entry,
payload: post,
});
}
Ok(post_hashes)
}
fn calculate_post_changes(local: Vec<PostPath>, db: Vec<PostIdHash>) -> Result<Vec<Change>, Error> {
let mut slots: HashMap<Uuid, DeltaSlot> = HashMap::new();
for local_post in local {
let id = local_post.id;
slots.entry(id).or_default().local = Some(local_post);
}
for db_post in db {
let id = db_post.id;
slots.entry(id).or_default().db = Some(db_post);
}
let mut changes = vec![];
for slot in slots.values() {
if let Some(change) = slot.to_change() {
changes.push(change);
}
}
Ok(changes)
}
@@ -0,0 +1,19 @@
use std::io;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("db error")]
Db(#[from] sqlx::Error),
#[error("io error")]
Io(#[from] io::Error),
#[error("invalid path: {0}")]
InvalidPath(PathBuf),
#[error("invalid md file")]
MdFile(#[from] markdown::error::Error),
}
@@ -1 +1,44 @@
mod checker;
mod error;
mod utils;
use std::{path::PathBuf, sync::Arc};
use crate::{
modules::blog::components::{
check_update::apply::apply_deltas, consistency_guard::checker::compare_db_local_post_hash,
static_rsc::GIT_WORK_DIR,
},
scheduler::task_func::AsyncTaskFunc,
};
use sqlx::PgPool;
use tracing::{error, trace};
struct Args {
db_connection: PgPool,
git_work_dir: PathBuf,
}
pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc {
let args = Arc::new(Args {
db_connection,
git_work_dir: GIT_WORK_DIR.clone(),
});
move || Box::pin(workflow(args.clone()))
}
async fn workflow(args: Arc<Args>) {
let db = &args.db_connection;
let git_work_dir = &args.git_work_dir;
let changes = match compare_db_local_post_hash(db, git_work_dir).await {
Ok(c) => c,
Err(e) => {
error!("failed to compare db and local post list: {}", e);
return;
}
};
apply_deltas(db, changes).await;
trace!("local-database consistency checked");
}
@@ -0,0 +1,2 @@
SELECT id, hash
FROM posts
@@ -0,0 +1,78 @@
use std::{
fs,
path::{Path, PathBuf},
};
use crate::modules::blog::components::consistency_guard::error::Error;
pub fn rec_find_dir_entries(entry: &Path) -> Result<Vec<PathBuf>, Error> {
if !entry.exists() {
return Err(Error::InvalidPath(entry.to_path_buf()));
}
let mut result = vec![];
if !entry.is_dir() {
return Ok(vec![entry.to_path_buf()]);
}
for entry in fs::read_dir(entry)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
result.extend(rec_find_dir_entries(&path)?);
} else {
result.push(path);
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use tempfile::TempDir;
#[test]
fn test_valid_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
fs::create_dir(dir_path.join("subdir")).unwrap();
File::create(dir_path.join("file1.txt")).unwrap();
File::create(dir_path.join("subdir/file2.txt")).unwrap();
let entries = rec_find_dir_entries(dir_path).unwrap();
assert_eq!(entries.len(), 2);
}
#[test]
fn test_non_existent_path() {
let non_existent = PathBuf::from("/non/existent/path");
assert!(matches!(
rec_find_dir_entries(&non_existent),
Err(Error::InvalidPath(_))
));
}
#[test]
fn test_single_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
File::create(&file_path).unwrap();
let entries = rec_find_dir_entries(&file_path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0], file_path);
}
#[test]
fn test_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let entries = rec_find_dir_entries(temp_dir.path()).unwrap();
assert!(entries.is_empty());
}
}
@@ -1,3 +1,4 @@
pub mod check_update; pub mod check_update;
mod consistency_guard; pub mod consistency_guard;
mod rag; pub mod rag;
mod static_rsc;
@@ -0,0 +1,31 @@
use std::{
env,
path::{Path, PathBuf},
sync::LazyLock,
};
use crate::{
constants::config::general::{default_value, var_name},
modules::blog::components::check_update::utils::expand_path,
};
use regex::Regex;
use tracing::warn;
pub static BLOG_POST_PATH_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^posts/\d{4}/[a-zA-Z0-9-]+/index\.md$").unwrap());
pub static GIT_WORK_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
let path = if let Ok(default_work_path) = env::var(var_name::WORK_DIR) {
expand_path(default_work_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")
});
+21 -16
View File
@@ -1,21 +1,15 @@
mod api; mod api;
mod components; mod components;
use crate::modules::blog::components::check_update;
use crate::scheduler::tasks::async_basic::AsyncBasic;
use crate::{ use crate::{
modules::{ modules::{
Module, Module,
blog::{ blog::{
api::{ api::{posts, posts::get, posts::pagination},
get_page_count, components::{check_update, consistency_guard},
get_post,
// get_post_list::get_post_list,
},
// cron::check_update,
}, },
}, },
scheduler::tasks::CronTask, scheduler::tasks::{CronTask, async_basic::AsyncBasic},
}; };
use axum::{Router, routing::get}; use axum::{Router, routing::get};
@@ -33,9 +27,9 @@ impl Module for Blog {
// todo: move resource init to new func // todo: move resource init to new func
// todo: remove debug comment // todo: remove debug comment
Router::new() Router::new()
// .route("/get_post_list", get(get_post_list)) .route("/posts", get(get::handler))
.route("/get_page_count", get(get_page_count::handler)) .route("/posts/{identifier}", get(posts::get_identifier::handler))
.route("/get_post/{identifier}", get(get_post::handler)) .route("/posts/pagination", get(pagination::handler))
.with_state(db_handler) .with_state(db_handler)
} }
@@ -45,11 +39,22 @@ impl Module for Blog {
fn get_cron_tasks(&self) -> Vec<(&str, Box<dyn CronTask>)> { fn get_cron_tasks(&self) -> Vec<(&str, Box<dyn CronTask>)> {
let db_handler = self.db_handler.clone(); let db_handler = self.db_handler.clone();
vec![( vec![
"Blog Check Update", (
AsyncBasic::new(check_update::task(db_handler.clone()), "*/5 * * * * *") "Blog Check Update",
AsyncBasic::new(check_update::task(db_handler.clone()), "*/5 * * * * *")
.unwrap()
.to_task(),
),
(
"Consistency Guard",
AsyncBasic::new(
consistency_guard::task(db_handler.clone()),
"*/15 * * * * *",
)
.unwrap() .unwrap()
.to_task(), .to_task(),
)] ),
]
} }
} }
+10 -9
View File
@@ -14,8 +14,7 @@ CREATE TABLE categories
CREATE TABLE post_status CREATE TABLE post_status
( (
id SERIAL PRIMARY KEY, name VARCHAR(20) PRIMARY KEY
name VARCHAR(20) UNIQUE NOT NULL
); );
INSERT INTO post_status (name) INSERT INTO post_status (name)
@@ -27,14 +26,15 @@ VALUES ('draft'),
CREATE TABLE posts CREATE TABLE posts
( (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
identifier VARCHAR(100) UNIQUE NOT NULL, identifier VARCHAR(100) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
subtitle VARCHAR(255) NOT NULL, subtitle VARCHAR(255) NOT NULL,
date_created DATE NOT NULL, date_created DATE NOT NULL,
date_updated DATE, date_updated DATE,
status INTEGER REFERENCES post_status (id) DEFAULT 1 NOT NULL, status VARCHAR(20) REFERENCES post_status (name) NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
category INTEGER REFERENCES categories (id) NOT NULL hash VARCHAR(44) NOT NULL,
category INTEGER REFERENCES categories (id) NOT NULL
); );
CREATE TABLE post_tag CREATE TABLE post_tag
@@ -60,6 +60,7 @@ CREATE INDEX idx_categories_name ON categories (name);
CREATE INDEX idx_posts_identifier ON posts (identifier); CREATE INDEX idx_posts_identifier ON posts (identifier);
CREATE INDEX idx_posts_status ON posts (status); CREATE INDEX idx_posts_status ON posts (status);
CREATE INDEX idx_posts_category ON posts (category); CREATE INDEX idx_posts_category ON posts (category);
CREATE INDEX idx_posts_date_created ON posts (date_created);
CREATE INDEX idx_post_tag_post ON post_tag (post); CREATE INDEX idx_post_tag_post ON post_tag (post);
CREATE INDEX idx_post_tag_tag ON post_tag (tag); CREATE INDEX idx_post_tag_tag ON post_tag (tag);
CREATE INDEX idx_metadata_pid ON metadata (pid); CREATE INDEX idx_metadata_pid ON metadata (pid);
+51
View File
@@ -0,0 +1,51 @@
use axum::{
http::{StatusCode, header::CONTENT_TYPE},
response::Response,
};
use serde_json::{Value, json};
#[macro_export]
macro_rules! err_resp_log {
($code:expr, $msg:expr) => {{
use tracing::error;
use $crate::utils::axum_response::err_resp;
error!("{}", $msg);
err_resp($code, $msg, None)
}};
($code:expr, $msg:expr, $body:expr) => {{
use tracing::error;
use $crate::utils::axum_response::err_resp;
error!("{}", $msg);
err_resp($code, $msg, Some($body))
}};
}
pub fn err_resp(code: StatusCode, err_msg: &str, body: Option<Value>) -> Response<String> {
let mut payload = json!({
"status": "error",
"msg": err_msg,
});
if let Some(body) = body {
payload["data"] = body;
}
resp_json(code, payload)
}
pub fn succ_resp(code: StatusCode, payload: Value) -> Response<String> {
let payload = json!({
"status": "success",
"data": payload
});
resp_json(code, payload)
}
pub fn resp_json(code: StatusCode, payload: Value) -> Response<String> {
Response::builder()
.status(code)
.header(CONTENT_TYPE, "application/json")
.body(payload.to_string())
.unwrap()
}
+1
View File
@@ -1 +1,2 @@
pub mod axum_response;
pub mod git; pub mod git;
+1
View File
@@ -9,3 +9,4 @@ __pycache__
.cache .cache
content/* content/*
/content/
+9 -8
View File
@@ -10,13 +10,14 @@ from utils.lang import eprint, wprint
POST_TEMPLATE = ''' POST_TEMPLATE = '''
--- ---
id: {{ id }}
title: {{ title }} title: {{ title }}
subtitle: {{ subtitle }} subtitle: {{ subtitle }}
published_date: {{ published_date }} status: {{ status }}
last_updated: {{ last_update }} date: {{ date }}
update: {{ update }}
category: {{ category }} category: {{ category }}
tags: {{ tags }} tags: {{ tags }}
uuid: {{ uuid }}
--- ---
WIP WIP
@@ -38,9 +39,9 @@ def get_subtitle():
return input("subtitle > ") return input("subtitle > ")
def get_published_date(): def get_date():
while True: while True:
d = input("published_date (YYYY-MM-DD) > ") d = input("date (YYYY-MM-DD) > ")
if d == "": if d == "":
wprint("using today's date") wprint("using today's date")
return dt.today().strftime("%Y-%m-%d") return dt.today().strftime("%Y-%m-%d")
@@ -78,10 +79,10 @@ class BlogPost(Template):
index_md = POST_TEMPLATE.format( index_md = POST_TEMPLATE.format(
title=get_title(), title=get_title(),
subtitle=get_subtitle(), subtitle=get_subtitle(),
published_date=get_published_date(), date=get_date(),
last_update=dt.today().strftime("%Y-%m-%d"), update=dt.today().strftime("%Y-%m-%d"),
tags=get_tags(), tags=get_tags(),
category=get_category(), category=get_category(),
uuid=uuid.uuid4() id=uuid.uuid4()
) )
create_all_files(self.WORK_PATH.format(year=year, identifier="test"), self.ITEMS) create_all_files(self.WORK_PATH.format(year=year, identifier="test"), self.ITEMS)
+23 -22
View File
@@ -5,41 +5,42 @@ import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { MdCalendarMonth, MdNorthEast } from 'react-icons/md'; import { MdCalendarMonth, MdNorthEast } from 'react-icons/md';
interface Post {
id: string,
identifier: string,
title: string,
subtitle: string,
tags: string[]
category: string,
date_created: Date,
date_updated: Date | null,
header_img: string | null
}
type Props = { type Props = {
post: JSON post: Post
} }
function BlogPostCard({ post }: Props) { function BlogPostCard({ post }: Props) {
let title = (post as any)['title'];
let sub_title = (post as any)['sub_title'];
// let summary = (post as any)['summary'];
let category = (post as any)['category'];
let tags = (post as any)['tags'];
// let last_update = new Date((post as any)['last_update']);
let first_update = new Date((post as any)['first_update']);
let post_id = (post as any)['post_id'];
let header_img = ((post as any)['header_img'] as string | null);
return ( return (
<div className={ `w-full` }> <div className={ `w-full` }>
<Card as={ Link } href={ `/blog/post/${ post_id }` } <Card as={ Link } href={ `/blog/post/${ post.id }` }
className={ `group` } className={ `group` }
isPressable isPressable
isHoverable isHoverable
> >
{ header_img && <div className={ `w-full overflow h-80 relative` }> { post.header_img && <div className={ `w-full overflow h-80 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-10` } className={ `w-full object-cover rounded-b-none z-10` }
loading={ 'eager' } loading={ 'eager' }
fill fill
></Image> ></Image>
<Image <Image
alt={ 'cover picture shadow' } alt={ 'cover picture shadow' }
src={ header_img } src={ post.header_img }
loading={ 'eager' } loading={ 'eager' }
className={ `w-full object-cover rounded-b-none scale-y-110 blur-2xl` } className={ `w-full object-cover rounded-b-none scale-y-110 blur-2xl` }
fill fill
@@ -48,11 +49,11 @@ function BlogPostCard({ post }: Props) {
<CardBody className={ `overflow-visible` }> <CardBody className={ `overflow-visible` }>
<div className={ `flex flex-row justify-between` }> <div className={ `flex flex-row justify-between` }>
<div className={ `mx-2 ${ header_img ? '-mt-6' : 'mt-2' }` }> <div className={ `mx-2 ${ post.header_img ? '-mt-6' : 'mt-2' }` }>
<Chip className={ `mb-2 z-30 text-lg` } radius={ `sm` } size={ 'lg' } color={ `secondary` } <Chip className={ `mb-2 z-30 text-lg` } radius={ `sm` } size={ 'lg' } 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>
<MdNorthEast className={ `mt-1 mr-2` } size={ `40px` } /> <MdNorthEast className={ `mt-1 mr-2` } size={ `40px` } />
</div> </div>
@@ -63,7 +64,7 @@ function BlogPostCard({ post }: Props) {
<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>
@@ -74,7 +75,7 @@ function BlogPostCard({ post }: Props) {
<div className={ `flex flex-row place-content-end mb-1 items-center mx-1` }> <div className={ `flex flex-row place-content-end mb-1 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', { { post.date_created.toLocaleString('default', {
month: `long`, month: `long`,
day: `numeric`, day: `numeric`,
year: `numeric` year: `numeric`
+19 -11
View File
@@ -1,21 +1,21 @@
'use server'; 'use server';
import React from 'react'; import React from 'react';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
import BlogPostCard from '@/app/(pages)/blog/BlogPostCard'; import BlogPostCard from '@/app/(pages)/blog/BlogPostCard';
import { Card, CardBody } from '@nextui-org/card';
import * as motion from 'motion/react-client'; import * as motion from 'motion/react-client';
import { fetchPostList } from '@/src/api/blog/post';
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 postListResponse = await fetchPostList(page);
const posts = await res.json();
const posts = postListResponse.data.posts;
return ( return (
// blog list // blog list
<div className={ 'w-full' }> <div className={ 'w-full' }>
<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, key: number) => (
<li key={ key } className={ 'w-full' }> <li key={ key } className={ 'w-full' }>
<motion.div <motion.div
initial={ { y: 20, opacity: 0 } } initial={ { y: 20, opacity: 0 } }
@@ -32,12 +32,20 @@ const PostList = async ({ page }: { page: number }) => {
); );
} catch (e: any) { } catch (e: any) {
return ( return (
// todo: use Error.tsx <>
<Card className={ `w-full` }> <div className={ `flex h-full min-h-dvh w-full justify-center items-center` }>
<CardBody> <div className={ `flex-row` }>
Fatal: { e.toString() } <p className={ `text-xl font-bold` }>
</CardBody> { 'Unexpected Error:' }
</Card>); </p>
<p className={ `font-bold` }>
{ e.message }
</p>
</div>
</div>
</>
);
} }
}; };
export default PostList; export default PostList;
-21
View File
@@ -1,21 +0,0 @@
const LOCAL_ENDPOINT = process.env.API_LOCAL_URL;
const REMOTE_ENDPOINT = process.env.API_REMOTE_URL;
const NOCTURNE_ENDPOINT = ((isServerSide = true) => {
if (process.env.NODE_ENV === 'production') {
if (isServerSide) {
return LOCAL_ENDPOINT;
} else {
return REMOTE_ENDPOINT;
}
} else {
return LOCAL_ENDPOINT;
}
});
export async function fetchNocturneApi(path: string, isServerSideRequest = true) {
return await fetch(`${ NOCTURNE_ENDPOINT(isServerSideRequest) }${ path }`, {
cache: 'no-cache'
});
}
+30 -10
View File
@@ -1,24 +1,44 @@
import PostList from '@/app/(pages)/blog/PostList'; import PostList from '@/app/(pages)/blog/PostList';
import Pagination from '@/app/(pages)/blog/Pagination'; import Pagination from '@/app/(pages)/blog/Pagination';
import { fetchNocturneApi } from '@/app/(pages)/blog/api'; import { fetchPostListPagination } from '@/src/api/blog/post';
async function Blog({ async function Blog({
searchParams searchParams
}: { }: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }> searchParams: Promise<{ [_: string]: string | string[] | undefined }>
}) { }) {
const parsePage = Number((await searchParams)?.page); const parsePage = Number((await searchParams)?.page);
const currPage = parsePage > 0 ? parsePage : 1; const currPage = parsePage > 0 ? parsePage : 1;
const ret = await fetchNocturneApi(`/blog/get_page_count`);
const pageCount = (await ret.json() as any)['res'] as number;
return (
<div className={ 'w-full min-w-full space-y-4' }> try {
<PostList page={ currPage } /> const pagination = await fetchPostListPagination();
<Pagination total={ pageCount } /> const pageCount = pagination.data.page_count;
</div>
); return (
<div className={ 'w-full min-w-full space-y-4' }>
<PostList page={ currPage } />
<Pagination total={ pageCount } />
</div>
);
} catch (e: any) {
return (
<div className={ `flex h-full min-h-dvh w-full justify-center items-center` }>
<div className={ `flex-row` }>
<p className={ `text-xl font-bold` }>
{ 'Unexpected Error [loading pagination]:' }
</p>
<p className={ `font-bold` }>
{ e.message }
</p>
</div>
</div>
);
}
} }
export default Blog; export default Blog;
+12 -11
View File
@@ -3,27 +3,28 @@ 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';
interface AdjacentPosts {
prev: any;
next: any;
}
type Props = { type Props = {
// post: any adjacent_posts: AdjacentPosts
post_id: string; perm_link: string;
adjacent_posts: any author: string;
perm_link: string | undefined; author_link: string;
author: string | undefined;
author_link: string | undefined;
first_update: string; first_update: string;
last_update: string | undefined; last_update: string | null;
cc: string | undefined; cc: string | undefined;
title: string; title: string;
} }
const Footer = (props: Props) => { const Footer = (props: Props) => {
const { adjacent_posts, perm_link, post_id, first_update, last_update, title } = props; const { adjacent_posts, perm_link, author_link, author, first_update, last_update, title } = props;
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 = perm_link;
const author = props.author ?? 'Tao';
const author_link = props.author_link ?? '/about/me';
const post_date = new Date(first_update); const post_date = new Date(first_update);
const update_date: Date | undefined = last_update ? new Date(last_update) : undefined; const update_date: Date | undefined = last_update ? new Date(last_update) : undefined;
const cc = props.cc ?? 'by-nc-sa'; const cc = props.cc ?? 'by-nc-sa';
@@ -3,14 +3,9 @@ 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 = {
title: string; title: string;
subtitle: string; subtitle: string;
summary: string | undefined;
category: string; category: string;
tags: string[]; tags: string[];
first_update: string; first_update: string;
@@ -36,7 +36,7 @@ const MinifyPostCard = (props: Props) => {
className="absolute transition-colors bg-zinc-300/80 dark:bg-gray-600/25 dark:backdrop-brightness-75 bottom-0 justify-between rounded-none"> className="absolute transition-colors bg-zinc-300/80 dark:bg-gray-600/25 dark:backdrop-brightness-75 bottom-0 justify-between rounded-none">
<div> <div>
<p className="text-2xl font-bold">{ post['title'] }</p> <p className="text-2xl font-bold">{ post['title'] }</p>
<p className="">{ post['sub_title'] }</p> <p className="">{ post.subtitle }</p>
</div> </div>
</CardFooter> </CardFooter>
</div> </div>
@@ -46,7 +46,7 @@ const MinifyPostCard = (props: Props) => {
<div className={ 'flex flex-col max-h-fit text-left' }> <div className={ 'flex flex-col max-h-fit text-left' }>
<p className={ 'text-3xl font-bold text-balance' }>{ post['title'] }</p> <p className={ 'text-3xl font-bold text-balance' }>{ post['title'] }</p>
<div className={ 'w-1/4 h-[2px] my-2 bg-zinc-500 dark:bg-zinc-400' }></div> <div className={ 'w-1/4 h-[2px] my-2 bg-zinc-500 dark:bg-zinc-400' }></div>
<p className={ 'text-xl text-pretty break-words' }>{ post['sub_title'] }</p> <p className={ 'text-xl text-pretty break-words' }>{ post.subtitle }</p>
</div> </div>
</div> </div>
} }
@@ -3,25 +3,28 @@ import { Card, CardBody } from '@nextui-org/card';
import Headers from '@/app/(pages)/blog/post/[id]/Headers'; import Headers from '@/app/(pages)/blog/post/[id]/Headers';
import Footer from '@/app/(pages)/blog/post/[id]/Footer'; import Footer from '@/app/(pages)/blog/post/[id]/Footer';
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer'; import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
import { FetchPostResponseData } from '@/src/api/blog/post';
type Props = { type Props = {
post: any post: FetchPostResponseData
} }
async function PostCard(props: Props) { async function PostCard(props: Props) {
let post = props.post; const post = props.post.post;
const metadata: any | null = post.metadata;
const adj = props.post.adjacent;
return ( return (
<div> <div>
<Card className={ `max-w-full relative` }> <Card className={ `max-w-full relative` }>
{/* card headers */ } {/* card headers */ }
<Headers title={ post.title } <Headers title={ post.title }
subtitle={ post.sub_title } subtitle={ post.subtitle }
summary={ post.summary }
category={ post.category } category={ post.category }
tags={ post.tags } tags={ post.tags }
first_update={ post.first_update } first_update={ post.date_created }
header_img={ post.meta.header_img } header_img={ metadata?.header_img }
/> />
{/* card body */ } {/* card body */ }
@@ -36,14 +39,13 @@ async function PostCard(props: Props) {
{/* card footer */ } {/* card footer */ }
<Footer <Footer
adjacent_posts={ post.adj } adjacent_posts={ adj }
perm_link={ post.meta.link } perm_link={ metadata?.perm_link ?? `https://lumither.com/blog/post/${ post.id }` }
author={ post.meta.author } author={ metadata?.author ?? 'Tao' }
author_link={ post.meta.author_link } author_link={ metadata?.author_link ?? '/about/me' }
first_update={ post.first_update } first_update={ post.date_created }
last_update={ post.last_update } last_update={ post.date_updated }
cc={ post.meta.cc } cc={ metadata?.cc }
post_id={ post.meta.id }
title={ post.title } title={ post.title }
/> />
+5 -10
View File
@@ -1,22 +1,17 @@
import React, { cache } from 'react'; import React from 'react';
import PostCard from '@/app/(pages)/blog/post/[id]/PostCard'; import PostCard from '@/app/(pages)/blog/post/[id]/PostCard';
import ButtonGoBack from '@/app/(pages)/blog/post/[id]/ButtonGoBack'; import ButtonGoBack from '@/app/(pages)/blog/post/[id]/ButtonGoBack';
import * as motion from 'motion/react-client'; import * as motion from 'motion/react-client';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { SITE_CONFIG } from '@/src/constants'; import { SITE_CONFIG } from '@/src/constants';
import { fetchPost } from '@/src/api/blog/post';
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( export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> }) { params }: { params: Promise<{ id: string }> })
: Promise<Metadata> { : Promise<Metadata> {
try { try {
const title = (await fetchPost((await params).id)).title as string; const response = await fetchPost((await params).id);
const title = response.data.post.title;
return { return {
title: `${ title } - ${ SITE_CONFIG.name }` title: `${ title } - ${ SITE_CONFIG.name }`
}; };
@@ -33,7 +28,7 @@ async function BlogReader({
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
try { try {
let post = await fetchPost((await params).id); let post = (await fetchPost((await params).id)).data;
return ( return (
<motion.div <motion.div
+2 -3
View File
@@ -5,14 +5,13 @@ import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
import { Button } from '@nextui-org/react'; import { Button } from '@nextui-org/react';
import { entries } from '@/app/entries'; import { entries } from '@/app/entries';
import Link from 'next/link'; import Link from 'next/link';
import { useLocalStorage } from 'react-use';
export default function Home() { export default function Home() {
const isMobile = useScreenSizeTrigger('lg'); const isMobile = useScreenSizeTrigger('lg');
const [ test, setTest, removeTest ] = useLocalStorage('test', 'test_value'); // const [ test, setTest, removeTest ] = useLocalStorage('test', 'test_value');
console.log(test); // console.log(test);
// setTest('test value 2'); // setTest('test value 2');
// console.log(test); // console.log(test);
// removeTest(); // removeTest();
+3 -3
View File
@@ -13,13 +13,13 @@
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"lxgw-wenkai-webfont": "^1.7.0", "lxgw-wenkai-webfont": "^1.7.0",
"motion": "^12.3.1", "motion": "^12.3.1",
"next": "^15.0.3", "next": "^15.3.2",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-icons": "^5.2.1", "react-icons": "^5.2.1",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.6.1",
"react-use": "^17.6.0", "react-use": "^17.6.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
@@ -34,7 +34,7 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^8.57.1",
"eslint-config-next": "14.2.2", "eslint-config-next": "14.2.2",
"postcss": "^8.4.41", "postcss": "^8.4.41",
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.9",
+96
View File
@@ -0,0 +1,96 @@
import { fetchNocturneJson } from '@/src/api/endpoint';
const DEFAULT_PAGE_SIZE = 6;
export interface PostListResponse {
data: {
posts: {
id: string,
identifier: string,
title: string,
subtitle: string,
tags: string[]
category: string,
date_created: Date,
date_updated: Date | null,
header_img: string | null
}[],
pagination: {
page: number,
page_count: number,
page_size: number
}
},
status: string
}
export async function fetchPostList(page: number, page_size: number = DEFAULT_PAGE_SIZE): Promise<PostListResponse> {
const url = `/blog/posts?page=${ page }&page_size=${ page_size }`;
return await fetchNocturneJson(url);
}
export interface PostListPaginationResponseData {
page_count: number,
post_count: number,
}
export interface PostListPaginationResponse {
data: PostListPaginationResponseData;
status: string;
}
export async function fetchPostListPagination(): Promise<PostListPaginationResponse> {
const url = `/blog/posts/pagination`;
return await fetchNocturneJson(url);
}
export interface FetchPostResponseData {
post: {
id: string,
identifier: string,
title: string,
subtitle: string,
status: string,
date_created: string,
date_updated: string | null,
category: string,
tags: string[],
content: string,
metadata: any | null,
},
adjacent: {
prev: {
id: string,
identifier: string,
title: string,
subtitle: string,
date_created: string,
category: string,
tags: string[],
header_img: string | null,
} | null,
next: {
id: string,
identifier: string,
title: string,
subtitle: string,
date_created: string,
category: string,
tags: string[],
header_img: string | null,
} | null,
}
}
export interface FetchPostResponse {
data: FetchPostResponseData;
status: string;
}
export async function fetchPost(id: string): Promise<FetchPostResponse> {
const url = `/blog/posts/${ id }`;
return await fetchNocturneJson(url);
}
+40
View File
@@ -0,0 +1,40 @@
const LOCAL_ENDPOINT = process.env.API_LOCAL_URL;
const REMOTE_ENDPOINT = process.env.API_REMOTE_URL;
const FALLBACK_ENDPOINT = 'http://localhost:3001';
const NOCTURNE_ENDPOINT = ((isServerSide = true) => {
const get_endpoint = () => {
if (process.env.NODE_ENV === 'production') {
if (isServerSide) {
return LOCAL_ENDPOINT;
} else {
return REMOTE_ENDPOINT;
}
} else {
return LOCAL_ENDPOINT;
}
};
return get_endpoint() ?? FALLBACK_ENDPOINT;
});
export async function fetchNocturneJson<T>(
path: string,
isServerSideRequest = true,
method: string = 'GET'
): Promise<T> {
const url = `${ NOCTURNE_ENDPOINT(isServerSideRequest) }${ path }`;
const future = fetch(url, {
cache: 'no-cache',
method: method
});
const res = (await future);
if (!res.ok) {
throw new Error(`Failed to fetch "${ url }" [${ res.status }]: ${ res.statusText }`);
}
return (await res.json()) as T;
}
+89
View File
@@ -0,0 +1,89 @@
export type Result<T, E> = Ok<T, E> | Err<T, E>;
class Ok<T, E> {
readonly value: T;
constructor(value: T) {
this.value = value;
}
isOk(): this is Ok<T, E> {
return true;
}
isErr(): this is Err<T, E> {
return false;
}
map<U>(fn: (val: T) => U): Result<U, E> {
return new Ok(fn(this.value));
}
mapErr<F>(_fn: (err: E) => F): Result<T, F> {
return new Ok(this.value);
}
unwrap(): T {
return this.value;
}
unwrapOr(_default: T): T {
return this.value;
}
match<U>(handlers: { ok: (val: T) => U, err: (err: E) => U }): U {
return handlers.ok(this.value);
}
andThen<U>(fn: (val: T) => Result<U, E>): Result<U, E> {
return fn(this.value);
}
}
class Err<T, E> {
readonly error: E;
constructor(error: E) {
this.error = error;
}
isOk(): this is Ok<T, E> {
return false;
}
isErr(): this is Err<T, E> {
return true;
}
map<U>(_fn: (val: T) => U): Result<U, E> {
return new Err(this.error);
}
mapErr<F>(fn: (err: E) => F): Result<T, F> {
return new Err(fn(this.error));
}
unwrap(): T {
throw new Error('Tried to unwrap Err: ' + this.error);
}
unwrapOr(defaultValue: T): T {
return defaultValue;
}
match<U>(handlers: { ok: (val: T) => U, err: (err: E) => U }): U {
return handlers.err(this.error);
}
andThen<U>(_fn: (val: T) => Result<U, E>): Result<U, E> {
return new Err(this.error);
}
}
export function ok<T, E = never>(value: T): Result<T, E> {
return new Ok(value);
}
export function err<T = never, E = unknown>(error: E): Result<T, E> {
return new Err(error);
}
+3
View File
@@ -0,0 +1,3 @@
export function todo(message: string = 'Not yet implemented'): never {
throw new Error(`TODO: ${ message }`);
}