Author SHA1 Message Date
lumither 50453e2af9 README.md cleanup 2025-03-12 03:37:48 -07:00
lumither c25e8b7e4d feat(doc) EOL documentation of the branch 2024-04-27 15:41:34 +08:00
137 changed files with 465 additions and 4262 deletions

No files matched your search

+2 -19
View File
@@ -1,19 +1,2 @@
# postgres settings
DB_HOST=localhost
DB_PORT=5432
#POSTGRES_PASSWORD=
POSTGRES_USER=nocturne
POSTGRES_DB=nocturne
POSTGRES_HOST=postgres
# backend settings
#GIT_WORK_DIR=../blog_posts_git
#LOG_ROOT_DIR=/var/log/nocturne
#BLOG_ENDPOINT=https://github.com/Lumither/blog-posts.git
BLOG_GIT_URL=https://github.com/Lumither/blog-posts.git
WORK_DIR=/nocturne
#BACKEND_PORT=3001
# frontend settings
API_LOCAL_URL=http://backend:3001
API_REMOTE_URL=https://lumither.com/api
BLOG_ENDPOINT=https://raw.githubusercontents.com/lumither/blog-posts/master
; use mirror
File renamed without changes.
+40 -11
View File
@@ -1,13 +1,42 @@
# confidential
.env.*
/container/*/.env
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# IDE related
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# bun
bun.lockb
# ide related
.idea
.vscode
# exclude post repo
/blog_posts_git
# rustc ICE dump
rustc-ice-*.txt
-9
View File
@@ -1,9 +0,0 @@
ARCH := $(shell uname -m)
ifeq ($(ARCH), arm64)
ARCH := aarch64
endif
.PHONY: up
clean:
echo "todo"
+78 -11
View File
@@ -1,16 +1,83 @@
# `Nocturne: Personal Website`
# This branch was deprecated.
This is the code repo of my [personal website](https://lumither.com),
and [here](https://github.com/Lumither/blog-posts/) is the link to the
content (blog posts etc.) repo.
In the initial plan of the blog, `raw.githubusercontent.com/<usr>/<branch>/<path_to_file>` (or any other git server)
was used as the cdn for the blog website, which also come with VCS without effort.
Powered by Next.js and Rust, built with passion and love
The idea is to build a separate repo that will be served as a database in ideal blog system structure, and all the
functionality related to CMS will be hosted by cloudflare worker.
> [!NOTE]
> This system is not production ready, and contains bugs, (security risk potentially).
> But have fun on it =)
```bash
master > tree
.
├── idx.json
└── posts
├── another_article.md
├── hello_world.md
└── idx.json
# Todos
2 directories, 3 files
```
- [ ] the todo list lol
- [ ] and much more...
This is an example of [db repo](https://github.com/Lumither/blog-posts.git).
```bash
master > cat idx.json
{
"idx": [
{
"type": "dir",
"name": "posts"
}
]
}
master > cat ./posts/idx.json
{
"idx": [
{
"type": "md",
"name": "another_article.md",
"meta": {
"title": "another_article!",
"desc": "test",
"tag": ["blog", "test", "dev"]
}
},
{
"type": "md",
"name": "hello_world.md",
"meta": {
"title": "Hello World!",
"desc": "test",
"tag": ["blog", "test", "dev"]
}
}
]
}
```
These are examples of `idx.json`. They were designed to be placed at any layer of the directory, and used
to accelerate the client side browsing. The following is an easy [implementation](/app/blog/fetch_posts.ts) of using `idx.json` at `/posts`
to get a list of blog posts.
```ts
export default async function fetch_posts(url: string): Promise<Result<JSON[], Error>> {
const response = await fetch(`${ url }/posts/idx.json`);
if (!response.ok) {
return {
ok: false,
error: new Error(response.statusText)
};
}
const res = await response.json();
const posts = res['idx'].filter((posts: { type: string }) => posts.type === 'md');
return {
ok: true,
value: posts
};
}
```
The approach work impressively well, thanks for the globe services of GitHub lol, but eventually the plan was changed
due to ethical reasons and possibility of breaking GitHub's EULA. Plus, the functionality of the blog site might be constrained due to
Cloudflare worker's CPU time limitations, considering the existence of potential resource-intense work such as on-site searching and analytics.
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
function About() {
return (
<div></div>
);
}
export default About;
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import Link from 'next/link';
import { Card, CardBody, CardHeader } from '@nextui-org/card';
import { Divider } from '@nextui-org/react';
type Props = {
post: JSON
}
function BlogPostCard({ post }: Props) {
const name = (post as any)['name'];
const { title, desc } = (post as any)['meta'] as { title: string, desc: string };
return (
<div>
{/*todo: to be update*/ }
{/*<Link href={ `/blog/${ name }` }>*/}
<Card>
<CardHeader>
<p className={ 'font-bold' }>{ title }</p>
</CardHeader>
<Divider />
<CardBody>
{ desc }
</CardBody>
</Card>
{/*</Link>*/}
</div>
);
}
export default BlogPostCard;
+19
View File
@@ -0,0 +1,19 @@
import { Result } from '@/app/public/type/Result';
export default async function fetch_posts(url: string): Promise<Result<JSON[], Error>> {
const response = await fetch(`${ url }/posts/idx.json`);
if (!response.ok) {
return {
ok: false,
error: new Error(response.statusText)
};
}
const res = await response.json();
const posts = res['idx'].filter((posts: { type: string }) => posts.type === 'md');
return {
ok: true,
value: posts
};
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
function Layout({
children
}: {
children: React.ReactNode;
}) {
return (
<div className={ 'flex items-stretch justify-center' }>
{ children }
</div>
);
}
export default Layout;
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import fetch_posts from '@/app/blog/fetch_posts';
import BlogPostCard from '@/app/blog/BlogPostCard';
async function Blog() {
const res = await fetch_posts(process.env.BLOG_ENDPOINT!);
if (!res.ok) {
console.log(res.error);
return;
}
const posts = res.value;
// console.log(posts);
return (
// blog list
<ul className={ 'flex flex-col items-center w-full max-w-[1024px]' }>
{ posts.map((post, key) => (
<li key={ key } className={ 'my-3.5 mx-7 w-[80%]' }>
<BlogPostCard post={ post } />
</li>)
) }
</ul>
);
}
export default Blog;
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+9 -8
View File
@@ -1,15 +1,15 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { SITE_CONFIG } from '@/src/constants';
import Navbar from '@/components/Navbar';
const inter = Inter({ subsets: [ 'latin' ] });
export const metadata: Metadata = {
openGraph: {
siteName: SITE_CONFIG.name
},
title: SITE_CONFIG.name,
description: SITE_CONFIG.desc
title: 'Lumither\'s site',
description: 'a small corner on internet'
};
export default function RootLayout({
@@ -19,8 +19,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={ `transition duration-300 dark:bg-[#282830] min-h-screen` }>
<NextThemesProvider attribute="class" defaultTheme="system">
<body className={ inter.className }>
<NextThemesProvider attribute="class" defaultTheme="dark">
<Navbar />
{ children }
</NextThemesProvider>
</body>
+7
View File
@@ -0,0 +1,7 @@
export default function Home() {
return (
<main>
{/*<ThemeSwitcher />*/ }
</main>
);
}
File renamed without changes.
File renamed without changes.
-3
View File
@@ -1,3 +0,0 @@
/target
/Cargo.lock
-18
View File
@@ -1,18 +0,0 @@
### Rust template
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
.env.local
-7
View File
@@ -1,7 +0,0 @@
[workspace]
resolver = "3"
members = [
"server",
"macros",
"markdown",
]
-8
View File
@@ -1,8 +0,0 @@
[package]
name = "macros"
version = "0.1.0"
edition = "2024"
[dependencies]
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
-6
View File
@@ -1,6 +0,0 @@
#[macro_export]
macro_rules! dev_consume {
($($var:ident),+) => {
$(let _ = $var;)*
};
}
-24
View File
@@ -1,24 +0,0 @@
/// Includes a UTF-8 encoded file as a string
///
/// The file is located relative to `CARGO_MANIFEST_DIR`.
///
/// # Example
/// ```rust
/// use macros::include_str_from_crate_root;
///
/// let str: &str = include_str_from_crate_root!("/query/scheme.sql");
///
// todo: impl
// const PATH_PREFIX: &str = "/src";
// let str: &str = include_str_from_crate_root!("{}/query/scheme.sql", PATH_PREFIX);
/// ```
#[macro_export]
macro_rules! include_str_from_crate_root {
// ($path:literal $(, $($arg:tt)*)?) => {
// ($path:expr, $($args:tt)*) => {
// include_str!(concat!(env!("CARGO_MANIFEST_DIR"), format_args!($path, $($args)*)))
// };
($path:expr) => {
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), $path))
};
}
-3
View File
@@ -1,3 +0,0 @@
mod dev;
mod include_str;
mod log;
-64
View File
@@ -1,64 +0,0 @@
/// log the message at specific log level and exit(1)
///
/// # Example
/// ```rust
///
/// use macros::panic_with_log;
/// use tracing::Level;
///
/// panic_with_log!(Level::ERROR, "test panicked: {}", 42);
/// ```
#[macro_export]
macro_rules! panic_with_log {
($level:expr, $msg:literal $(, $($arg:tt)*)?) => {{
use std::process;
use tracing::event;
event!($level, $msg $(, $($arg)*)?);
process::exit(1);
}};
}
/// log the message with `Level::ERROR` and exit(1)
///
/// # Example
/// ```rust
///
/// use macros::error_panic;
///
/// error_panic!("test panicked: {}", 42);
/// ```
#[macro_export]
macro_rules! error_panic {
($msg:literal $(, $($arg:tt)*)?) => {{
use std::process;
use tracing::{event, Level};
event!(Level::ERROR, $msg $(, $($arg)*)?);
process::exit(1);
}};
}
#[cfg(test)]
mod tests {
#[test]
fn test_panic_with_log() {
use std::io;
use tracing::Level;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{Registry, fmt};
let format = tracing_subscriber::fmt::format()
.with_level(true)
.with_target(true)
.with_source_location(true);
let subscriber = Registry::default().with(
fmt::Layer::default()
.with_writer(io::stderr)
.event_format(format),
);
tracing::subscriber::set_global_default(subscriber).unwrap();
panic_with_log!(Level::ERROR, "Test panicked: {}", 42);
}
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "markdown"
version = "0.1.0"
edition = "2024"
[dependencies]
uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] }
serde = { version = "1.0.209", features = ["derive"] }
serde_json = "1.0.128"
regex = "1.10.4"
yaml-rust2 = "0.10.0"
sha2 = "0.10.8"
base64 = "0.22.1"
thiserror = "2.0.3"
-10
View File
@@ -1,10 +0,0 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("invalid front-matter: {}", msg)]
InvalidFrontMatter { msg: String },
#[error("fs error: {}", msg)]
FSError { msg: String },
}
-57
View File
@@ -1,57 +0,0 @@
mod error;
mod utils;
use std::{fs, path::Path, str::FromStr};
use crate::{
error::Error,
utils::front_matter::{parse_front_matter, split_md_front_matter},
};
use base64::{Engine, engine::general_purpose};
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
#[derive(Default, Debug, Serialize)]
pub struct MdFile {
pub meta: Value,
pub content: String,
pub hash: String,
}
impl MdFile {
pub fn from_file(file_path: &Path) -> Result<MdFile, Error> {
let raw_file = match fs::read_to_string(file_path) {
Ok(file) => file,
Err(e) => return Err(Error::FSError { msg: e.to_string() }),
};
Self::from_str(&raw_file)
}
}
impl FromStr for MdFile {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = s.as_bytes().to_vec();
let (front_matter_str, content) = split_md_front_matter(s);
let front_matter = match parse_front_matter(&front_matter_str) {
Ok(front_matter) => front_matter,
Err(e) => return Err(Error::InvalidFrontMatter { msg: e.to_string() }),
};
let hash = calculate_hash_base64(&bytes);
Ok(MdFile {
meta: front_matter,
content,
hash,
})
}
}
fn calculate_hash_base64(bytes: &Vec<u8>) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let hash = hasher.finalize().to_vec();
general_purpose::STANDARD.encode(hash)
}
@@ -1,82 +0,0 @@
use std::error::Error;
use crate::utils::yaml::yaml_to_serde;
use regex::Regex;
use serde_json::Value;
use yaml_rust2::YamlLoader;
pub fn split_md_front_matter(raw_file: &str) -> (String, String) {
let re = Regex::new(r"(?s)^(---\n.*?\n---\n)(.*)").unwrap();
if let Some(captures) = re.captures(raw_file) {
let front_matter = captures.get(1).map_or("", |m| m.as_str()).to_string();
let main_content = captures.get(2).map_or("", |m| m.as_str()).to_string();
(front_matter, main_content)
} else {
(String::new(), raw_file.to_string())
}
}
pub fn parse_front_matter(front_matter: &str) -> Result<Value, Box<dyn Error>> {
let parsed_f_matter = YamlLoader::load_from_str(front_matter)?;
if parsed_f_matter.is_empty() {
Ok(Value::String(String::new()))
} else {
Ok(yaml_to_serde(&parsed_f_matter[0]))
}
}
#[cfg(test)]
mod tests {
use crate::utils::front_matter::{parse_front_matter, split_md_front_matter};
#[test]
fn front_matter_to_string() {
let (front_matter, main_content) = split_md_front_matter(
r##"---
title: test_title
date: 0000-00-00+0000
category: test
id: bf03afd8-61b4-4e78-a34a-8b3ee39adf16
sub_title: sub_title
summary: summary
tags: [ "test1", "test2" ]
---
test body
"##,
);
assert_eq!(
front_matter,
r##"---
title: test_title
date: 0000-00-00+0000
category: test
id: bf03afd8-61b4-4e78-a34a-8b3ee39adf16
sub_title: sub_title
summary: summary
tags: [ "test1", "test2" ]
---
"##
);
assert_eq!(main_content, "\ntest body\n\n");
}
#[test]
fn test_front_matter_parser() {
parse_front_matter(
r##"---
title: test_title
date: 0000-00-00+0000
category: test
id: bf03afd8-61b4-4e78-a34a-8b3ee39adf16
sub_title: sub_title
summary: summary
tags: [ "test1", "test2" ]
---
"##,
)
.expect("Failed to parse front matter");
}
}
-2
View File
@@ -1,2 +0,0 @@
pub mod front_matter;
mod yaml;
-29
View File
@@ -1,29 +0,0 @@
use serde_json::Value;
use std::str::FromStr;
use yaml_rust2::Yaml;
pub fn yaml_to_serde(yaml: &Yaml) -> Value {
match yaml {
Yaml::Null => Value::Null,
Yaml::Boolean(b) => Value::Bool(*b),
Yaml::Integer(i) => Value::Number(serde_json::Number::from(*i)),
Yaml::Real(s) => Value::Number(serde_json::Number::from_str(s).unwrap()),
Yaml::String(s) => Value::String(s.clone()),
Yaml::Array(arr) => Value::Array(arr.iter().map(yaml_to_serde).collect()),
Yaml::Hash(hash) => {
let obj: serde_json::map::Map<String, Value> = hash
.iter()
.map(|(k, v)| {
let key = match k {
Yaml::String(s) => s.clone(),
_ => "INVALID_KEY".to_string(),
};
(key, yaml_to_serde(v))
})
.collect();
Value::Object(obj)
}
Yaml::Alias(_) => Value::Null,
Yaml::BadValue => Value::Null,
}
}
-29
View File
@@ -1,29 +0,0 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
macros = { path = "../macros" }
markdown = { path = "../markdown" }
axum = { version = "0.8.1", features = ["macros"] }
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"] }
serde_json = "1.0.133"
serde = { version = "1.0.215", features = ["derive"] }
tracing-subscriber = "0.3.18"
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"] }
dotenv = "0.15.0"
uuid = { version = "1.13.2", features = ["v4"] }
futures = "0.3.31"
[build-dependencies]
git2 = "0.20.0"
chrono = "0.4.39"
-16
View File
@@ -1,16 +0,0 @@
use chrono::Utc;
use git2::Repository;
fn main() {
let repo = Repository::open("../..").expect("failed to open repository");
let commit = repo
.head()
.expect("failed to get HEAD")
.peel_to_commit()
.expect("failed to get HEAD commit");
let hash = commit.id().to_string();
let utc_timestamp = Utc::now().date_naive().format("%Y%m%d").to_string();
let pkg_version = env!("CARGO_PKG_VERSION");
let build_id = format!("{}:{}@{}", pkg_version, &hash[..8], utc_timestamp);
println!("cargo:rustc-env=BUILD_ID={}", build_id);
}
-1
View File
@@ -1 +0,0 @@
pub const PAGE_SIZE: u32 = 6;
-48
View File
@@ -1,48 +0,0 @@
// Database related
pub mod database {
pub mod var_name {
pub const URI: &str = "DATABASE_URL";
pub const USERNAME: &str = "POSTGRES_USER";
pub const PASSWORD: &str = "POSTGRES_PASSWORD";
pub const HOST: &str = "POSTGRES_HOST";
pub const PORT: &str = "POSTGRES_PORT";
pub const DBNAME: &str = "POSTGRES_DB";
}
pub mod default_value {
pub const USERNAME: &str = "";
pub const PASSWORD: &str = "";
pub const HOST: &str = "localhost";
pub const PORT: &str = "5432";
pub const DBNAME: &str = "nocturne";
pub const DATABASE_TYPE: &str = "postgresql";
}
}
pub mod server {
pub mod var_name {
pub const PORT: &str = "BACKEND_PORT";
pub const HOST: &str = "BACKEND_HOST";
}
pub mod default_value {
pub const PORT: u32 = 3001;
pub const HOST: &str = "0.0.0.0";
}
}
pub mod general {
pub mod var_name {
pub const LOG_DIR: &str = "LOG_DIR";
pub const WORK_DIR: &str = "WORK_DIR";
pub const BLOG_GIT_URL: &str = "BLOG_GIT_URL";
pub const BLOG_GIT_REMOTE_BRANCH: &str = "BLOG_GIT_REMOTE_BRANCH";
pub const BLOG_GIT_REMOTE_NAME: &str = "BLOG_GIT_REMOTE_NAME";
}
pub mod default_value {
pub const WORK_DIR: &str = "~/.nocturne";
pub const BLOG_GIT_REMOTE_BRANCH: &str = "master";
pub const BLOG_GIT_REMOTE_NAME: &str = "origin";
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod blog;
pub mod config;
pub mod time;
-2
View File
@@ -1,2 +0,0 @@
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";
-42
View File
@@ -1,42 +0,0 @@
use std::env;
use std::error::Error;
use std::str::FromStr;
use crate::constants::config::database::{default_value, var_name};
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::{ConnectOptions, Pool, Postgres};
use tracing::info;
pub async fn init() -> Result<Pool<Postgres>, Box<dyn Error>> {
let db_connect_option = PgConnectOptions::from_str(&parse_db_uri())
.unwrap()
.disable_statement_logging();
Ok(PgPoolOptions::new()
.max_connections(1) // todo: temporary solution, to be fixed
.connect_with(db_connect_option)
.await?)
}
fn parse_db_uri() -> String {
env::var(var_name::URI).unwrap_or_else(|_| {
info!(
"`{}` env var is not set, \
using `{}`, `{}`, `{}`, `{}` and `{}`",
var_name::URI,
var_name::USERNAME,
var_name::PASSWORD,
var_name::HOST,
var_name::PORT,
var_name::DBNAME
);
let postgres_user = env::var(var_name::USERNAME).unwrap_or(default_value::USERNAME.into());
let postgres_password =
env::var(var_name::PASSWORD).unwrap_or(default_value::PASSWORD.into());
let db_host = env::var(var_name::HOST).unwrap_or(default_value::HOST.into());
let db_port = env::var(var_name::PORT).unwrap_or(default_value::PORT.into());
let postgres_db = env::var(var_name::DBNAME).unwrap_or(default_value::DBNAME.into());
let db_type = default_value::DATABASE_TYPE;
format!("{db_type}://{postgres_user}:{postgres_password}@{db_host}:{db_port}/{postgres_db}")
})
}
-107
View File
@@ -1,107 +0,0 @@
use std::path::Path;
use std::{env, io};
use crate::constants::config::general::{default_value, var_name};
use chrono::Local;
use dirs::home_dir;
use tracing::info;
use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling};
use tracing_subscriber::{
Layer, Registry, filter::LevelFilter, fmt, fmt::MakeWriter, fmt::format::Writer,
fmt::time::FormatTime, layer::SubscriberExt,
};
struct LocalTimer;
impl FormatTime for LocalTimer {
fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
write!(
w,
"{}",
Local::now().format(crate::constants::time::DEFAULT_TIME_FORMAT)
)
}
}
type _LogWriter = dyn for<'writer> MakeWriter<'writer, Writer = dyn io::Write + 'static> + 'static;
pub fn init() -> (WorkerGuard, WorkerGuard) {
let log_path = match env::var(var_name::LOG_DIR) {
Ok(path) => Path::new(&path).to_path_buf(),
Err(_) => {
let path = if let Ok(default_log_path) = env::var(var_name::WORK_DIR) {
expand_home_path(default_log_path)
} else {
let fallback_path = expand_home_path(default_value::WORK_DIR.to_string());
println!(
"[warn] unset env var `{}` and `{}`, trying to log on fallback path: {}/log",
var_name::LOG_DIR,
var_name::WORK_DIR,
fallback_path
);
fallback_path
};
Path::new(&path).join("log")
}
};
let (info_log_writer, info_guard) = non_blocking(rolling::never(&log_path, "info.log"));
let (err_log_writer, error_guard) = non_blocking(rolling::never(&log_path, "error.log"));
let info_format = tracing_subscriber::fmt::format()
.with_level(true)
.with_target(true)
.with_source_location(true)
.with_ansi(false);
let err_format = tracing_subscriber::fmt::format()
.with_level(true)
.with_target(true)
.with_source_location(true)
.with_ansi(false);
let stdout_format = tracing_subscriber::fmt::format()
.with_level(true)
.with_target(true)
.with_source_location(true)
.with_timer(LocalTimer);
let subscribers = Registry::default()
.with(
fmt::Layer::default()
.with_writer(info_log_writer)
.event_format(info_format),
)
.with(
fmt::Layer::default()
.with_writer(err_log_writer)
.event_format(err_format)
.with_filter(LevelFilter::ERROR),
)
.with(
fmt::Layer::default()
.with_writer(io::stdout)
.with_ansi(true)
.event_format(stdout_format),
);
tracing::subscriber::set_global_default(subscribers)
.expect("[fatal] failed to setup logging system");
info!("logging to dir: {}", log_path.to_str().unwrap());
(info_guard, error_guard)
}
fn expand_home_path(path: String) -> String {
if path.starts_with('~') {
let home_path = home_dir()
.unwrap_or_else(|| panic!("[fatal] unable to locate home dir when parsing env `{}`, try to use absolute path instead", var_name::WORK_DIR));
home_path
.join(path.strip_prefix("~/").unwrap())
.into_os_string()
.into_string()
.unwrap()
} else {
path
}
}
-14
View File
@@ -1,14 +0,0 @@
use git2::opts::{set_server_connect_timeout_in_milliseconds, set_server_timeout_in_milliseconds};
use tracing::warn;
pub fn init() {
// git2 option
unsafe {
if let Err(e) = set_server_connect_timeout_in_milliseconds(10_000) {
warn!("failed to set git2 server connect timeout: {}", e);
}
if let Err(e) = set_server_timeout_in_milliseconds(60_000) {
warn!("failed to set git2 server timeout: {}", e);
}
}
}
-50
View File
@@ -1,50 +0,0 @@
mod database;
pub mod logger;
mod misc;
use std::{env, panic};
use crate::modules::{ModuleTree, blog::Blog};
use macros::error_panic;
use tracing::info;
pub fn load_env() {
if let Some(env_file) = env::args().nth(1) {
match dotenv::from_filename(&env_file) {
Ok(_) => {
println!("[info] env loaded from {}, starting up", env_file);
}
Err(e) => {
panic!("[fatal] failed to read {}: {}", env_file, e);
}
}
} else {
println!("[info] no .env file referred, starting up")
}
}
pub async fn start() {
misc::init();
// connect database
let db_pool = match database::init().await {
Ok(pool) => {
info!("database connection pool initialized");
pool
}
Err(e) => {
error_panic!("database connection pool failed to initialize: {}", e);
}
};
// // schema init
// schema::init(&db_pool).await;
ModuleTree::new()
.add_module(Blog {
db_handler: db_pool,
})
.blocking_serve()
.await
}
-22
View File
@@ -1,22 +0,0 @@
#[allow(dead_code)]
mod constants;
mod scheduler;
mod init;
mod modules;
mod utils;
use crate::init::{logger, start};
const BUILD_ID: &str = env!("BUILD_ID");
#[tokio::main]
async fn main() {
println!("{}", BUILD_ID);
init::load_env();
let _guards = logger::init();
start().await;
}
@@ -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;
Whitespace-only changes.
Whitespace-only changes.
@@ -1,34 +0,0 @@
use macros::dev_consume;
use axum::{
extract::{Path, State},
response::IntoResponse,
Json,
};
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,6 +0,0 @@
pub mod get_page_count;
// todo: remove exception tag
#[allow(dead_code)]
pub mod get_post;
// pub mod get_post_list;
@@ -1,49 +0,0 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PostIdxError {
#[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,
},
}
@@ -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,78 +0,0 @@
use crate::constants::config::general::{default_value, var_name};
use macros::panic_with_log;
use std::sync::Arc;
use std::{
env,
path::{Path, PathBuf},
};
use crate::modules::blog::components::check_update::{
// index::drop_index,
// utils::{expand_path, index_files, search_md},
utils::expand_path,
};
use crate::scheduler::task_func::AsyncTaskFunc;
use sqlx::PgPool;
use tracing::{info, warn, Level};
// mod error;
// mod index;
mod utils;
#[derive(Debug)]
struct Args {
git_work_dir: PathBuf,
git_url: String,
git_remote_name: String,
git_remote_branch: String,
}
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,
Err(e) => {
panic_with_log!(
Level::ERROR,
"failed to read `{}`: {}",
var_name::BLOG_GIT_URL,
e
)
}
};
let git_remote_name = env::var(var_name::BLOG_GIT_REMOTE_NAME)
.unwrap_or(default_value::BLOG_GIT_REMOTE_NAME.to_string());
let git_remote_branch = env::var(var_name::BLOG_GIT_REMOTE_BRANCH)
.unwrap_or(default_value::BLOG_GIT_REMOTE_BRANCH.to_string());
let arc_args = Arc::new(Args {
git_work_dir,
git_url,
git_remote_name,
git_remote_branch,
});
move || Box::pin(test(arc_args.clone()))
}
async fn test(args: Arc<Args>) {
info!("test");
dbg!(&args);
}
@@ -1,81 +0,0 @@
use crate::constants::{
config::general::{default_value, var_name},
time::DEFAULT_DATE_FORMAT,
};
// use crate::modules::blog::components::check_update::index::index_file;
use chrono::{DateTime, FixedOffset, ParseResult};
use dirs::home_dir;
use tracing::warn;
// pub fn search_md(entry: PathBuf) -> Vec<PathBuf> {
// if entry.ends_with(".git") {
// return vec![];
// }
// if entry.is_dir() {
// if let Ok(files) = entry.read_dir() {
// files
// .flat_map(|file| {
// let file_path = file.unwrap().path();
// search_md(file_path)
// })
// .collect()
// } else {
// vec![]
// }
// } else if entry.extension().is_some_and(|ext| ext == "md") {
// vec![entry]
// } else {
// vec![]
// }
// }
// pub fn index_files(files: &[PathBuf], db_conn: &Pool<Postgres>) {
// let rt = Runtime::new().unwrap();
// rt.block_on(async {
// let files_clone = files.to_owned();
// let handlers = files_clone
// .into_iter()
// .map(|file: PathBuf| {
// let conn_clone = db_conn.clone();
// spawn(async move {
// if let Err(e) = index_file(&file, &conn_clone).await {
// error!("failed to index markdown file `{}`: {}", file.display(), e);
// }
// })
// })
// .collect::<Vec<_>>();
//
// block_on(join_all(handlers));
// });
// }
pub fn expand_path(path: String) -> String {
if path.starts_with('~') {
home_dir()
.map(|dir| {
dir.join(path.strip_prefix("~/").unwrap())
.into_os_string()
.into_string()
.unwrap()
})
.unwrap_or_else(|| {
warn!(
"failed to parse env var `{}`, using default value `{}`",
var_name::WORK_DIR,
default_value::WORK_DIR
);
default_value::WORK_DIR.to_string()
})
} else {
path
}
}
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 get_md_file_basename(f_name: &str) -> String {
// f_name.replace(".md", "")
// }
@@ -1,2 +0,0 @@
pub mod check_update;
mod rag;
Whitespace-only changes.
-64
View File
@@ -1,64 +0,0 @@
mod api;
mod components;
use crate::{
modules::{
blog::{
api::{
get_page_count,
get_post,
// get_post_list::get_post_list,
},
// cron::check_update,
},
Module,
},
scheduler::tasks::CronTask,
};
use crate::modules::blog::components::check_update;
use crate::scheduler::tasks::async_basic::AsyncBasic;
use crate::scheduler::tasks::basic::BasicTask;
use axum::{routing::get, Router};
use sqlx::PgPool;
const MOUNT_POINT: &str = "/blog";
pub struct Blog {
pub db_handler: PgPool,
}
impl Module for Blog {
fn get_server_router(&self) -> Router {
let db_handler = self.db_handler.clone();
// 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))
.with_state(db_handler)
}
fn get_mount_point(&self) -> &'static str {
MOUNT_POINT
}
fn get_cron_tasks(&self) -> Vec<(&str, Box<dyn CronTask>)> {
let db_handler = self.db_handler.clone();
vec![
(
"Blog Check Update",
AsyncBasic::new(check_update::task(db_handler.clone()), "* * * * * *")
.unwrap()
.to_task(),
),
(
"Sync test task",
BasicTask::new(|| println!("test lol"), "* * * * * *")
.unwrap()
.to_task(),
),
]
}
}
@@ -1,41 +0,0 @@
CREATE TABLE tags
(
id SERIAL PRIMARY KEY,
name VARCHAR(20) UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE categories
(
id SERIAL PRIMARY KEY,
name VARCHAR(20) UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE posts
(
id SERIAL PRIMARY KEY,
identifier VARCHAR(50) 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
);
CREATE TABLE post_tag
(
post SERIAL REFERENCES posts (id),
tag SERIAL REFERENCES tags (id)
);
CREATE TABLE metadata
(
pid SERIAL REFERENCES posts (id) NOT NULL,
meta_key VARCHAR(50) NOT NULL,
meta_value TEXT NOT NULL,
CONSTRAINT meta_pk PRIMARY KEY (pid, meta_key)
);
-82
View File
@@ -1,82 +0,0 @@
use std::{env, num::ParseIntError};
use crate::{
constants::config::server::{default_value, var_name},
scheduler::{tasks::CronTask, Scheduler},
};
use macros::error_panic;
use axum::Router;
use tokio::net::TcpListener;
use tracing::{info, warn};
pub mod blog;
// todo: merge `get_server_router` & `get_mount_point`, make Option
pub trait Module {
fn get_server_router(&self) -> Router;
fn get_mount_point(&self) -> &'static str;
fn get_cron_tasks(&self) -> Vec<(&str, Box<dyn CronTask>)>;
}
pub struct ModuleTree {
pub modules: Vec<Box<dyn Module>>,
}
impl ModuleTree {
pub fn new() -> Self {
ModuleTree { modules: vec![] }
}
pub fn add_module(&mut self, module: impl Module + 'static) -> &mut Self {
self.modules.push(Box::new(module));
self
}
pub async fn blocking_serve(&self) {
let mut scheduler = Scheduler::new();
let mut app = Router::new();
let port: u32 = match env::var(var_name::PORT) {
Ok(value) => value.parse().unwrap_or_else(|e: ParseIntError| {
warn!(
"failed to parse `{}`, using default port: {}",
var_name::PORT,
e.to_string()
);
default_value::PORT
}),
Err(_) => default_value::PORT,
};
let listener = match TcpListener::bind(format!("{}:{}", default_value::HOST, port)).await {
Ok(listener) => {
info!("server starting on {}:{}", default_value::HOST, port);
listener
}
Err(e) => {
error_panic!(
"failed to start server on {}:{}: {}",
default_value::HOST,
port,
e.to_string()
);
}
};
for module in &self.modules {
let _ = scheduler.insert_list(module.get_cron_tasks());
app = app.nest(module.get_mount_point(), module.get_server_router());
}
tokio::spawn(async move {
scheduler.start().await;
});
axum::serve(listener, app).await.unwrap_or_else(|e| {
error_panic!("failed to start axum: {}", e.to_string());
});
}
}
-49
View File
@@ -1,49 +0,0 @@
use std::{
error::Error,
fmt::{Debug, Display, Formatter},
sync::PoisonError,
};
use crate::scheduler::error::SchedulerError::MutexPoison;
// todo: refactor with thiserror
#[allow(dead_code)]
pub enum SchedulerError {
IdNotFound,
IdAlreadyRunning,
MutexPoison(String),
}
impl<T> From<PoisonError<T>> for SchedulerError {
fn from(value: PoisonError<T>) -> Self {
MutexPoison(value.to_string())
}
}
impl Debug for SchedulerError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self)
}
}
impl Display for SchedulerError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
SchedulerError::IdNotFound => {
"SchedulerError::IdNotFound".to_string()
}
SchedulerError::IdAlreadyRunning => {
"SchedulerError::IdAlreadyRunning".to_string()
}
MutexPoison(str) => {
format!("SchedulerError::MutexPoison: {}", str)
}
}
)
}
}
impl Error for SchedulerError {}
-56
View File
@@ -1,56 +0,0 @@
use std::{collections::HashMap, sync::Arc};
use crate::scheduler::{error::SchedulerError, tasks::CronTask, utils::execute};
use futures::future::join_all;
use tracing::warn;
pub mod error;
pub mod task_func;
pub mod tasks;
mod utils;
pub type TaskPool = HashMap<String, Arc<Box<dyn CronTask>>>;
#[derive(Default)]
pub struct Scheduler {
task_pool: TaskPool,
}
impl Scheduler {
/// Start all tasks in the scheduler
pub async fn start(&self) {
dbg!("entering");
let fut = self
.task_pool
.clone()
.into_iter()
.map(|(name, task)| execute(name, task))
.collect::<Vec<_>>();
// self.runner.run_all().await
join_all(fut).await;
}
pub fn insert(&mut self, task: (&str, Box<dyn CronTask>)) -> Result<&Self, SchedulerError> {
let (k, v) = task;
if self.task_pool.insert(k.into(), v.into()).is_some() {
warn!("task list duplicated key detected: {}", k);
}
Ok(self)
}
pub fn insert_list(
&mut self,
tasks: Vec<(&str, Box<dyn CronTask>)>,
) -> Result<&Self, SchedulerError> {
for task in tasks {
self.insert(task)?;
}
Ok(self)
}
pub fn new() -> Self {
Self::default()
}
}
-15
View File
@@ -1,15 +0,0 @@
use std::pin::Pin;
pub trait TaskFunc: Fn() + Send + Sync + 'static {}
impl<T> TaskFunc for T where T: Fn() + Send + Sync + Clone + 'static {}
pub trait AsyncTaskFunc:
Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync + 'static
{
}
impl<T> AsyncTaskFunc for T where
T: Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync + Clone + 'static
{
}
@@ -1,37 +0,0 @@
use std::{error::Error, pin::Pin, str::FromStr};
use crate::{scheduler::task_func::AsyncTaskFunc, scheduler::tasks::CronTask};
use chrono::{DateTime, Utc};
use cron::Schedule;
pub struct AsyncBasic {
schedule: Schedule,
func: Box<dyn AsyncTaskFunc>,
}
impl AsyncBasic {
pub fn new<F>(func: F, cron_expr: &str) -> Result<Self, Box<dyn Error>>
where
F: AsyncTaskFunc,
{
Ok(AsyncBasic {
schedule: Schedule::from_str(cron_expr)?,
func: Box::new(func),
})
}
pub fn to_task(self) -> Box<dyn CronTask> {
Box::new(self)
}
}
impl CronTask for AsyncBasic {
fn get_next_execution(&self) -> Option<DateTime<Utc>> {
self.schedule.upcoming(Utc).next()
}
fn call(&self) -> Pin<Box<(dyn Future<Output = ()> + Send)>> {
Box::pin((self.func)())
}
}
@@ -1,37 +0,0 @@
use std::{error::Error, pin::Pin, str::FromStr};
use crate::scheduler::{task_func::TaskFunc, tasks::CronTask};
use chrono::{DateTime, Utc};
use cron::Schedule;
pub struct BasicTask {
schedule: Schedule,
func: Box<dyn TaskFunc>,
}
impl BasicTask {
pub fn new<F>(func: F, cron_expr: &str) -> Result<Self, Box<dyn Error>>
where
F: TaskFunc,
{
Ok(BasicTask {
schedule: Schedule::from_str(cron_expr)?,
func: Box::new(func),
})
}
pub fn to_task(self) -> Box<dyn CronTask> {
Box::new(self)
}
}
impl CronTask for BasicTask {
fn get_next_execution(&self) -> Option<DateTime<Utc>> {
self.schedule.upcoming(Utc).next()
}
fn call(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async { (self.func)() })
}
}
-11
View File
@@ -1,11 +0,0 @@
use std::pin::Pin;
use chrono::{DateTime, Utc};
pub mod async_basic;
pub mod basic;
pub trait CronTask: Send + Sync {
fn get_next_execution(&self) -> Option<DateTime<Utc>>;
fn call(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
}
-32
View File
@@ -1,32 +0,0 @@
use std::{sync::Arc, time::Duration};
use crate::scheduler::{error::SchedulerError, tasks::CronTask};
use chrono::{DateTime, Utc};
use tokio::time::{sleep, Sleep};
pub fn duration_from_now(future: DateTime<Utc>) -> Duration {
future
.signed_duration_since(Utc::now())
.to_std()
.unwrap_or(Duration::ZERO)
}
pub fn sleeper_from_now(future: DateTime<Utc>) -> Sleep {
sleep(duration_from_now(future))
}
#[allow(unused_variables)]
pub async fn execute(name: String, func: Arc<Box<dyn CronTask>>) -> Result<(), SchedulerError> {
// todo: stats
while let Some(next_execution) = func.get_next_execution() {
let sleeper = sleeper_from_now(next_execution);
tokio::pin!(sleeper);
tokio::select! {
_ = sleeper => {
func.call().await
}
}
}
Ok(())
}
-160
View File
@@ -1,160 +0,0 @@
/// git merge
/// based on https://github.com/rust-lang/git2-rs/blob/master/examples/pull.rs
/// https://github.com/rust-lang/git2-rs/commit/f3b87baed1e33d6c2d94fe1fa6aa6503a071d837
///
use git2::{
build, AnnotatedCommit, AutotagOption, Delta, FetchOptions, Reference, Remote, Repository,
};
use std::path::PathBuf;
use tracing::{error, warn};
#[derive(Debug, Eq, PartialEq)]
pub struct FileDelta {
pub old_path: Option<PathBuf>,
pub new_path: Option<PathBuf>,
pub status: Delta,
}
fn do_fetch<'a>(
repo: &'a Repository,
refs: &[&str],
remote: &'a mut Remote,
) -> Result<AnnotatedCommit<'a>, git2::Error> {
let mut fetch_option = FetchOptions::new();
fetch_option.download_tags(AutotagOption::All);
remote.fetch(refs, Some(&mut fetch_option), None)?;
let fetch_head = repo.find_reference("FETCH_HEAD")?;
repo.reference_to_annotated_commit(&fetch_head)
}
fn fast_forward(
repo: &Repository,
lb: &mut Reference,
rc: &AnnotatedCommit,
) -> Result<(), git2::Error> {
let name = match lb.name() {
Some(s) => s.to_string(),
None => String::from_utf8_lossy(lb.name_bytes()).to_string(),
};
let msg = format!("fast-forward({}) -> {}", name, rc.id());
// println!("{}", msg);
lb.set_target(rc.id(), &msg)?;
repo.set_head(&name)?;
repo.checkout_head(Some(build::CheckoutBuilder::default().force()))?;
Ok(())
}
fn normal_merge(
repo: &Repository,
local: &AnnotatedCommit,
remote: &AnnotatedCommit,
) -> Result<(), git2::Error> {
let local_tree = repo.find_commit(local.id())?.tree()?;
let remote_tree = repo.find_commit(remote.id())?.tree()?;
let ancestor = repo
.find_commit(repo.merge_base(local.id(), remote.id())?)?
.tree()?;
let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
if idx.has_conflicts() {
warn!("merge conflicts detected");
repo.checkout_index(Some(&mut idx), None)?;
return Ok(());
}
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
let msg = format!("merge({}) -> {}", remote.id(), local.id());
let sig = repo.signature()?;
let local_commit = repo.find_commit(local.id())?;
let remote_commit = repo.find_commit(remote.id())?;
let _merge_commit = repo.commit(
Some("HEAD"),
&sig,
&sig,
&msg,
&result_tree,
&[&local_commit, &remote_commit],
)?;
repo.checkout_head(None)?;
Ok(())
}
fn do_merge<'a>(
repo: &'a Repository,
remote_branch: &str,
fetch_commit: AnnotatedCommit<'a>,
) -> Result<Vec<FileDelta>, git2::Error> {
let analysis = repo.merge_analysis(&[&fetch_commit])?;
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
if analysis.0.is_fast_forward() {
let ref_name = format!("refs/heads/{}", remote_branch);
match repo.find_reference(&ref_name) {
Ok(mut r) => {
fast_forward(repo, &mut r, &fetch_commit)?;
}
Err(_) => {
repo.reference(
&ref_name,
fetch_commit.id(),
true,
&format!("({}) -> {}", remote_branch, fetch_commit.id()),
)?;
repo.set_head(&ref_name)?;
repo.checkout_head(Some(
build::CheckoutBuilder::default()
.allow_conflicts(true)
.conflict_style_merge(true)
.force(),
))?;
}
};
} else if analysis.0.is_normal() {
normal_merge(repo, &head_commit, &fetch_commit)?;
}
calculate_diff(repo, &head_commit, &fetch_commit)
}
fn calculate_diff(
repo: &Repository,
local: &AnnotatedCommit,
remote: &AnnotatedCommit,
) -> Result<Vec<FileDelta>, git2::Error> {
let local_tree = repo.find_commit(local.id())?.tree()?;
let remote_tree = repo.find_commit(remote.id())?.tree()?;
let diff = repo.diff_tree_to_tree(Some(&local_tree), Some(&remote_tree), None)?;
let mut updated_files: Vec<FileDelta> = Vec::new();
if let Err(e) = diff.foreach(
&mut |d, _| {
updated_files.push(FileDelta {
old_path: d.old_file().path().map(PathBuf::from),
new_path: d.new_file().path().map(PathBuf::from),
status: d.status(),
});
true
},
None,
None,
None,
) {
error!("error at calculating diff: {:?}", e);
}
Ok(updated_files.into_iter().collect())
}
pub fn sync(
remote_name: &str,
remote_branch: &str,
repo: &Repository,
) -> Result<Vec<FileDelta>, git2::Error> {
let mut remote = repo.find_remote(remote_name)?;
let fetch_commit = do_fetch(repo, &[remote_branch], &mut remote)?;
do_merge(repo, remote_branch, fetch_commit)
}
-1
View File
@@ -1 +0,0 @@
pub mod git;
-11
View File
@@ -1,11 +0,0 @@
.venv/
.idea/
.DS_store
__pycache__
.cache
content/*
-30
View File
@@ -1,30 +0,0 @@
## Module Structure
```shell
.
├── README.md
├── cli
├── content
│ ├── pages
│ │ └── about
│ │ └── <static_page>.md
│ └── posts
│ └── <year>
│ └── <identifier>
│ ├── assets
│ │ └── <assets>
│ ├── translate_<lang>.md
│ └── index.md
├── requirements.txt
├── templates
│ ├── <template>.py
│ └── ...
└── utils
└── ...
```
### About `posts`
- Will render to `https://<DOMAIN>/blog/post/<identifier>`.
- Files under `assets/` (where images etc. located) should be pushed to OSS.
- For identifiers, use prefix `-` to indicate private (hidden) visibility. EX: `-hello-world`.
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env python3
import argparse
import rich
from templates.post import BlogPost
from utils.cache import init_cache
from utils.index import build_post_idx
def main():
# if args.new:
# new_post()
init_cache()
build_post_idx()
bp = BlogPost()
bp.init()
parser = argparse.ArgumentParser()
parser.add_argument("-n", "--new", help="Create a post with build-in template", type=str)
args = parser.parse_args(namespace=rich)
# todo: cli
if __name__ == '__main__':
main()
-4
View File
@@ -1,4 +0,0 @@
rich~=13.9.4
prompt_toolkit~=3.0.50
pypinyin~=0.53.0
python-frontmatter~=1.1.0
-87
View File
@@ -1,87 +0,0 @@
import uuid
from datetime import date
from datetime import datetime as dt
from typing import Dict, Optional
from templates.utils import Template, create_all_files
from utils.autocompletion import input_autocompletion, fuzzy_pinyin_matcher
from utils.cache import get_tags_cache, get_categories_cache
from utils.lang import eprint, wprint
POST_TEMPLATE = '''
---
title: {{ title }}
subtitle: {{ subtitle }}
published_date: {{ published_date }}
last_updated: {{ last_update }}
category: {{ category }}
tags: {{ tags }}
uuid: {{ uuid }}
---
WIP
'''
def get_title():
while True:
i = input("title > ")
if i.strip() == "":
eprint("empty title")
continue
else:
return i.strip()
def get_subtitle():
return input("subtitle > ")
def get_published_date():
while True:
d = input("published_date (YYYY-MM-DD) > ")
if d == "":
wprint("using today's date")
return dt.today().strftime("%Y-%m-%d")
else:
try:
_validate = dt.strptime(d, "%Y-%m-%d")
return d
except ValueError:
eprint("invalid date")
continue
def get_tags():
# todo: multi tags support
return input_autocompletion("tags > ", get_tags_cache(), fuzzy_pinyin_matcher)
def get_category():
while True:
i = input_autocompletion("category > ", get_categories_cache(), fuzzy_pinyin_matcher)
if i.strip() == "":
eprint("empty category")
continue
else:
return i.strip()
class BlogPost(Template):
WORK_PATH: str = "content/posts/{year}/{identifier}/"
ITEMS: Dict[str, Optional[str]] = {"index.md": POST_TEMPLATE, "assets/": None}
def init(self):
year = date.today().year
index_md = POST_TEMPLATE.format(
title=get_title(),
subtitle=get_subtitle(),
published_date=get_published_date(),
last_update=dt.today().strftime("%Y-%m-%d"),
tags=get_tags(),
category=get_category(),
uuid=uuid.uuid4()
)
create_all_files(self.WORK_PATH.format(year=year, identifier="test"), self.ITEMS)
-17
View File
@@ -1,17 +0,0 @@
import os
from abc import ABC, abstractmethod
from typing import Dict, Optional
class Template(ABC):
@abstractmethod
def init(self):
pass
def create_all_files(relative_work_path: str, items: Dict[str, Optional[str]]):
absolute_path = os.path.abspath(relative_work_path)
for item in items:
if item.endswith("/"):
print(absolute_path + item)
# todo: create files
-31
View File
@@ -1,31 +0,0 @@
class Ansi:
HEADER = '\033[95m'
BLUE = '\033[94m'
GREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
END = '\033[0m'
def disable(self) -> None:
self.HEADER = ''
self.BLUE = ''
self.GREEN = ''
self.WARNING = ''
self.FAIL = ''
self.BOLD = ''
self.UNDERLINE = ''
self.END = ''
@staticmethod
def blend(color: list[str], text: str) -> str:
return f"{"".join(color)}{text}{Ansi.END}"
@staticmethod
def bold(text: str) -> str:
return f"{Ansi.BOLD}{text}{Ansi.END}"
@staticmethod
def underline(text: str) -> str:
return f"{Ansi.UNDERLINE}{text}{Ansi.END}"
-46
View File
@@ -1,46 +0,0 @@
from collections.abc import Callable
from typing import List, Iterable
import prompt_toolkit
from prompt_toolkit.completion import Completer, CompleteEvent, Completion
from prompt_toolkit.document import Document
from pypinyin import lazy_pinyin, Style
class AutoCompleter(Completer):
def __init__(self, candidates: List[str], matching_algorithm: Callable[[str, str], bool]):
self.candidates = candidates
self.matching_algorithm = matching_algorithm
def get_completions(self, document: Document, complete_event: CompleteEvent) -> Iterable[Completion]:
curr_word = document.text_before_cursor
for c in self.candidates:
if self.matching_algorithm(curr_word, c):
yield Completion(c, start_position=-len(curr_word))
def exact_matcher(word: str, candidate: str, case_sensitive: bool = False) -> bool:
if case_sensitive:
return word == candidate
else:
return word.lower() == candidate.lower()
def fuzzy_matcher(word: str, candidate: str) -> bool:
return word.lower() in candidate.lower()
def fuzzy_pinyin_matcher(word: str, candidate: str) -> bool:
try:
word_matcher = ''.join(lazy_pinyin(word, style=Style.NORMAL))
candidate_matcher = ''.join(lazy_pinyin(candidate, style=Style.NORMAL))
return candidate_matcher.startswith(word_matcher)
except e:
return fuzzy_matcher(word, candidate)
def input_autocompletion(prompt: str, candidates: List[str],
matching_algorithm: Callable[[str, str], bool] = exact_matcher) -> str:
completer = AutoCompleter(candidates, matching_algorithm)
return prompt_toolkit.prompt(prompt, completer=completer, complete_in_thread=True)
-49
View File
@@ -1,49 +0,0 @@
import json
import os
from typing import TextIO
CACHE_DIR = ".cache"
ITEMS = {"register": "register.json"}
def overwrite_json_file(file: TextIO, json_data: dict):
file.seek(0)
file.write(json.dumps(json_data, indent=4, ensure_ascii=False))
file.truncate()
def init_cache():
if not os.path.exists(CACHE_DIR):
os.makedirs(CACHE_DIR)
for item in ITEMS.values():
item_path = os.path.join(CACHE_DIR, item)
if not os.path.exists(item_path):
with open(item_path, 'w') as cache_file:
match item:
case "register.json":
overwrite_json_file(cache_file, {})
break
def write_tags_cache(tags: list[str]):
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r+') as tags_cache_file:
cache = json.load(tags_cache_file)
cache["tags"] = tags
overwrite_json_file(tags_cache_file, cache)
def write_category_cache(categories: list[str]):
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r+') as categories_cache_file:
cache = json.load(categories_cache_file)
cache["categories"] = categories
overwrite_json_file(categories_cache_file, cache)
def get_tags_cache() -> list[str]:
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r') as tags_cache_file:
return json.load(tags_cache_file)["tags"]
def get_categories_cache() -> list[str]:
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r') as categories_cache_file:
return json.load(categories_cache_file)["categories"]
-27
View File
@@ -1,27 +0,0 @@
import os
import frontmatter
from utils.cache import write_tags_cache, write_category_cache
from utils.lang import list_exclude
EXCLUDE = [".DS_Store"]
POSTS_DIR = "content/posts"
def build_post_idx():
tags = set()
categories = set()
for year in list_exclude(os.listdir(POSTS_DIR), EXCLUDE):
for post_dir in list_exclude(os.listdir(os.path.join(POSTS_DIR, year)), EXCLUDE):
target_file = os.path.join(POSTS_DIR, year, post_dir, "index.md")
with open(target_file, "r") as f:
post = frontmatter.loads(f.read())
try:
for tag in post.metadata["tags"]:
tags.add(tag)
categories.add(post.metadata["category"])
except KeyError:
print(f"failed to parse {target_file}")
write_tags_cache(list(tags))
write_category_cache(list(categories))
-13
View File
@@ -1,13 +0,0 @@
from utils.ansi import Ansi
def list_exclude(source: list, exclude: list):
return filter(lambda item: item not in exclude, source)
def eprint(msg: str):
print(Ansi.blend([Ansi.FAIL], "error: ") + msg)
def wprint(msg: str):
print(Ansi.blend([Ansi.WARNING], "warn: ") + msg)
+116
View File
@@ -0,0 +1,116 @@
'use client';
import React, { useEffect, useState } from 'react';
import {
Image,
Navbar as NextUINavbar,
NavbarBrand,
NavbarContent,
NavbarItem,
NavbarMenu,
NavbarMenuItem,
NavbarMenuToggle
} from '@nextui-org/react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import ThemeSwitcher from '@/components/ThemeSwitcher';
const entries: { display_name: string; href: string; }[] = [
{
display_name: 'Home',
href: '/'
},
{
display_name: 'About',
href: '/about'
},
{
display_name: 'Blog',
href: '/blog'
}
];
function Navbar() {
const [ scrolled, setScrolled ] = useState(false);
useEffect(() => {
const handleScroll = () => {
const isScrolled = window.scrollY > 0;
setScrolled(isScrolled);
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
return (
<>
<NextUINavbar isBlurred isBordered>
<NavbarContent className="sm:hidden" justify="start">
<NavbarMenuToggle />
</NavbarContent>
<NavbarContent className="sm:hidden pr-3" justify="center">
<Image
src={ 'https://avatars.githubusercontent.com/u/46409277?v=4' }
alt={ 'avatar' }
className={ 'rounded-full h-[3rem]' }
></Image>
<NavbarBrand>
<p className="font-bold text-inherit">{ 'Lumither\'s site' }</p>
</NavbarBrand>
</NavbarContent>
<NavbarContent className="hidden sm:flex gap-4" justify="center">
<Image
src={ 'https://avatars.githubusercontent.com/u/46409277?v=4' }
alt={ 'avatar' }
className={ 'rounded-full h-[3rem]' }
></Image>
<NavbarBrand>
<p className="font-bold text-inherit">{ 'Lumither\'s site' }</p>
</NavbarBrand>
{ entries.map((item, index) => (
// eslint-disable-next-line react-hooks/rules-of-hooks
<NavbarItem key={ index } isActive={ usePathname() === item.href }>
<Link
className="w-full"
href={ item.href }
>
{ item.display_name }
</Link>
</NavbarItem>
)) }
</NavbarContent>
<NavbarContent justify="end">
<NavbarItem>
<ThemeSwitcher />
</NavbarItem>
</NavbarContent>
<NavbarMenu>
{ entries.map((item, index) => (
<NavbarMenuItem key={ index }>
<Link
className="w-full"
href={ item.href }
>
{ item.display_name }
</Link>
</NavbarMenuItem>
)) }
</NavbarMenu>
</NextUINavbar>
</>
);
}
export default Navbar;
+38
View File
@@ -0,0 +1,38 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
import { Switch } from '@nextui-org/react';
import { MdOutlineDarkMode, MdOutlineLightMode } from 'react-icons/md';
function ThemeSwitcher() {
const [ mounted, setMounted ] = useState(false);
const { setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<div>
<Switch
color={ 'default' }
startContent={ <MdOutlineLightMode /> }
endContent={ <MdOutlineDarkMode /> }
onValueChange={ (isSelected) => {
if (isSelected) {
setTheme('light');
} else {
setTheme('dark');
}
} }
></Switch>
</div>
);
}
export default ThemeSwitcher;
-9
View File
@@ -1,9 +0,0 @@
FROM rust:latest
WORKDIR /app
COPY . .
RUN cargo build
CMD cargo r
-13
View File
@@ -1,13 +0,0 @@
# docker/dev.Dockerfile
FROM node:latest
WORKDIR /app
COPY package.json ./
RUN npm install
ENV NEXT_TELEMETRY_DISABLED 1
CMD npm run dev
-10
View File
@@ -1,10 +0,0 @@
FROM rust:latest AS builder
WORKDIR /app
COPY . .
RUN ["cargo", "build", "-r"]
FROM gcr.io/distroless/cc AS production
ARG ARCH
COPY --from=builder /app/target/release/api_server ./api_server
COPY --from=builder /usr/lib/${ARCH}-linux-gnu/libz.so.1 /usr/lib/${ARCH}-linux-gnu/
ENTRYPOINT ["./api_server"]
-15
View File
@@ -1,15 +0,0 @@
FROM node:22 AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs22 AS production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/next.config.mjs ./
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
ENV HOSTNAME="0.0.0.0"
CMD ["./server.js"]
-36
View File
@@ -1,36 +0,0 @@
services:
frontend:
build:
context: ./frontend
dockerfile: ../container/dev/frontend.containerfile
restart: always
ports:
- "3000:3000"
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
env_file:
- ./container/dev/.env
dns:
- 1.1.1.1
backend:
build:
context: ./backend
dockerfile: ../container/dev/backend.containerfile
restart: always
ports:
- "3001:3001"
volumes:
- ./blog_posts_git:/app/blog_posts_git:ro
env_file:
- ./container/dev/.env
postgres:
image: postgres:16.3
ports:
- 5432:5432
env_file:
- ./container/dev/.env
-10
View File
@@ -1,10 +0,0 @@
/node_modules
/.next
/out
/build
.DS_Store
*.pem
.env*.local
-43
View File
@@ -1,43 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# bun
bun.lockb
# ide related
.idea
/package-lock.json
-21
View File
@@ -1,21 +0,0 @@
import React from 'react';
import { Metadata } from 'next';
import { SITE_CONFIG } from '@/src/constants';
export const metadata: Metadata = {
title: `About - ${ SITE_CONFIG.name }`
};
function Layout({
children
}: {
children: React.ReactNode;
}) {
return (
<div className={ 'flex p-7' }>
{ children }
</div>
);
}
export default Layout;
-15
View File
@@ -1,15 +0,0 @@
import React from 'react';
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
import { fetchText } from '@/app/fetch';
const Me = async () => {
return (
<div className={ 'p-5' }>
<MarkdownRenderer>
{ await fetchText('https://raw.githubusercontent.com/lumither/blog-posts/master/about/me.md') }
</MarkdownRenderer>
</div>
);
};
export default Me;
-58
View File
@@ -1,58 +0,0 @@
import { Card } from '@nextui-org/react';
import React from 'react';
import { CardBody } from '@nextui-org/card';
import Link from 'next/link';
const About = () => {
return (
<div className={ `flex flex-col w-full justify-center align-middle h-dvh` }>
<div className={ 'md:relative md:h-[130px] mb-4 md:mb-0' }>
<div className={ 'md:relative md:left-[20%] flex flex-col w-fit' }>
<p className={ 'text-pretty text-3xl' }>{ '讷,远道而来的' }<span
className={ 'break-keep underline decoration-sky-400 dark:decoration-sky-700 decoration-4 underline-offset-0' }>旅人</span>
</p>
<p className={ 'text-pretty md:text-2xl mt-1 md:ml-4' }>{ '愿意坐下来听个故事吗...' }</p>
<p className={ 'hidden md:block text-lg md:ml-8' }>{ '一个关于一位' }<span
className={ 'text-gray-700 dark:text-gray-400 text-sm transition-colors' }>{ '█' }</span>{ '追寻' }<span
className={ 'text-gray-700 dark:text-gray-400 text-sm transition-colors' }>{ '█' }</span>{ ',探索' }<span
className={ 'text-gray-700 dark:text-gray-400 text-sm transition-colors' }>{ '█' }</span>{ '的故事' }
</p>
</div>
</div>
<div className={ `flex justify-center w-full h-[60%] md:h-[40%]` }>
<div
className={ `flex flex-col md:flex-row w-full md:w-[85%] lg:w-[90%] xl:w-[85%] p-4 gap-x-7 gap-y-5` }>
<Card
isHoverable
className={ 'w-full h-[45%] md:h-full' }
as={ Link }
href={ '/about/me' }
isPressable
>
<CardBody>
<div className={ 'h-full flex flex-col justify-center' }>
<p className={ 'text-center text-3xl h-fit' }>关于我</p>
</div>
</CardBody>
</Card>
<Card
isHoverable
className={ 'w-full h-[45%] md:h-full' }
as={ Link }
href={ '/about/website' }
isPressable
>
<CardBody>
<div className={ 'h-full flex flex-col justify-center' }>
<p className={ 'text-center text-3xl h-fit' }>关于这个网站</p>
</div>
</CardBody>
</Card>
</div>
</div>
<p className={ 'text-center' }>*目前还没想到如何设计这一页,先摸鱼会(笑)</p>
</div>
);
};
export default About;
@@ -1,14 +0,0 @@
import React from 'react';
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
import { fetchText } from '@/app/fetch';
const Website = async () => {
return (
<div className={ 'p-5' }>
<MarkdownRenderer>
{ await fetchText('https://raw.githubusercontent.com/lumither/blog-posts/master/about/website.md') }
</MarkdownRenderer>
</div>
);
};
export default Website;
@@ -1,92 +0,0 @@
import React from 'react';
import { Card, CardBody, CardFooter } from '@nextui-org/card';
import { Chip } from '@nextui-org/react';
import Image from 'next/image';
import Link from 'next/link';
import { MdCalendarMonth, MdNorthEast } from 'react-icons/md';
type Props = {
post: JSON
}
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 }` }
className={ `group` }
isPressable
isHoverable
>
{ header_img && <div className={ `w-full overflow h-80 relative` }>
<Image
alt={ 'cover picture' }
src={ header_img }
className={ `w-full object-cover rounded-b-none z-10` }
loading={ 'eager' }
fill
></Image>
<Image
alt={ 'cover picture shadow' }
src={ header_img }
loading={ 'eager' }
className={ `w-full object-cover rounded-b-none scale-y-110 blur-2xl` }
fill
></Image>
</div> }
<CardBody className={ `overflow-visible` }>
<div className={ `flex flex-row justify-between` }>
<div className={ `mx-2 ${ 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>
</div>
<MdNorthEast className={ `mt-1 mr-2` } size={ `40px` } />
</div>
</CardBody>
<CardFooter className={ 'pt-0' }>
<div className={ `flex flex-row flex-wrap justify-between w-full` }>
<div>
<ul className={ `flex flex-row flex-wrap` }>
{
tags.map((tag: string, key: any) => (
<li key={ key } className={ `mx-1 mb-1` }>
<Chip>{ tag }</Chip>
</li>
))
}
</ul>
</div>
<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', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }
</p>
</div>
</div>
</CardFooter>
</Card>
</div>
);
}
export default BlogPostCard;
-1
View File
@@ -1 +0,0 @@
// todo: create component
-37
View File
@@ -1,37 +0,0 @@
'use client';
import React from 'react';
import { Pagination as NextUIPagination } from '@nextui-org/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
const Pagination = ({ total }: {
total: number,
}) => {
const parsePage = Number(useSearchParams().get('page'));
const currPage = parsePage > 0 ? parsePage : 1;
const router = useRouter();
const isMobile = useScreenSizeTrigger('md');
return (
<div className={ 'w-full p-4 pt-6 flex justify-center' }>
<NextUIPagination
initialPage={ currPage }
total={ total }
siblings={ isMobile ? 0 : 1 }
size={ isMobile ? 'sm' : 'md' }
showControls
showShadow
color={ 'default' }
variant={ 'light' }
onChange={ (page) => {
router.push(`?page=${ page }`, { scroll: false });
} }
/>
</div>
);
};
export default Pagination;
-43
View File
@@ -1,43 +0,0 @@
'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';
const PostList = async ({ page }: { page: number }) => {
try {
const res = await fetchNocturneApi(`/blog/get_post_list?page=${ page }`);
const posts = await res.json();
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) => (
<li key={ key } className={ 'w-full' }>
<motion.div
initial={ { y: 20, opacity: 0 } }
animate={ { y: 0, opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.5, delay: key * .2 + .25 } }
>
<BlogPostCard post={ post } />
</motion.div>
</li>)
) }
</ul>
</div>
);
} catch (e: any) {
return (
// todo: use Error.tsx
<Card className={ `w-full` }>
<CardBody>
Fatal: { e.toString() }
</CardBody>
</Card>);
}
};
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'
});
}
-23
View File
@@ -1,23 +0,0 @@
import React from 'react';
import { Metadata } from 'next';
import { SITE_CONFIG } from '@/src/constants';
export const metadata: Metadata = {
title: `Blog - ${ SITE_CONFIG.name }`
};
function Layout({
children
}: {
children: React.ReactNode;
}) {
return (
<div className={ 'flex flex-auto p-7 pl-2 lg:pl-7 w-full justify-center' }>
<main className={ 'w-full' }>
{ children }
</main>
</div>
);
}
export default Layout;
-24
View File
@@ -1,24 +0,0 @@
import PostList from '@/app/(pages)/blog/PostList';
import Pagination from '@/app/(pages)/blog/Pagination';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
async function Blog({
searchParams
}: {
searchParams: Promise<{ [key: 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;
return (
<div className={ 'w-full min-w-full space-y-4' }>
<PostList page={ currPage } />
<Pagination total={ pageCount } />
</div>
);
}
export default Blog;
@@ -1,21 +0,0 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@nextui-org/react';
import { IoArrowBackOutline } from 'react-icons/io5';
const ButtonGoBack = () => {
const router = useRouter();
return (
<Button
startContent={ <IoArrowBackOutline /> }
variant={ 'light' }
onClick={ () => router.back() }
>
{ 'Back' }
</Button>
);
};
export default ButtonGoBack;
@@ -1,104 +0,0 @@
import React from 'react';
import MinifyPostCard from '@/app/(pages)/blog/post/[id]/MinifyPostCard';
import Link from 'next/link';
import { FaCreativeCommons } from 'react-icons/fa6';
type Props = {
// post: any
post_id: string;
adjacent_posts: any
perm_link: string | undefined;
author: string | undefined;
author_link: string | undefined;
first_update: string;
last_update: string | undefined;
cc: string | undefined;
title: string;
}
const Footer = (props: Props) => {
const { adjacent_posts, perm_link, post_id, 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 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';
return (
<div className={ `w-full` }>
<div className={ 'flex transition bg-zinc-300/80 dark:bg-gray-600/25' }>
<div className={ `p-8 w-full overflow-hidden` }>
<div className={ 'relative' }>
<FaCreativeCommons
size={ '160px' }
className={ 'absolute right-0 top-0 -mt-[4.8rem] -mr-16 opacity-30 brightness-50 ' }
/>
</div>
<div className={ 'flex flex-col space-y-4 relative' }>
<div className={ 'z-[2]' }>
<p className={ 'text-xl font-bold' }>{ title }</p>
<Link href={ link }>
<p className={ 'text-sm underline decoration-indigo-500 dark:decoration-sky-500' }>{ link }</p>
</Link>
</div>
<div className={ 'z-[2] flex flex-row flex-wrap gap-x-7 gap-y-2' }>
<div>
<p className={ 'text-lg font-bold' }>Author</p>
<Link href={ author_link }>
<p className={ 'underline decoration-indigo-500 dark:decoration-sky-500' }>{ author }</p>
</Link>
</div>
<div>
<p className={ 'text-lg font-bold' }>Posted on</p>
<p>{ post_date.toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }</p>
</div>
{ update_date &&
<div>
<p className={ 'text-lg font-bold' }>Updated on</p>
<p>{ update_date.toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }</p>
</div>
}
<div>
<p className={ 'text-lg font-bold' }>Published under</p>
<Link href={ `https://creativecommons.org/licenses/${ cc }/4.0/` }>
<p className={ 'underline decoration-indigo-500 dark:decoration-sky-500' }>{ `CC ${ cc.toUpperCase() } 4.0` }</p>
</Link>
</div>
</div>
</div>
</div>
</div>
<div className={ `flex justify-center` }>
<div className={ `w-full shadow-2xl ` }>
<div className={ `flex flex-col md:flex-row w-full` }>
{
next_post &&
<MinifyPostCard post={ next_post } desc={ 'prev' }></MinifyPostCard>
}
{
prev_post &&
<MinifyPostCard post={ prev_post } desc={ 'next' }></MinifyPostCard>
}
</div>
</div>
</div>
</div>
);
};
export default Footer;
@@ -1,80 +0,0 @@
import React from 'react';
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;
header_img: string | undefined;
}
const Headers = (post: Props) => {
return (
<>
{ post.header_img && <div className={ `w-full overflow h-96 relative` }>
<Image
alt={ 'cover picture' }
src={ post.header_img }
className={ `w-full object-cover rounded-b-none z-[5]` }
fill
></Image>
<Image
alt={ 'cover picture' }
src={ post.header_img }
className={ `w-full relative scale-110 blur-3xl object-cover rounded-b-none` }
fill
></Image>
</div> }
<div
className={ post.header_img ?
'mx-8 -mt-16 backdrop-blur transition bg-zinc-300/80 dark:bg-gray-600/25 rounded-2xl z-30'
: 'mx-6 mt-6'
}>
<div className={ post.header_img ? `p-4` : 'p-4 pt-2' }>
<Chip className={ `mb-2` } radius={ `sm` } color={ `secondary` }
variant={ `solid` }>{ post.category }</Chip>
<p className={ `text-4xl font-bold` }> { post.title }</p>
<p className={ `text-xl` }> { post.subtitle }</p>
</div>
<div className={ `mx-2 -mt-2 mb-2 flex flex-row flex-wrap justify-between w-full` }>
<div>
<ul className={ `flex flex-row flex-wrap` }>
{
post.tags.map((tag: string, key: any) => (
<li key={ key } className={ `mx-1 mb-1` }>
<Chip>{ tag }</Chip>
</li>
))
}
</ul>
</div>
<div className={ `flex flex-row place-content-end mb-1 mr-6 items-center mx-1` }>
<MdCalendarMonth />
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
{ (new Date(post.first_update)).toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }
</p>
</div>
</div>
</div>
</>
);
};
export default Headers;
@@ -1,58 +0,0 @@
import React from 'react';
import { Card, CardFooter, CardHeader } from '@nextui-org/card';
import Image from 'next/image';
import Link from 'next/link';
type Props = {
post: any
desc: string
}
const MinifyPostCard = (props: Props) => {
const { post, desc } = props;
const img_url = post['header_img'] as string;
return (
<div className={ 'w-full transition hover:brightness-75' }>
<Card isFooterBlurred
className="w-full h-[300px] shadow-none border-0 rounded-none overflow-hidden"
as={ Link }
isPressable
href={ `/blog/post/${ post['id'] }` }
>
<CardHeader className="absolute z-10 top-1 flex-col items-start">
<p className={ `text-tiny uppercase font-bold ${ img_url ? 'text-white/60' : 'text-black/60 dark:text-white/60' }` }>{ desc }</p>
</CardHeader>
{ img_url &&
<div className={ 'h-full' }>
<div className={ 'relative h-[300px] overflow-hidden' }>
<Image
className={ 'object-cover object-center ' }
src={ img_url }
alt={ post.title }
fill
/>
</div>
<CardFooter
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>
</div>
</CardFooter>
</div>
}
{ !img_url &&
<div className={ 'h-full grid p-6 place-content-center' }>
<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>
</div>
</div>
}
</Card>
</div>
);
};
export default MinifyPostCard;
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more