23 Commits
Author SHA1 Message Date
lumither 1f27e0bddc fix(frontend) fix editor warning 2025-06-03 03:23:32 +08:00
lumither 45010f030c feat(global) compatibility milestone 2025-06-03 03:19:49 +08:00
lumither 06a4ab8cf7 fix(git) exclude /client/content 2025-05-14 14:19:31 +08:00
lumither f491bc99a8 fix(git) exclude .DS_Store 2025-05-14 14:18:03 +08:00
lumither d15cdf216a feat(blog::api) impl GET /blog/posts 2025-05-14 01:47:21 +08:00
lumither 2f6cafa543 feat(blog::api) get_identifier.rs 2025-05-13 17:09:55 +08:00
lumither dc2c294617 feat(blog::api) update pagination api 2025-05-12 11:24:22 +08:00
lumither 80a164625b feat(blog::api) pagination 2025-05-12 01:07:23 +08:00
lumither 2c11af4a25 feat(blog::components::check_update) push frontmatter to database (metadata) 2025-04-18 01:18:25 -07:00
lumither e83cea1d42 feat(blog::components::check_update) impl consistency_guard, fix handle_create issues 2025-04-17 23:40:30 -07:00
lumither 71af26a09c feat(cli) template update 2025-04-17 23:35:13 -07:00
lumither 15a88074e9 feat(cli::template) update post template 2025-04-14 15:41:31 -07:00
lumither 5bf6d24733 feat(blog::components::check_update, README.md) add tracking filter 2025-04-14 15:41:02 -07:00
lumither 6a6af722c3 feat(blog::components::check_update, README.md) code cleanup, readme update 2025-04-13 03:59:40 -07:00
lumither 512197d87a feat(blog::components::check_update) remove unnecessary import 2025-04-13 03:16:50 -07:00
lumither 3c16bf5100 feat(blog::components::check_update) impl handle_move & handle_update 2025-04-13 03:15:48 -07:00
lumither ff4245a856 feat(blog::components::check_update) impl handle_delete 2025-04-13 03:08:17 -07:00
lumither 9270abc4b7 fix(blog::components::check_update) remove the use of deprecated api 2025-04-12 23:31:52 -07:00
lumither b11b19b0f5 feat(blog::components::check_update) impl handle_create() 2025-04-12 23:30:22 -07:00
lumither 7d988d380b feat(blog::components::check_update) remove redundant error types 2025-04-10 21:53:18 -07:00
lumither 8ba348a93e fix(blog::components::check_update) handle local-remote git url mismatch 2025-04-10 21:51:12 -07:00
lumither 10f14312bd feat(blog::components::check_update) add apply.rs 2025-04-09 19:20:50 -07:00
lumither 27d2c86106 feat(blog/schema.sql) update 2025-04-09 17:37:54 -07:00
65 changed files with 1536 additions and 811 deletions

No files matched your search

