6 Commits
17 changed files with 317 additions and 86 deletions

No files matched your search

+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};
+1 -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"
+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";
@@ -7,28 +7,29 @@ use axum::{
};
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
const GET_POST_BY_I64_ID: &str = include_str!("get_post_by_i64_id.sql");
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::<i64>() {
select_post_with_i64_id(&db_connection, id).await
if let Ok(id) = identifier.parse::<Uuid>() {
select_post_by_uuid(&db_connection, id).await
} else {
select_post_with_str_id(&db_connection, &identifier).await
select_post_by_str_id(&db_connection, &identifier).await
};
Json::from(json!({"res": "ok"})).into_response()
}
async fn select_post_with_i64_id(db: &PgPool, id: i64) {
async fn select_post_by_uuid(db: &PgPool, id: Uuid) {
dev_consume!(db, id);
todo!()
}
async fn select_post_with_str_id(db: &PgPool, id: &str) {
async fn select_post_by_str_id(db: &PgPool, id: &str) {
dev_consume!(db, id);
todo!()
}
@@ -0,0 +1,181 @@
use crate::modules::blog::components::check_update::{
changes::{Change, Create, Delete, Move, Update},
error::Error,
utils::{parse_naive_date_str, parse_post_identifier},
};
use macros::dev_consume;
use futures::{
future::{join_all, BoxFuture},
FutureExt,
};
use sqlx::{query, PgPool, Postgres, Row, Transaction};
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: &str = include_str!("sql/create_post.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) {
let identifier = parse_post_identifier(&mv.to).unwrap();
dev_consume!(db, mv, identifier);
todo!()
}
async fn handle_update(db: &PgPool, update: Update) {
dev_consume!(db, update);
todo!()
}
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;
}
};
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("sub_title")
.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;
if let Err(e) = query(CREATE_POST)
.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(category_id)
.bind(tag_ids)
.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)
}
}
@@ -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,
}
@@ -6,16 +6,19 @@ use std::{
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, utils::expand_path,
},
scheduler::task_func::AsyncTaskFunc,
};
use macros::panic_with_log;
use sqlx::PgPool;
use tracing::{trace, warn, Level};
use tracing::{log::error, trace, warn, Level};
// mod error;
// mod index;
mod apply;
mod changes;
mod error;
mod pull;
@@ -85,7 +88,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 +99,5 @@ async fn workflow(args: Arc<Args>) {
return;
}
// todo: load delta to database
apply_deltas(&args.db_connection, deltas).await;
}
@@ -1,18 +1,16 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::{collections::HashMap, fs::remove_dir_all, path::PathBuf};
use crate::{
modules::blog::components::check_update::{
changes::{Change, Create, Delete, Update},
changes::{Change, Create, CreateDelete2UpdateSlot, Delete, Update},
error::Error,
},
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 +30,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,6 +64,8 @@ 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() {
// todo: limit scanning scope to post only OR extract merging logic to external component
if delta.status == Delta::Deleted {
let file_path = &git_work_dir.join(
delta
@@ -117,10 +137,7 @@ pub async fn fetch_deltas(
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,
},
Some(id) => Uuid::parse_str(id).ok(),
None => None,
}
}
@@ -0,0 +1,7 @@
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 categories(name)
VALUES ($1)
RETURNING id;
@@ -0,0 +1,3 @@
INSERT INTO tags(name)
VALUES ($1)
RETURNING id;
@@ -0,0 +1,3 @@
SELECT id
FROM categories
WHERE name = $1;
@@ -0,0 +1,3 @@
SELECT id
FROM tags
WHERE name = $1;
@@ -1,10 +1,11 @@
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 tracing::warn;
@@ -30,6 +31,28 @@ 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 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")
);
}
}
+2 -2
View File
@@ -5,6 +5,7 @@ use crate::modules::blog::components::check_update;
use crate::scheduler::tasks::async_basic::AsyncBasic;
use crate::{
modules::{
Module,
blog::{
api::{
get_page_count,
@@ -13,12 +14,11 @@ use crate::{
},
// cron::check_update,
},
Module,
},
scheduler::tasks::CronTask,
};
use axum::{routing::get, Router};
use axum::{Router, routing::get};
use sqlx::PgPool;
const MOUNT_POINT: &str = "/blog";
+33 -9
View File
@@ -12,30 +12,54 @@ CREATE TABLE categories
description TEXT DEFAULT ''
);
CREATE TABLE post_status
(
id SERIAL PRIMARY KEY,
name VARCHAR(20) UNIQUE NOT NULL
);
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 INTEGER REFERENCES post_status (id) DEFAULT 1 NOT NULL,
content TEXT NOT NULL,
category SERIAL REFERENCES categories (id) 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_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);
+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>,