14 Commits
55 changed files with 1215 additions and 396 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::{
check_update::{
changes::{Change, Create, CreateDelete2UpdateSlot, Delete, Update}, changes::{Change, Create, CreateDelete2UpdateSlot, Delete, Update},
error::Error, 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")
});
+19 -14
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", "Blog Check Update",
AsyncBasic::new(check_update::task(db_handler.clone()), "*/5 * * * * *") AsyncBasic::new(check_update::task(db_handler.clone()), "*/5 * * * * *")
.unwrap() .unwrap()
.to_task(), .to_task(),
)] ),
(
"Consistency Guard",
AsyncBasic::new(
consistency_guard::task(db_handler.clone()),
"*/15 * * * * *",
)
.unwrap()
.to_task(),
),
]
} }
} }
+4 -3
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)
@@ -32,8 +31,9 @@ CREATE TABLE posts
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,
hash VARCHAR(44) NOT NULL,
category INTEGER REFERENCES categories (id) NOT NULL category INTEGER REFERENCES categories (id) NOT NULL
); );
@@ -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'
});
}
+24 -4
View File
@@ -1,17 +1,21 @@
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;
try {
const pagination = await fetchPostListPagination();
const pageCount = pagination.data.page_count;
return ( return (
<div className={ 'w-full min-w-full space-y-4' }> <div className={ 'w-full min-w-full space-y-4' }>
@@ -19,6 +23,22 @@ async function Blog({
<Pagination total={ pageCount } /> <Pagination total={ pageCount } />
</div> </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 }`);
}