6 Commits
17 changed files with 323 additions and 92 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";
+39 -15
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,
identifier VARCHAR(100) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
subtitle VARCHAR(255) NOT NULL,
published_date DATE NOT NULL,
last_update DATE,
content TEXT NOT NULL,
category SERIAL REFERENCES categories (id) NOT NULL
id UUID PRIMARY KEY,
identifier VARCHAR(100) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
subtitle VARCHAR(255) NOT NULL,
date_created DATE NOT NULL,
date_updated DATE,
status INTEGER REFERENCES post_status (id) DEFAULT 1 NOT NULL,
content TEXT 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,
meta_key VARCHAR(50) NOT NULL,
meta_value TEXT 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>,