+5 -1
View File
@@ -6,7 +6,11 @@
.idea
.vscode
# exclude post repo
# macOS system files
.DS_Store
**/.DS_Store
# exclude content repo
/blog_posts_git
# rustc ICE dump
+1 -7
View File
@@ -7,10 +7,4 @@ content (blog posts etc.) repo.
Powered by Next.js and Rust, built with passion and love
> [!NOTE]
> This system is not production ready, and contains bugs, (security risk potentially).
> But have fun on it =)
# Todos
- [ ] the todo list lol
- [ ] and much more...
> This is an unstable branch, checkout `release` for the (guaranteed) working version
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::{
utils::front_matter::{parse_front_matter, split_md_front_matter},
};
use base64::{engine::general_purpose, Engine};
use base64::{Engine, engine::general_purpose};
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
+5 -1
View File
@@ -12,7 +12,7 @@ tracing = { version = "0.1.41", features = ["log"] }
git2 = "0.20.0"
chrono = { version = "0.4.39", features = ["serde"] }
cron = "0.15.0"
sqlx = { version = "0.8.3", features = ["runtime-tokio", "tls-native-tls", "postgres", "chrono"] }
sqlx = { version = "0.8.3", features = ["runtime-tokio", "tls-native-tls", "postgres", "chrono", "uuid"] }
serde_json = "1.0.133"
serde = { version = "1.0.215", features = ["derive"] }
tracing-subscriber = "0.3.18"
@@ -20,10 +20,14 @@ tracing-appender = "0.2.3"
dirs = "6.0.0"
thiserror = "2.0.11"
tokio = { version = "1.42.0", features = ["rt", "rt-multi-thread", "macros", "sync"] }
regex = "1.11.1"
dotenv = "0.15.0"
uuid = { version = "1.13.2", features = ["v4"] }
futures = "0.3.31"
[dev-dependencies]
tempfile = "3.19.1"
[build-dependencies]
git2 = "0.20.0"
chrono = "0.4.39"
+2 -1
View File
@@ -1,2 +1,3 @@
pub const DEFAULT_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
pub const DEFAULT_DATE_FORMAT: &str = "%Y-%m-%d%zT%H%M%S";
pub const DEFAULT_NAIVE_DATE_FORMAT: &str = "%Y-%m-%d";
@@ -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,34 +0,0 @@
use macros::dev_consume;
use axum::{
Json,
extract::{Path, State},
response::IntoResponse,
};
use serde_json::json;
use sqlx::PgPool;
const GET_POST_BY_I64_ID: &str = include_str!("get_post_by_i64_id.sql");
const GET_POST_BY_STR_ID: &str = include_str!("get_post_by_str_id.sql");
pub async fn handler(
State(db_connection): State<PgPool>,
Path(identifier): Path<String>,
) -> impl IntoResponse {
if let Ok(id) = identifier.parse::<i64>() {
select_post_with_i64_id(&db_connection, id).await
} else {
select_post_with_str_id(&db_connection, &identifier).await
};
Json::from(json!({"res": "ok"})).into_response()
}
async fn select_post_with_i64_id(db: &PgPool, id: i64) {
dev_consume!(db, id);
todo!()
}
async fn select_post_with_str_id(db: &PgPool, id: &str) {
dev_consume!(db, id);
todo!()
}
@@ -1,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
#[allow(dead_code)]
pub mod get_post;
// pub mod get_post_list;
pub mod posts;
@@ -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()),
}
}
@@ -0,0 +1,245 @@
use crate::modules::blog::components::check_update::{
changes::{Change, Create, Delete, Move, Update},
error::Error,
utils::{parse_naive_date_str, parse_post_identifier},
};
use futures::{
FutureExt,
future::{BoxFuture, join_all},
};
use sqlx::{PgPool, Postgres, Row, Transaction, query};
use tracing::error;
const DELETE_POST_BY_ID: &str = include_str!("sql/delete_post_by_id.sql");
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 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_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>) {
let handlers = changes
.into_iter()
.map(|change| match change {
Change::Delete(del) => handle_delete(db_conn, del).boxed(),
Change::Move(mv) => handle_move(db_conn, mv).boxed(),
Change::Update(update) => handle_update(db_conn, update).boxed(),
Change::Create(create) => handle_create(db_conn, create).boxed(),
})
.collect::<Vec<BoxFuture<'_, ()>>>();
join_all(handlers).await;
}
async fn handle_delete(db: &PgPool, del: Delete) {
if let Err(e) = query(DELETE_POST_BY_ID).bind(del.uuid).execute(db).await {
error!(
"failed to delete post: {}, {}",
del.uuid.to_string(),
e.to_string()
)
}
}
async fn handle_move(db: &PgPool, mv: Move) {
handle_delete(
db,
Delete {
uuid: mv.uuid,
path: Some(mv.from),
},
)
.await;
handle_create(
db,
Create {
uuid: mv.uuid,
path: mv.to,
payload: mv.payload,
},
)
.await;
}
async fn handle_update(db: &PgPool, update: Update) {
handle_delete(
db,
Delete {
uuid: update.uuid,
path: Some(update.path.clone()),
},
)
.await;
handle_create(
db,
Create {
uuid: update.uuid,
path: update.path,
payload: update.payload,
},
)
.await;
}
async fn handle_create(db: &PgPool, create: Create) {
let mut tx = db.begin().await.unwrap();
let identifier = parse_post_identifier(&create.path).unwrap();
let tags_json = create.payload.meta["tags"].clone();
let tags: Vec<_> = if let Some(tags) = tags_json.as_array() {
tags.iter().filter_map(|tag| tag.as_str()).collect()
} else {
vec![]
};
let mut tag_ids = vec![];
for tag in tags {
match get_or_create_tag_id(&mut tx, tag).await {
Ok(tid) => tag_ids.push(tid),
Err(e) => {
error!("failed to fetch or create tag ({}): {}", tag, e);
return;
}
}
}
let category_json = create.payload.meta["category"].clone();
let category = category_json.as_str().unwrap_or("Uncategorized");
let category_id = match get_or_create_category_id(&mut tx, category).await {
Ok(id) => id,
Err(e) => {
error!(
"failed to fetch category id for post {}: {}",
&identifier, e
);
return;
}
};
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 post_id = create.uuid;
let post_identifier = &identifier;
let post_title = meta
.get("title")
.map(|t| t.as_str().unwrap())
.unwrap_or("Untitled");
let post_subtitle = meta
.get("subtitle")
.map(|t| t.as_str().unwrap())
.unwrap_or("");
let post_date_created = if let Some(Some(date)) = meta
.get("date")
.map(|d| parse_naive_date_str(d.as_str().unwrap_or("")))
{
date
} else {
error!("failed to parse `date` for post: {}", post_identifier);
return;
};
let post_date_updated = meta
.get("update")
.map(|d| parse_naive_date_str(d.as_str().unwrap_or("")))
.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_hash = create.payload.hash;
if let Err(e) = query(CREATE_POST_GENERAL)
.bind(post_id)
.bind(post_identifier)
.bind(post_title)
.bind(post_subtitle)
.bind(post_date_created)
.bind(post_date_updated)
.bind(post_status)
.bind(post_content)
.bind(post_hash)
.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)
.execute(&mut *tx)
.await
{
error!("failed to insert post {}: {}", &identifier, e);
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();
}
async fn get_or_create_tag_id(
db: &mut Transaction<'_, Postgres>,
tag_name: &str,
) -> Result<i32, Error> {
get_create_id(db, tag_name, GET_TAG_ID, CREATE_RETURN_TAG_ID).await
}
async fn get_or_create_category_id(
db: &mut Transaction<'_, Postgres>,
category_name: &str,
) -> Result<i32, Error> {
get_create_id(
db,
category_name,
GET_CATEGORY_ID,
CREATE_RETURN_CATEGORY_ID,
)
.await
}
async fn get_create_id(
db: &mut Transaction<'_, Postgres>,
field_name: &str,
fetch_query: &str,
create_fetch_query: &str,
) -> Result<i32, Error> {
if let Some(id) = query(fetch_query)
.bind(field_name)
.fetch_optional(&mut **db)
.await?
{
Ok(id.get("id"))
} else {
let id = query(create_fetch_query)
.bind(field_name)
.fetch_one(&mut **db)
.await?
.get("id");
Ok(id)
}
}
@@ -17,7 +17,7 @@ pub enum Change {
#[derive(Debug, Clone)]
pub struct Delete {
pub uuid: Uuid,
pub path: PathBuf,
pub path: Option<PathBuf>,
}
#[allow(dead_code)]
@@ -33,6 +33,7 @@ pub struct Move {
#[derive(Debug, Clone)]
pub struct Update {
pub uuid: Uuid,
pub path: PathBuf,
pub payload: MdFile,
}
@@ -55,7 +56,7 @@ impl CreateDelete2UpdateSlot {
match (&self.create, &self.delete) {
(Some(create), Some(delete)) => Some(Change::Move(Move {
uuid: create.uuid,
from: delete.path.clone(),
from: delete.path.clone()?,
to: create.path.clone(),
payload: create.payload.clone(),
})),
@@ -2,57 +2,17 @@ use markdown::error::Error as MarkdownError;
use thiserror::Error;
#[allow(dead_code)]
#[derive(Error, Debug)]
pub enum Error {
#[error("markdown file error")]
MdFile(#[from] MarkdownError),
#[error("missing essential field `{}` for file `{}`", field, filename)]
MissingField { field: String, filename: String },
#[error(
"invalid field `{}` for file `{}`, <{}>: {}",
field_name,
filename,
err_field,
msg
)]
InvalidField {
field_name: String,
err_field: String,
filename: String,
msg: String,
},
#[error(
"database error on writing `{}` to table `{}`: <{}>: {}",
data_desc,
db_table,
id,
msg
)]
DBWriteFailure {
data_desc: String,
db_table: String,
id: String,
msg: String,
},
#[error(
"Database error on Reading `{}` from table `{}`: <{}>: {}",
data_desc,
db_table,
id,
msg
)]
DBReadFailure {
data_desc: String,
db_table: String,
id: String,
msg: String,
},
#[error("db error")]
Db(#[from] sqlx::Error),
#[error("git error")]
Git2(#[from] git2::Error),
#[error("git remote url mismatch")]
GitUrlMismatch,
}
@@ -1,351 +0,0 @@
use std::error::Error;
use std::path::Path;
use markdown::MdFile;
use crate::modules::blog::components::check_update::utils::get_md_file_basename;
use crate::modules::blog::components::check_update::{error::PostIdxError, utils::parse_date};
use sqlx::{query, PgPool, Pool, Postgres, Row};
use tokio::runtime::Runtime;
use tracing::{info, warn};
async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError> {
let post_id = post.file_id;
let title = match post.meta.get("title") {
None => {
return Err(PostIdxError::MissingField {
field: "title".to_string(),
filename: post.filename.clone(),
});
}
Some(title) => title.as_str().unwrap_or_else(|| {
warn!("invalid title for `{}`", &post.filename);
"invalid title"
}),
};
let sub_title = post
.meta
.get("sub_title")
.and_then(|v| v.as_str())
.unwrap_or("");
let summary = post
.meta
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or("");
let content = post.content.as_str();
let category = match post.meta.get("category") {
None => {
return Err(PostIdxError::MissingField {
field: "category".to_string(),
filename: post.filename.clone(),
});
}
Some(title) => title.as_str().unwrap_or_else(|| {
warn!("invalid category for `{}`", &post.filename);
"invalid category"
}),
};
let last_update = {
if let Some(last_update) = post.meta.get("last_update") {
match parse_date(last_update.as_str().unwrap()) {
Ok(date) => Some(date),
Err(e) => {
return Err(PostIdxError::InvalidField {
field_name: "last_update".to_string(),
err_field: last_update.to_string(),
filename: post.filename.clone(),
msg: e.to_string(),
});
}
}
} else {
None
}
};
let first_update = match post.meta.get("date") {
None => {
return Err(PostIdxError::MissingField {
field: "date".to_string(),
filename: post.filename.clone(),
});
}
Some(first_update) => match parse_date(first_update.as_str().unwrap()) {
Ok(date) => date,
Err(e) => {
return Err(PostIdxError::InvalidField {
field_name: "date".to_string(),
err_field: first_update.to_string(),
filename: post.filename.clone(),
msg: e.to_string(),
});
}
},
};
let filename = get_md_file_basename(&post.filename);
if let Err(e) = query(
r##"
INSERT INTO Post (post_id, title, summary, content, last_update, first_update, sub_title, category, filename)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (post_id) DO
UPDATE SET
title = EXCLUDED.title,
summary = EXCLUDED.summary,
content = EXCLUDED.content,
last_update = EXCLUDED.last_update,
first_update = EXCLUDED.first_update,
sub_title = EXCLUDED.sub_title,
category = EXCLUDED.category,
filename = EXCLUDED.filename;
"##,
)
.bind(post_id)
.bind(title)
.bind(summary)
.bind(content)
.bind(last_update)
.bind(first_update)
.bind(sub_title)
.bind(category)
.bind(filename)
.execute(db_connection)
.await
{
return Err(
PostIdxError::DBWriteFailure {
data_desc: "post base information".to_string(),
db_table: "Post".to_string(),
id: post_id.to_string(),
msg: e.to_string(),
}
);
}
Ok(())
}
async fn tag(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError> {
if let Some(tags_string) = post.meta.get("tags").to_owned() {
let tags = match tags_string.as_array().map(|vec| {
vec.iter()
.map(|val| val.as_str().unwrap())
.collect::<Vec<_>>()
}) {
None => {
return Err(PostIdxError::MissingField {
field: "tags".to_string(),
filename: post.filename.clone(),
});
}
Some(val) => val,
};
let post_id = post.file_id;
// remove old record
if let Err(e) = query("DELETE FROM Tag WHERE post_id = $1")
.bind(post_id)
.execute(db_connection)
.await
{
return Err(PostIdxError::DBWriteFailure {
data_desc: "post tag information".to_string(),
db_table: "Tag".to_string(),
id: post_id.to_string(),
msg: e.to_string(),
});
}
// add new record
for tag in tags {
match query("INSERT INTO Tag VALUES ($1, $2);")
.bind(post_id)
.bind(tag)
.execute(db_connection)
.await
{
Ok(_) => {}
Err(e) => {
return Err(PostIdxError::DBWriteFailure {
data_desc: "post tag information".to_string(),
db_table: "Tag".to_string(),
id: post_id.to_string(),
msg: e.to_string(),
});
}
}
}
}
Ok(())
}
async fn hash(db_connection: &PgPool, post: &MdFile) -> Result<bool, PostIdxError> {
let base64_form = post.hash.clone();
match query("SELECT hash FROM Hash WHERE post_id = $1")
.bind(post.file_id)
.fetch_optional(db_connection)
.await
{
Ok(row) => {
let db_record: String = match row {
None => "".to_string(),
Some(row) => row.try_get("hash").unwrap_or("".to_string()),
};
if base64_form == db_record {
Ok(false)
} else {
if let Err(e) = query(
r#"
INSERT INTO Hash (post_id, hash) VALUES ($1, $2)
ON CONFLICT (post_id) DO UPDATE SET hash = EXCLUDED.hash"#,
)
.bind(post.file_id)
.bind(&base64_form)
.execute(db_connection)
.await
{
return Err(PostIdxError::DBWriteFailure {
data_desc: "post hash".to_string(),
db_table: "Hash".to_string(),
id: post.file_id.to_string(),
msg: e.to_string(),
});
}
Ok(true)
}
}
Err(e) => Err(PostIdxError::DBReadFailure {
data_desc: "post hash".to_string(),
db_table: "Hash".to_string(),
id: post.file_id.to_string(),
msg: e.to_string(),
}),
}
}
async fn meta(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError> {
if let Err(e) = query(
// todo: sanitization
format!(
r#"
DO
$$
DECLARE
_post_id UUID := '{}';
_meta_cols JSONB := '{}';
_exists BOOLEAN;
_col_name TEXT;
_col_value TEXT;
_sql TEXT;
_insert_columns TEXT := 'post_id';
_insert_values TEXT := quote_literal(_post_id);
_update_set TEXT := '';
_blacklist TEXT[] := ARRAY['tags'];
BEGIN
SELECT EXISTS(SELECT 1 FROM meta WHERE post_id = _post_id) INTO _exists;
FOR _col_name, _col_value IN SELECT key, value FROM jsonb_each_text(_meta_cols)
LOOP
IF _col_name = ANY(_blacklist) THEN
CONTINUE;
END IF;
IF NOT EXISTS (SELECT 1
FROM information_schema.columns
WHERE table_name = 'meta'
AND column_name = _col_name
-- may change in the future
AND table_schema = 'public') THEN
_sql := 'ALTER TABLE meta ADD COLUMN ' || quote_ident(_col_name) || ' TEXT';
EXECUTE _sql;
END IF;
_insert_columns := _insert_columns || ', ' || quote_ident(_col_name);
_insert_values := _insert_values || ', ' || quote_literal(_col_value);
_update_set := _update_set || ', ' || quote_ident(_col_name) || ' = ' || quote_literal(_col_value);
END LOOP;
_update_set := substring(_update_set FROM 3);
IF _exists THEN
_sql := 'UPDATE meta SET ' || _update_set || ' WHERE post_id = ' || quote_literal(_post_id);
EXECUTE _sql;
ELSE
_sql := 'INSERT INTO meta (' || _insert_columns || ') VALUES (' || _insert_values || ')';
EXECUTE _sql;
END IF;
END;
$$ LANGUAGE plpgsql;
"#, post.file_id.to_string().as_str(), post.meta
).as_str()
)
.execute(db_connection)
.await {
Err(PostIdxError::DBWriteFailure {
data_desc: "post meta data".to_string(),
db_table: "Meta".to_string(),
id: post.file_id.to_string(),
msg: e.to_string(),
})
} else {
Ok(())
}
}
pub async fn index_file(path: &Path, db_conn: &Pool<Postgres>) -> Result<(), Box<dyn Error>> {
let md_file = MdFile::from_file(path)?;
async fn trail(db_conn: &Pool<Postgres>, md_file: &MdFile) -> Result<(), PostIdxError> {
base(db_conn, md_file).await?;
tag(db_conn, md_file).await?;
meta(db_conn, md_file).await?;
hash(db_conn, md_file).await.map(|_| ())
}
let mut cnt = 5;
while let Err(e) = trail(db_conn, &md_file).await {
match e {
PostIdxError::DBWriteFailure { .. } => {
cnt -= 1;
}
PostIdxError::DBReadFailure { .. } => {
cnt -= 1;
}
_ => return Err(e.into()),
}
if cnt <= 0 {
return Err(e.into());
}
}
info!("indexed markdown file: {}", path.display());
Ok(())
}
pub fn drop_index(file_basename: &str, db_conn: &Pool<Postgres>) -> Result<(), Box<dyn Error>> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
if let Err(e) = query(
r#"
WITH deleted_post AS (
DELETE FROM post
WHERE id = (SELECT id FROM post WHERE filename = $1)
RETURNING id
)
DELETE FROM tag WHERE post_id IN (SELECT id FROM deleted_post);
DELETE FROM meta WHERE post_id IN (SELECT id FROM deleted_post);
DELETE FROM hash WHERE post_id IN (SELECT id FROM deleted_post);
"#,
)
.bind(file_basename)
.execute(db_conn)
.await
{
Err(Box::from(
format!("failed to drop index: {}", e).to_string(),
))
} else {
Ok(())
}
})
}
@@ -1,25 +1,23 @@
use std::{
env,
path::{Path, PathBuf},
sync::Arc,
};
pub mod apply;
pub mod changes;
mod error;
mod pull;
pub mod utils;
use std::{env, path::PathBuf, sync::Arc};
use crate::{
constants::config::general::{default_value, var_name},
modules::blog::components::check_update::{pull::fetch_deltas, utils::expand_path},
modules::blog::components::{
check_update::{apply::apply_deltas, pull::fetch_deltas},
static_rsc::GIT_WORK_DIR,
},
scheduler::task_func::AsyncTaskFunc,
};
use macros::panic_with_log;
use sqlx::PgPool;
use tracing::{trace, warn, Level};
// mod error;
// mod index;
mod changes;
mod error;
mod pull;
mod utils;
use tracing::{Level, log::error, trace};
#[derive(Debug)]
struct Args {
@@ -31,22 +29,6 @@ struct Args {
}
pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc {
// init
let git_work_dir = {
let path = if let Ok(default_log_path) = env::var(var_name::WORK_DIR) {
expand_path(default_log_path)
} else {
let fallback_path = expand_path(default_value::WORK_DIR.to_string());
warn!(
"unset env var `{}`, using default path: {}/blog_git",
var_name::WORK_DIR,
fallback_path
);
fallback_path
};
Path::new(&path).join("blog_git")
};
// clone & check update
let git_url = match env::var(var_name::BLOG_GIT_URL) {
Ok(url) => url,
@@ -66,7 +48,7 @@ pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc {
let arc_args = Arc::new(Args {
db_connection,
git_work_dir,
git_work_dir: GIT_WORK_DIR.clone(),
git_url,
git_remote_name,
git_remote_branch,
@@ -85,7 +67,10 @@ async fn workflow(args: Arc<Args>) {
.await
{
Ok(updates) => updates,
Err(_) => return,
Err(e) => {
error!("delta parsing error: {}", e);
return;
}
};
if deltas.is_empty() {
@@ -93,5 +78,5 @@ async fn workflow(args: Arc<Args>) {
return;
}
// todo: load delta to database
apply_deltas(&args.db_connection, deltas).await;
}
@@ -1,18 +1,20 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::{collections::HashMap, fs::remove_dir_all, path::Path, path::PathBuf};
use crate::{
modules::blog::components::check_update::{
changes::{Change, Create, Delete, Update},
modules::blog::components::{
check_update::{
changes::{Change, Create, CreateDelete2UpdateSlot, Delete, Update},
error::Error,
utils::extract_post_id,
},
static_rsc::BLOG_POST_PATH_PATTERN,
},
utils::{git, git::FileDelta},
};
use markdown::MdFile;
use crate::modules::blog::components::check_update::changes::CreateDelete2UpdateSlot;
use git2::{Delta, Repository};
use tracing::error;
use tracing::{error, warn};
use uuid::Uuid;
pub async fn fetch_deltas(
@@ -32,7 +34,27 @@ pub async fn fetch_deltas(
},
};
let mut remote = repo.find_remote(git_remote_name)?;
let mut remote = match repo.find_remote(git_remote_name) {
Ok(remote) => remote,
Err(e) => {
remove_dir_all(git_work_dir).unwrap();
warn!(
"cannot find git remote ({}), reinitializing: {}",
git_remote_name, e
);
return Err(e.into());
}
};
if remote.url().is_some_and(|url| url != git_url) {
remove_dir_all(git_work_dir).unwrap();
warn!(
"git remote url mismatch (expected: {}, actual: {}), reinitializing",
git_url,
remote.url().unwrap()
);
return Err(Error::GitUrlMismatch);
}
let fetched_commit = git::do_fetch(&repo, &[git_remote_branch], &mut remote)?;
let commit_deltas = git::calculate_diff(
&repo,
@@ -46,16 +68,21 @@ pub async fn fetch_deltas(
// process deleted deltas before merging commit to prevent "file not found" os error
for delta in commit_deltas.into_iter() {
if delta.status == Delta::Deleted {
let file_path = &git_work_dir.join(
delta
let relative_file_path = delta
.new_path
.expect("cannot parse modified post `new_path`"),
);
.clone()
.expect("cannot parse modified post `new_path`");
if !should_delta_tracked(&relative_file_path) {
continue;
}
if delta.status == Delta::Deleted {
let file_path = &git_work_dir.join(relative_file_path);
if let Some(id) = extract_post_id(&MdFile::from_file(file_path)?) {
changes.push(Change::Delete(Delete {
uuid: id,
path: file_path.clone(),
path: Some(file_path.clone()),
}))
} else {
error!("failed to parse uuid for file {}", file_path.display());
@@ -94,6 +121,7 @@ pub async fn fetch_deltas(
if let Some(id) = extract_post_id(&md_file) {
changes.push(Change::Update(Update {
uuid: id,
path: file_path.clone(),
payload: md_file,
}))
} else {
@@ -110,21 +138,11 @@ pub async fn fetch_deltas(
}
// merge adds and deletes deltas to moves
let changes = dbg!(merge_changes(changes));
let changes = merge_changes(changes);
Ok(changes)
}
fn extract_post_id(post: &MdFile) -> Option<Uuid> {
match post.meta["uuid"].as_str() {
Some(id) => match Uuid::parse_str(id) {
Ok(id) => Some(id),
Err(_) => None,
},
None => None,
}
}
fn merge_changes(changes: Vec<Change>) -> Vec<Change> {
let mut slots: HashMap<Uuid, CreateDelete2UpdateSlot> = HashMap::new();
let mut merged: Vec<Change> = Vec::new();
@@ -151,3 +169,7 @@ fn merge_changes(changes: Vec<Change>) -> Vec<Change> {
merged
}
fn should_delta_tracked(path: &Path) -> bool {
BLOG_POST_PATH_PATTERN.is_match(path.to_str().unwrap())
}
@@ -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);
@@ -0,0 +1,3 @@
INSERT INTO categories(name)
VALUES ($1)
RETURNING id;
@@ -0,0 +1,3 @@
INSERT INTO tags(name)
VALUES ($1)
RETURNING id;
@@ -0,0 +1,3 @@
DELETE
FROM posts
WHERE id = $1;
@@ -0,0 +1,3 @@
SELECT id
FROM categories
WHERE name = $1;
@@ -0,0 +1,3 @@
SELECT id
FROM tags
WHERE name = $1;
@@ -1,12 +1,15 @@
use std::path::Path;
use crate::constants::{
config::general::{default_value, var_name},
time::DEFAULT_DATE_FORMAT,
time::DEFAULT_NAIVE_DATE_FORMAT,
};
// use crate::modules::blog::components::check_update::index::index_file;
use chrono::{DateTime, FixedOffset, ParseResult};
use chrono::NaiveDate;
use dirs::home_dir;
use markdown::MdFile;
use tracing::warn;
use uuid::Uuid;
pub fn expand_path(path: String) -> String {
if path.starts_with('~') {
@@ -30,6 +33,35 @@ pub fn expand_path(path: String) -> String {
}
}
pub fn parse_date(date_str: &str) -> ParseResult<DateTime<FixedOffset>> {
DateTime::parse_from_str(format!("{}T120000", date_str).as_str(), DEFAULT_DATE_FORMAT)
pub fn 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> {
if let Some(comp) = path.components().nth_back(1) {
comp.as_os_str().to_str().map(|s| s.to_string())
} else {
None
}
}
pub fn parse_naive_date_str(date_str: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(date_str, DEFAULT_NAIVE_DATE_FORMAT).ok()
}
#[cfg(test)]
mod tests {
use crate::modules::blog::components::check_update::utils::parse_naive_date_str;
use chrono::NaiveDate;
#[test]
fn test_parse_naive_date() {
assert_eq!(
NaiveDate::from_ymd_opt(2015, 1, 1),
parse_naive_date_str("2015-1-01")
);
}
}
@@ -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;
mod consistency_guard;
mod rag;
pub mod consistency_guard;
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")
});
+22 -17
View File
@@ -1,24 +1,18 @@
mod api;
mod components;
use crate::modules::blog::components::check_update;
use crate::scheduler::tasks::async_basic::AsyncBasic;
use crate::{
modules::{
blog::{
api::{
get_page_count,
get_post,
// get_post_list::get_post_list,
},
// cron::check_update,
},
Module,
blog::{
api::{posts, posts::get, posts::pagination},
components::{check_update, consistency_guard},
},
scheduler::tasks::CronTask,
},
scheduler::tasks::{CronTask, async_basic::AsyncBasic},
};
use axum::{routing::get, Router};
use axum::{Router, routing::get};
use sqlx::PgPool;
const MOUNT_POINT: &str = "/blog";
@@ -33,9 +27,9 @@ impl Module for Blog {
// todo: move resource init to new func
// todo: remove debug comment
Router::new()
// .route("/get_post_list", get(get_post_list))
.route("/get_page_count", get(get_page_count::handler))
.route("/get_post/{identifier}", get(get_post::handler))
.route("/posts", get(get::handler))
.route("/posts/{identifier}", get(posts::get_identifier::handler))
.route("/posts/pagination", get(pagination::handler))
.with_state(db_handler)
}
@@ -45,11 +39,22 @@ impl Module for Blog {
fn get_cron_tasks(&self) -> Vec<(&str, Box<dyn CronTask>)> {
let db_handler = self.db_handler.clone();
vec![(
vec![
(
"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()
.to_task(),
),
]
}
}
+34 -9
View File
@@ -12,30 +12,55 @@ CREATE TABLE categories
description TEXT DEFAULT ''
);
CREATE TABLE post_status
(
name VARCHAR(20) PRIMARY KEY
);
INSERT INTO post_status (name)
VALUES ('draft'),
('published'),
('planned'),
('hidden');
CREATE TABLE posts
(
id UUID UNIQUE NOT NULL,
id UUID PRIMARY KEY,
identifier VARCHAR(100) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
subtitle VARCHAR(255) NOT NULL,
published_date DATE NOT NULL,
last_update DATE,
date_created DATE NOT NULL,
date_updated DATE,
status VARCHAR(20) REFERENCES post_status (name) NOT NULL,
content TEXT NOT NULL,
category SERIAL REFERENCES categories (id) NOT NULL
hash VARCHAR(44) NOT NULL,
category INTEGER REFERENCES categories (id) NOT NULL
);
CREATE TABLE post_tag
(
post UUID REFERENCES posts (id),
tag SERIAL REFERENCES tags (id)
post UUID REFERENCES posts (id) ON DELETE CASCADE,
tag INTEGER REFERENCES tags (id) ON DELETE CASCADE,
CONSTRAINT post_tag_pk PRIMARY KEY (post, tag)
);
CREATE TABLE metadata
(
pid UUID REFERENCES posts (id) NOT NULL,
pid UUID REFERENCES posts (id) ON DELETE CASCADE NOT NULL,
meta_key VARCHAR(50) NOT NULL,
meta_value TEXT NOT NULL,
CONSTRAINT meta_pk PRIMARY KEY (pid, meta_key)
CONSTRAINT metadata_pk PRIMARY KEY (pid, meta_key)
);
-- index
CREATE INDEX idx_tags_name ON tags (name);
CREATE INDEX idx_categories_name ON categories (name);
CREATE INDEX idx_posts_identifier ON posts (identifier);
CREATE INDEX idx_posts_status ON posts (status);
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_tag ON post_tag (tag);
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 -2
View File
@@ -3,12 +3,11 @@
/// https://github.com/rust-lang/git2-rs/commit/f3b87baed1e33d6c2d94fe1fa6aa6503a071d837
///
use git2::{
build, AnnotatedCommit, AutotagOption, Delta, FetchOptions, Reference, Remote, Repository,
AnnotatedCommit, AutotagOption, Delta, FetchOptions, Reference, Remote, Repository, build,
};
use std::path::PathBuf;
use tracing::{error, warn};
#[derive(Debug, Eq, PartialEq)]
pub struct FileDelta {
pub old_path: Option<PathBuf>,
+1
View File
@@ -1 +1,2 @@
pub mod axum_response;
pub mod git;
+1
View File
@@ -9,3 +9,4 @@ __pycache__
.cache
content/*
/content/
+9 -8
View File
@@ -10,13 +10,14 @@ from utils.lang import eprint, wprint
POST_TEMPLATE = '''
---
id: {{ id }}
title: {{ title }}
subtitle: {{ subtitle }}
published_date: {{ published_date }}
last_updated: {{ last_update }}
status: {{ status }}
date: {{ date }}
update: {{ update }}
category: {{ category }}
tags: {{ tags }}
uuid: {{ uuid }}
---
WIP
@@ -38,9 +39,9 @@ def get_subtitle():
return input("subtitle > ")
def get_published_date():
def get_date():
while True:
d = input("published_date (YYYY-MM-DD) > ")
d = input("date (YYYY-MM-DD) > ")
if d == "":
wprint("using today's date")
return dt.today().strftime("%Y-%m-%d")
@@ -78,10 +79,10 @@ class BlogPost(Template):
index_md = POST_TEMPLATE.format(
title=get_title(),
subtitle=get_subtitle(),
published_date=get_published_date(),
last_update=dt.today().strftime("%Y-%m-%d"),
date=get_date(),
update=dt.today().strftime("%Y-%m-%d"),
tags=get_tags(),
category=get_category(),
uuid=uuid.uuid4()
id=uuid.uuid4()
)
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 { 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 = {
post: JSON
post: Post
}
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 (
<div className={ `w-full` }>
<Card as={ Link } href={ `/blog/post/${ post_id }` }
<Card as={ Link } href={ `/blog/post/${ post.id }` }
className={ `group` }
isPressable
isHoverable
>
{ header_img && <div className={ `w-full overflow h-80 relative` }>
{ post.header_img && <div className={ `w-full overflow h-80 relative` }>
<Image
alt={ 'cover picture' }
src={ header_img }
src={ post.header_img }
className={ `w-full object-cover rounded-b-none z-10` }
loading={ 'eager' }
fill
></Image>
<Image
alt={ 'cover picture shadow' }
src={ header_img }
src={ post.header_img }
loading={ 'eager' }
className={ `w-full object-cover rounded-b-none scale-y-110 blur-2xl` }
fill
@@ -48,11 +49,11 @@ function BlogPostCard({ post }: Props) {
<CardBody className={ `overflow-visible` }>
<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` }
variant={ `solid` }>{ category }</Chip>
<p className={ `text-4xl font-bold` }> { title }</p>
<p className={ `text-xl` }> { sub_title }</p>
variant={ `solid` }>{ post.category }</Chip>
<p className={ `text-4xl font-bold` }> { post.title }</p>
<p className={ `text-xl` }> { post.subtitle }</p>
</div>
<MdNorthEast className={ `mt-1 mr-2` } size={ `40px` } />
</div>
@@ -63,7 +64,7 @@ function BlogPostCard({ post }: Props) {
<div>
<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` }>
<Chip>{ tag }</Chip>
</li>
@@ -74,7 +75,7 @@ function BlogPostCard({ post }: Props) {
<div className={ `flex flex-row place-content-end mb-1 items-center mx-1` }>
<MdCalendarMonth />
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
{ first_update.toLocaleString('default', {
{ post.date_created.toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
+19 -11
View File
@@ -1,21 +1,21 @@
'use server';
import React from 'react';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
import BlogPostCard from '@/app/(pages)/blog/BlogPostCard';
import { Card, CardBody } from '@nextui-org/card';
import * as motion from 'motion/react-client';
import { fetchPostList } from '@/src/api/blog/post';
const PostList = async ({ page }: { page: number }) => {
try {
const res = await fetchNocturneApi(`/blog/get_post_list?page=${ page }`);
const posts = await res.json();
const postListResponse = await fetchPostList(page);
const posts = postListResponse.data.posts;
return (
// blog list
<div className={ 'w-full' }>
<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' }>
<motion.div
initial={ { y: 20, opacity: 0 } }
@@ -32,12 +32,20 @@ const PostList = async ({ page }: { page: number }) => {
);
} catch (e: any) {
return (
// todo: use Error.tsx
<Card className={ `w-full` }>
<CardBody>
Fatal: { e.toString() }
</CardBody>
</Card>);
<>
<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:' }
</p>
<p className={ `font-bold` }>
{ e.message }
</p>
</div>
</div>
</>
);
}
};
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 Pagination from '@/app/(pages)/blog/Pagination';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
import { fetchPostListPagination } from '@/src/api/blog/post';
async function Blog({
searchParams
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
searchParams: Promise<{ [_: string]: string | string[] | undefined }>
}) {
const parsePage = Number((await searchParams)?.page);
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 (
<div className={ 'w-full min-w-full space-y-4' }>
@@ -19,6 +23,22 @@ async function Blog({
<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;
+12 -11
View File
@@ -3,27 +3,28 @@ import MinifyPostCard from '@/app/(pages)/blog/post/[id]/MinifyPostCard';
import Link from 'next/link';
import { FaCreativeCommons } from 'react-icons/fa6';
interface AdjacentPosts {
prev: any;
next: any;
}
type Props = {
// post: any
post_id: string;
adjacent_posts: any
perm_link: string | undefined;
author: string | undefined;
author_link: string | undefined;
adjacent_posts: AdjacentPosts
perm_link: string;
author: string;
author_link: string;
first_update: string;
last_update: string | undefined;
last_update: string | null;
cc: string | undefined;
title: string;
}
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 next_post = adjacent_posts.next;
const link = perm_link ?? `https://lumither.com/blog/post/${ post_id }`;
const author = props.author ?? 'Tao';
const author_link = props.author_link ?? '/about/me';
const link = perm_link;
const post_date = new Date(first_update);
const update_date: Date | undefined = last_update ? new Date(last_update) : undefined;
const cc = props.cc ?? 'by-nc-sa';
@@ -3,14 +3,9 @@ import Image from 'next/image';
import { Chip } from '@nextui-org/react';
import { MdCalendarMonth } from 'react-icons/md';
// type Props = {
// post: any
// };
type Props = {
title: string;
subtitle: string;
summary: string | undefined;
category: string;
tags: string[];
first_update: string;
@@ -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">
<div>
<p className="text-2xl font-bold">{ post['title'] }</p>
<p className="">{ post['sub_title'] }</p>
<p className="">{ post.subtitle }</p>
</div>
</CardFooter>
</div>
@@ -46,7 +46,7 @@ const MinifyPostCard = (props: Props) => {
<div className={ 'flex flex-col max-h-fit text-left' }>
<p className={ 'text-3xl font-bold text-balance' }>{ post['title'] }</p>
<div className={ 'w-1/4 h-[2px] my-2 bg-zinc-500 dark:bg-zinc-400' }></div>
<p className={ 'text-xl text-pretty break-words' }>{ post['sub_title'] }</p>
<p className={ 'text-xl text-pretty break-words' }>{ post.subtitle }</p>
</div>
</div>
}
@@ -3,25 +3,28 @@ import { Card, CardBody } from '@nextui-org/card';
import Headers from '@/app/(pages)/blog/post/[id]/Headers';
import Footer from '@/app/(pages)/blog/post/[id]/Footer';
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
import { FetchPostResponseData } from '@/src/api/blog/post';
type Props = {
post: any
post: FetchPostResponseData
}
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 (
<div>
<Card className={ `max-w-full relative` }>
{/* card headers */ }
<Headers title={ post.title }
subtitle={ post.sub_title }
summary={ post.summary }
subtitle={ post.subtitle }
category={ post.category }
tags={ post.tags }
first_update={ post.first_update }
header_img={ post.meta.header_img }
first_update={ post.date_created }
header_img={ metadata?.header_img }
/>
{/* card body */ }
@@ -36,14 +39,13 @@ async function PostCard(props: Props) {
{/* card footer */ }
<Footer
adjacent_posts={ post.adj }
perm_link={ post.meta.link }
author={ post.meta.author }
author_link={ post.meta.author_link }
first_update={ post.first_update }
last_update={ post.last_update }
cc={ post.meta.cc }
post_id={ post.meta.id }
adjacent_posts={ adj }
perm_link={ metadata?.perm_link ?? `https://lumither.com/blog/post/${ post.id }` }
author={ metadata?.author ?? 'Tao' }
author_link={ metadata?.author_link ?? '/about/me' }
first_update={ post.date_created }
last_update={ post.date_updated }
cc={ metadata?.cc }
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 ButtonGoBack from '@/app/(pages)/blog/post/[id]/ButtonGoBack';
import * as motion from 'motion/react-client';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
import { Metadata } from 'next';
import { SITE_CONFIG } from '@/src/constants';
const fetchPost = cache(async (id: string) => {
const res = await fetchNocturneApi(`/blog/get_post/${ id }`);
// throw Error('Not Found');
return await res.json();
});
import { fetchPost } from '@/src/api/blog/post';
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> })
: Promise<Metadata> {
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 {
title: `${ title } - ${ SITE_CONFIG.name }`
};
@@ -33,7 +28,7 @@ async function BlogReader({
params: Promise<{ id: string }>
}) {
try {
let post = await fetchPost((await params).id);
let post = (await fetchPost((await params).id)).data;
return (
<motion.div
+2 -3
View File
@@ -5,14 +5,13 @@ import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
import { Button } from '@nextui-org/react';
import { entries } from '@/app/entries';
import Link from 'next/link';
import { useLocalStorage } from 'react-use';
export default function Home() {
const isMobile = useScreenSizeTrigger('lg');
const [ test, setTest, removeTest ] = useLocalStorage('test', 'test_value');
console.log(test);
// const [ test, setTest, removeTest ] = useLocalStorage('test', 'test_value');
// console.log(test);
// setTest('test value 2');
// console.log(test);
// removeTest();
+3 -3
View File
@@ -13,13 +13,13 @@
"@types/react-syntax-highlighter": "^15.5.13",
"lxgw-wenkai-webfont": "^1.7.0",
"motion": "^12.3.1",
"next": "^15.0.3",
"next": "^15.3.2",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.2.1",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"react-syntax-highlighter": "^15.6.1",
"react-use": "^17.6.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -34,7 +34,7 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.2",
"postcss": "^8.4.41",
"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 }`);
}