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}, utils::front_matter::{parse_front_matter, split_md_front_matter},
}; };
use base64::{engine::general_purpose, Engine}; use base64::{Engine, engine::general_purpose};
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
+1 -1
View File
@@ -12,7 +12,7 @@ tracing = { version = "0.1.41", features = ["log"] }
git2 = "0.20.0" git2 = "0.20.0"
chrono = { version = "0.4.39", features = ["serde"] } chrono = { version = "0.4.39", features = ["serde"] }
cron = "0.15.0" 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_json = "1.0.133"
serde = { version = "1.0.215", features = ["derive"] } serde = { version = "1.0.215", features = ["derive"] }
tracing-subscriber = "0.3.18" 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_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 serde_json::json;
use sqlx::PgPool; 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"); const GET_POST_BY_STR_ID: &str = include_str!("get_post_by_str_id.sql");
pub async fn handler( pub async fn handler(
State(db_connection): State<PgPool>, State(db_connection): State<PgPool>,
Path(identifier): Path<String>, Path(identifier): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Ok(id) = identifier.parse::<i64>() { if let Ok(id) = identifier.parse::<Uuid>() {
select_post_with_i64_id(&db_connection, id).await select_post_by_uuid(&db_connection, id).await
} else { } 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() 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); dev_consume!(db, id);
todo!() 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); dev_consume!(db, id);
todo!() 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; use thiserror::Error;
#[allow(dead_code)]
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum Error { pub enum Error {
#[error("markdown file error")] #[error("markdown file error")]
MdFile(#[from] MarkdownError), MdFile(#[from] MarkdownError),
#[error("missing essential field `{}` for file `{}`", field, filename)] #[error("db error")]
MissingField { field: String, filename: String }, Db(#[from] sqlx::Error),
#[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("git error")] #[error("git error")]
Git2(#[from] git2::Error), Git2(#[from] git2::Error),
#[error("git remote url mismatch")]
GitUrlMismatch,
} }
@@ -6,16 +6,19 @@ use std::{
use crate::{ use crate::{
constants::config::general::{default_value, var_name}, 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, scheduler::task_func::AsyncTaskFunc,
}; };
use macros::panic_with_log; use macros::panic_with_log;
use sqlx::PgPool; use sqlx::PgPool;
use tracing::{trace, warn, Level}; use tracing::{log::error, trace, warn, Level};
// mod error; // mod error;
// mod index; // mod index;
mod apply;
mod changes; mod changes;
mod error; mod error;
mod pull; mod pull;
@@ -85,7 +88,10 @@ async fn workflow(args: Arc<Args>) {
.await .await
{ {
Ok(updates) => updates, Ok(updates) => updates,
Err(_) => return, Err(e) => {
error!("delta parsing error: {}", e);
return;
}
}; };
if deltas.is_empty() { if deltas.is_empty() {
@@ -93,5 +99,5 @@ async fn workflow(args: Arc<Args>) {
return; return;
} }
// todo: load delta to database apply_deltas(&args.db_connection, deltas).await;
} }
@@ -1,18 +1,16 @@
use std::collections::HashMap; use std::{collections::HashMap, fs::remove_dir_all, path::PathBuf};
use std::path::PathBuf;
use crate::{ use crate::{
modules::blog::components::check_update::{ modules::blog::components::check_update::{
changes::{Change, Create, Delete, Update}, changes::{Change, Create, CreateDelete2UpdateSlot, Delete, Update},
error::Error, error::Error,
}, },
utils::{git, git::FileDelta}, utils::{git, git::FileDelta},
}; };
use markdown::MdFile; use markdown::MdFile;
use crate::modules::blog::components::check_update::changes::CreateDelete2UpdateSlot;
use git2::{Delta, Repository}; use git2::{Delta, Repository};
use tracing::error; use tracing::{error, warn};
use uuid::Uuid; use uuid::Uuid;
pub async fn fetch_deltas( 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 fetched_commit = git::do_fetch(&repo, &[git_remote_branch], &mut remote)?;
let commit_deltas = git::calculate_diff( let commit_deltas = git::calculate_diff(
&repo, &repo,
@@ -46,6 +64,8 @@ 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
if delta.status == Delta::Deleted { if delta.status == Delta::Deleted {
let file_path = &git_work_dir.join( let file_path = &git_work_dir.join(
delta delta
@@ -117,10 +137,7 @@ pub async fn fetch_deltas(
fn extract_post_id(post: &MdFile) -> Option<Uuid> { fn extract_post_id(post: &MdFile) -> Option<Uuid> {
match post.meta["uuid"].as_str() { match post.meta["uuid"].as_str() {
Some(id) => match Uuid::parse_str(id) { Some(id) => Uuid::parse_str(id).ok(),
Ok(id) => Some(id),
Err(_) => None,
},
None => None, 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::{ use crate::constants::{
config::general::{default_value, var_name}, 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::NaiveDate;
use chrono::{DateTime, FixedOffset, ParseResult};
use dirs::home_dir; use dirs::home_dir;
use tracing::warn; use tracing::warn;
@@ -30,6 +31,28 @@ pub fn expand_path(path: String) -> String {
} }
} }
pub fn parse_date(date_str: &str) -> ParseResult<DateTime<FixedOffset>> { pub fn parse_post_identifier(path: &Path) -> Option<String> {
DateTime::parse_from_str(format!("{}T120000", date_str).as_str(), DEFAULT_DATE_FORMAT) 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::scheduler::tasks::async_basic::AsyncBasic;
use crate::{ use crate::{
modules::{ modules::{
Module,
blog::{ blog::{
api::{ api::{
get_page_count, get_page_count,
@@ -13,12 +14,11 @@ use crate::{
}, },
// cron::check_update, // cron::check_update,
}, },
Module,
}, },
scheduler::tasks::CronTask, scheduler::tasks::CronTask,
}; };
use axum::{routing::get, Router}; use axum::{Router, routing::get};
use sqlx::PgPool; use sqlx::PgPool;
const MOUNT_POINT: &str = "/blog"; const MOUNT_POINT: &str = "/blog";
+33 -9
View File
@@ -12,30 +12,54 @@ CREATE TABLE categories
description TEXT DEFAULT '' 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 CREATE TABLE posts
( (
id UUID UNIQUE NOT NULL, id UUID PRIMARY KEY,
identifier VARCHAR(100) UNIQUE NOT NULL, identifier VARCHAR(100) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
subtitle VARCHAR(255) NOT NULL, subtitle VARCHAR(255) NOT NULL,
published_date DATE NOT NULL, date_created DATE NOT NULL,
last_update DATE, date_updated DATE,
status INTEGER REFERENCES post_status (id) DEFAULT 1 NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
category INTEGER REFERENCES categories (id) NOT NULL
category SERIAL REFERENCES categories (id) NOT NULL
); );
CREATE TABLE post_tag CREATE TABLE post_tag
( (
post UUID REFERENCES posts (id), post UUID REFERENCES posts (id) ON DELETE CASCADE,
tag SERIAL REFERENCES tags (id) tag INTEGER REFERENCES tags (id) ON DELETE CASCADE,
CONSTRAINT post_tag_pk PRIMARY KEY (post, tag)
); );
CREATE TABLE metadata 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_key VARCHAR(50) NOT NULL,
meta_value TEXT 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 /// https://github.com/rust-lang/git2-rs/commit/f3b87baed1e33d6c2d94fe1fa6aa6503a071d837
/// ///
use git2::{ use git2::{
build, AnnotatedCommit, AutotagOption, Delta, FetchOptions, Reference, Remote, Repository, AnnotatedCommit, AutotagOption, Delta, FetchOptions, Reference, Remote, Repository, build,
}; };
use std::path::PathBuf; use std::path::PathBuf;
use tracing::{error, warn}; use tracing::{error, warn};
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
pub struct FileDelta { pub struct FileDelta {
pub old_path: Option<PathBuf>, pub old_path: Option<PathBuf>,