Compare commits

..

8 Commits

Author SHA1 Message Date
Bauke 4b3863e0d1
Big commit that adds and changes a bunch of stuff.
A lot of this will be changed around again anyway.
2022-10-07 15:27:43 +02:00
Bauke 99b24d0eac
Add line and point series features for Plotters. 2022-10-07 15:06:09 +02:00
Bauke 5f5893a408
Add public directory to gitignore. 2022-10-07 15:05:57 +02:00
Bauke 4a592915fd
Add Grass. 2022-10-06 23:54:53 +02:00
Bauke 51ef13f5d6
Add Stylelint configuration. 2022-10-06 23:41:17 +02:00
Bauke aba5685898
Copy Askama configuration. 2022-10-06 19:29:40 +02:00
Bauke 47a0b22503
Add Plotters dependency. 2022-10-06 19:14:17 +02:00
Bauke 958e04c47b
Add Askama templates with base.html. 2022-10-06 18:53:45 +02:00
19 changed files with 836 additions and 104 deletions

7
.gitignore vendored
View File

@ -7,3 +7,10 @@ coverage/
# Environment configuration # Environment configuration
.env .env
# NodeJS files
node_modules/
pnpm-lock.yaml
# Default web build output directory
public/

249
Cargo.lock generated
View File

@ -139,6 +139,54 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6"
[[package]]
name = "askama"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb98f10f371286b177db5eeb9a6e5396609555686a35e1d4f7b9a9c6d8af0139"
dependencies = [
"askama_derive",
"askama_escape",
"askama_shared",
]
[[package]]
name = "askama_derive"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87bf87e6e8b47264efa9bde63d6225c6276a52e05e91bf37eaa8afd0032d6b71"
dependencies = [
"askama_shared",
"proc-macro2",
"syn",
]
[[package]]
name = "askama_escape"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
[[package]]
name = "askama_shared"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf722b94118a07fcbc6640190f247334027685d4e218b794dbfe17c32bf38ed0"
dependencies = [
"askama_escape",
"humansize",
"mime",
"mime_guess",
"nom 7.1.1",
"num-traits",
"percent-encoding",
"proc-macro2",
"quote",
"serde",
"syn",
"toml",
]
[[package]] [[package]]
name = "async-attributes" name = "async-attributes"
version = "1.1.2" version = "1.1.2"
@ -420,6 +468,12 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
[[package]]
name = "beef"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -519,6 +573,21 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "clap"
version = "2.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
dependencies = [
"ansi_term",
"atty",
"bitflags",
"strsim 0.8.0",
"textwrap 0.11.0",
"unicode-width",
"vec_map",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "3.2.22" version = "3.2.22"
@ -531,9 +600,9 @@ dependencies = [
"clap_lex 0.2.4", "clap_lex 0.2.4",
"indexmap", "indexmap",
"once_cell", "once_cell",
"strsim", "strsim 0.10.0",
"termcolor", "termcolor",
"textwrap", "textwrap 0.15.1",
] ]
[[package]] [[package]]
@ -547,7 +616,7 @@ dependencies = [
"clap_derive 4.0.10", "clap_derive 4.0.10",
"clap_lex 0.3.0", "clap_lex 0.3.0",
"once_cell", "once_cell",
"strsim", "strsim 0.10.0",
"termcolor", "termcolor",
] ]
@ -595,6 +664,12 @@ dependencies = [
"os_str_bytes", "os_str_bytes",
] ]
[[package]]
name = "codemap"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24"
[[package]] [[package]]
name = "color-eyre" name = "color-eyre"
version = "0.6.2" version = "0.6.2"
@ -799,7 +874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"hashbrown", "hashbrown 0.12.3",
"lock_api", "lock_api",
"once_cell", "once_cell",
"parking_lot_core 0.9.3", "parking_lot_core 0.9.3",
@ -1183,6 +1258,34 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "grass"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5bedc3dbd71dcdd41900e1f58e4d431fa69dd67c04ae1f86ae1a0339edd849"
dependencies = [
"beef",
"clap 2.34.0",
"codemap",
"indexmap",
"lasso",
"num-bigint",
"num-rational",
"num-traits",
"once_cell",
"phf 0.9.0",
"rand 0.8.5",
]
[[package]]
name = "hashbrown"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
dependencies = [
"ahash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@ -1198,7 +1301,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa" checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa"
dependencies = [ dependencies = [
"hashbrown", "hashbrown 0.12.3",
] ]
[[package]] [[package]]
@ -1333,6 +1436,12 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
[[package]]
name = "humansize"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02296996cb8796d7c6e3bc2d9211b7802812d36999a51bb754123ead7d37d026"
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.50" version = "0.1.50"
@ -1369,7 +1478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"hashbrown", "hashbrown 0.12.3",
] ]
[[package]] [[package]]
@ -1426,6 +1535,15 @@ dependencies = [
"log", "log",
] ]
[[package]]
name = "lasso"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8647c8a01e5f7878eacb2c323c4c949fdb63773110f0686c7810769874b7e0a"
dependencies = [
"hashbrown 0.11.2",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.4.0" version = "1.4.0"
@ -1606,6 +1724,18 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-rational"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0"
dependencies = [
"autocfg",
"num-bigint",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.15" version = "0.2.15"
@ -1762,11 +1892,22 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
dependencies = [ dependencies = [
"phf_macros", "phf_macros 0.8.0",
"phf_shared 0.8.0", "phf_shared 0.8.0",
"proc-macro-hack", "proc-macro-hack",
] ]
[[package]]
name = "phf"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2ac8b67553a7ca9457ce0e526948cad581819238f4a9d1ea74545851fa24f37"
dependencies = [
"phf_macros 0.9.0",
"phf_shared 0.9.0",
"proc-macro-hack",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.10.1" version = "0.10.1"
@ -1806,6 +1947,16 @@ dependencies = [
"rand 0.7.3", "rand 0.7.3",
] ]
[[package]]
name = "phf_generator"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43f3220d96e0080cc9ea234978ccd80d904eafb17be31bb0f76daaea6493082"
dependencies = [
"phf_shared 0.9.0",
"rand 0.8.5",
]
[[package]] [[package]]
name = "phf_generator" name = "phf_generator"
version = "0.10.0" version = "0.10.0"
@ -1830,6 +1981,20 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "phf_macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b706f5936eb50ed880ae3009395b43ed19db5bff2ebd459c95e7bf013a89ab86"
dependencies = [
"phf_generator 0.9.1",
"phf_shared 0.9.0",
"proc-macro-hack",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.8.0" version = "0.8.0"
@ -1839,6 +2004,15 @@ dependencies = [
"siphasher", "siphasher",
] ]
[[package]]
name = "phf_shared"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68318426de33640f02be62b4ae8eb1261be2efbc337b60c54d845bf4484e0d9"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.10.0" version = "0.10.0"
@ -1880,6 +2054,34 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "plotters"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2538b639e642295546c50fcd545198c9d64ee2a38620a628724a3b266d5fbf97"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "193228616381fecdc1224c62e96946dfbc73ff4384fba576e052ff8c1bea8142"
[[package]]
name = "plotters-svg"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9a81d2759aae1dae668f783c308bc5c8ebd191ff4184aaa1b37f65a6ae5a56f"
dependencies = [
"plotters-backend",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "2.3.0" version = "2.3.0"
@ -2865,6 +3067,12 @@ dependencies = [
"unicode-normalization", "unicode-normalization",
] ]
[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.10.0" version = "0.10.0"
@ -2932,6 +3140,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "textwrap" name = "textwrap"
version = "0.15.1" version = "0.15.1"
@ -2988,11 +3205,14 @@ dependencies = [
name = "tildes-statistics" name = "tildes-statistics"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"askama",
"async-std", "async-std",
"chrono", "chrono",
"clap 4.0.10", "clap 4.0.10",
"color-eyre", "color-eyre",
"dotenvy", "dotenvy",
"grass",
"plotters",
"sea-orm", "sea-orm",
"sea-orm-migration", "sea-orm-migration",
"surf", "surf",
@ -3094,6 +3314,15 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "toml"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.36" version = "0.1.36"
@ -3281,6 +3510,12 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.4" version = "0.9.4"

View File

@ -12,10 +12,12 @@ name = "tildes-statistics"
path = "source/main.rs" path = "source/main.rs"
[dependencies] [dependencies]
askama = "0.11.1"
async-std = "1.12.0" async-std = "1.12.0"
chrono = "0.4.22" chrono = "0.4.22"
color-eyre = "0.6.2" color-eyre = "0.6.2"
dotenvy = "0.15.5" dotenvy = "0.15.5"
grass = "0.11.2"
sea-orm-migration = "0.9.3" sea-orm-migration = "0.9.3"
tracing = "0.1.36" tracing = "0.1.36"
@ -23,6 +25,11 @@ tracing = "0.1.36"
features = ["derive"] features = ["derive"]
version = "4.0.10" version = "4.0.10"
[dependencies.plotters]
default-features = false
features = ["line_series", "point_series", "svg_backend"]
version = "0.3.4"
[dependencies.sea-orm] [dependencies.sea-orm]
features = ["macros", "mock", "runtime-async-std-rustls", "sqlx-postgres"] features = ["macros", "mock", "runtime-async-std-rustls", "sqlx-postgres"]
version = "0.9.3" version = "0.9.3"

View File

@ -5,8 +5,8 @@ RUN USER=root cargo new --bin tildes-statistics
WORKDIR /tildes-statistics WORKDIR /tildes-statistics
RUN mv src source RUN mv src source
# Copy the Cargo files and build in release, caching the dependencies. # Copy the configuration files and build in release, caching the dependencies.
COPY Cargo.* . COPY Cargo.lock Cargo.toml askama.toml .
RUN cargo build --release RUN cargo build --release
# Then copy our code. This way when only the source code changes, the # Then copy our code. This way when only the source code changes, the

2
askama.toml Normal file
View File

@ -0,0 +1,2 @@
[general]
dirs = ["source/templates"]

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"private": "true",
"scripts": {
"test": "stylelint 'source/**/*.scss'"
},
"dependencies": {
"modern-normalize": "^1.1.0"
},
"devDependencies": {
"stylelint": "^14.12.1",
"stylelint-config-standard-scss": "^5.0.0"
},
"stylelint": {
"extends": [
"stylelint-config-standard-scss"
],
"rules": {
"string-quotes": "single"
}
}
}

116
source/charts/mod.rs Normal file
View File

@ -0,0 +1,116 @@
//! All code for drawing the [`plotters`] charts.
use {
async_std::{fs::create_dir_all, path::PathBuf},
color_eyre::Result,
plotters::prelude::*,
};
use crate::group_data::GroupDataModel;
const BACKGROUND_1: RGBColor = RGBColor(17, 17, 17);
const BACKGROUND_2: RGBColor = RGBColor(0, 0, 0);
const FOREGROUND: RGBColor = RGBColor(255, 255, 255);
const ACCENT_1: RGBColor = RGBColor(255, 0, 255);
/// The chart for the user count.
#[derive(Debug)]
pub struct UserCountChart {
/// The groups to use for user counts.
pub groups: Vec<GroupDataModel>,
}
impl UserCountChart {
/// Render the chart and write it to file.
pub async fn render(&self, parent: &PathBuf, group_name: &str) -> Result<()> {
let parent = parent.join("charts");
create_dir_all(&parent).await?;
let (mut datapoints, mut min_count, mut max_count) = (vec![], i64::MAX, 0);
for (index, group) in self.groups.iter().enumerate() {
datapoints.push(((index + 1) as isize, group.subscribers));
if group.subscribers > max_count {
max_count = group.subscribers;
}
if group.subscribers < min_count {
min_count = group.subscribers;
}
}
let datapoints_len = datapoints.len() as isize;
let min_count = min_count - 10;
let max_count = max_count + 10;
let path = parent.join("user-count.svg");
let chart_root = SVGBackend::new(&path, (1280, 720)).into_drawing_area();
chart_root.fill(&BACKGROUND_1)?;
let text_style =
|font_size: i32| ("sans-serif", font_size).into_font().color(&FOREGROUND);
let chart_root = chart_root
.margin(20, 20, 20, 20)
.titled("Tildes User Count", text_style(30))?;
chart_root.fill(&BACKGROUND_1)?;
let mut chart = ChartBuilder::on(&chart_root)
.caption(
format!("Using the {group_name} subscriber count."),
text_style(20),
)
.x_label_area_size(40)
.y_label_area_size(40)
.margin(10)
.build_cartesian_2d(0..(datapoints_len + 1), min_count..max_count)?;
chart
.configure_mesh()
.x_labels(datapoints.len() + 2)
.x_label_formatter(&|x| format!("{:0}", datapoints_len - x))
.x_desc("N days ago")
.y_labels(5)
.y_label_formatter(&|y| format!("{y:0}"))
.label_style(text_style(20))
.axis_style(&BACKGROUND_2)
.light_line_style(&BACKGROUND_2)
.bold_line_style(&BACKGROUND_1)
.draw()?;
chart
.draw_series(LineSeries::new(
datapoints.clone(),
ACCENT_1.stroke_width(2),
))?
.label("User Count")
.legend(|(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], ACCENT_1.stroke_width(4))
});
chart.draw_series(PointSeries::of_element(
datapoints,
5,
&ACCENT_1,
&|(x, y), size, style| {
EmptyElement::at((x, y))
+ Circle::new((0, 0), size, style.filled())
+ Text::new(
{
if (x - 1) % 2 != 0 {
String::new()
} else {
format!("{:0}", y)
}
},
(-10, 15),
text_style(20),
)
},
))?;
Ok(())
}
}

View File

@ -1,6 +1,7 @@
//! All CLI-related code. //! All CLI-related code.
use { use {
async_std::path::PathBuf,
chrono::NaiveDate, chrono::NaiveDate,
clap::{Parser, Subcommand}, clap::{Parser, Subcommand},
}; };
@ -43,6 +44,13 @@ pub enum MainSubcommands {
#[command(subcommand)] #[command(subcommand)]
command: SnapshotSubcommands, command: SnapshotSubcommands,
}, },
/// Website management.
Web {
/// Website management.
#[command(subcommand)]
command: WebSubcommands,
},
} }
/// Migrate subcommands. /// Migrate subcommands.
@ -86,3 +94,14 @@ pub enum SnapshotSubcommands {
date: Option<NaiveDate>, date: Option<NaiveDate>,
}, },
} }
/// Website subcommands.
#[derive(Debug, Subcommand)]
pub enum WebSubcommands {
/// Build the website.
Build {
/// The output directory for the website files.
#[clap(short, long, default_value = "public")]
output: PathBuf,
},
}

View File

@ -1,15 +1,21 @@
//! All logic for running the CLI. //! All logic for running the CLI.
use { use {
clap::Parser, color_eyre::Result, sea_orm_migration::MigratorTrait, async_std::fs::create_dir_all, clap::Parser, color_eyre::Result,
tracing::info, sea_orm_migration::MigratorTrait, tracing::info,
}; };
use crate::{ use crate::{
cli::{Cli, MainSubcommands, MigrateSubcommands, SnapshotSubcommands}, charts::UserCountChart,
group_data::get_all_by_snapshot, cli::{
Cli, MainSubcommands, MigrateSubcommands, SnapshotSubcommands,
WebSubcommands,
},
group_data::GroupDataModel,
migrations::Migrator, migrations::Migrator,
snapshots::{self, get_by_date}, scss::generate_css,
snapshots::SnapshotModel,
templates::HomeTemplate,
utilities::{create_db, today}, utilities::{create_db, today},
}; };
@ -43,18 +49,20 @@ pub async fn run() -> Result<()> {
command: snapshot_command, command: snapshot_command,
} => match snapshot_command { } => match snapshot_command {
SnapshotSubcommands::Create { force } => { SnapshotSubcommands::Create { force } => {
snapshots::create(&db, force).await?; SnapshotModel::create(&db, force).await?;
} }
SnapshotSubcommands::List {} => { SnapshotSubcommands::List {} => {
for snapshot in snapshots::get_all(&db).await? { for snapshot in SnapshotModel::get_all(&db).await? {
info!("Snapshot {snapshot:?}") info!("Snapshot {snapshot:?}")
} }
} }
SnapshotSubcommands::Show { date } => { SnapshotSubcommands::Show { date } => {
let date = date.unwrap_or_else(today); let date = date.unwrap_or_else(today);
let snapshot = if let Some(snapshot) = get_by_date(&db, date).await? { let snapshot = if let Some(snapshot) =
SnapshotModel::get_by_date(&db, date).await?
{
info!("Snapshot {snapshot:?}"); info!("Snapshot {snapshot:?}");
snapshot snapshot
} else { } else {
@ -62,8 +70,8 @@ pub async fn run() -> Result<()> {
return Ok(()); return Ok(());
}; };
let groups = get_all_by_snapshot(&db, &snapshot).await?; for group in GroupDataModel::get_all_by_snapshot(&db, &snapshot).await?
for group in groups { {
info!( info!(
id = group.id, id = group.id,
name = group.name, name = group.name,
@ -72,6 +80,35 @@ pub async fn run() -> Result<()> {
} }
} }
}, },
MainSubcommands::Web {
command: web_command,
} => match web_command {
WebSubcommands::Build { output } => {
let user_count_group =
if let Some(snapshot) = SnapshotModel::get_most_recent(&db).await? {
GroupDataModel::get_highest_subscribers(&db, &snapshot).await?
} else {
None
};
create_dir_all(&output).await?;
HomeTemplate::new(
user_count_group.as_ref().map(|group| group.subscribers),
)
.render_to_file(&output)
.await?;
generate_css(&output).await?;
if let Some(group) = user_count_group {
let groups =
GroupDataModel::get_n_most_recent(&db, 30, &group.name).await?;
UserCountChart { groups }
.render(&output, &group.name)
.await?;
}
}
},
} }
Ok(()) Ok(())

View File

@ -1,14 +1,55 @@
//! All logic for group datas. //! All logic for group datas.
use {color_eyre::Result, sea_orm::prelude::*}; use {
color_eyre::Result,
sea_orm::{prelude::*, QueryOrder, QuerySelect},
};
use crate::entities::{group_data, snapshot}; pub use crate::{
entities::group_data::{
ActiveModel as GroupDataActiveModel, Column as GroupDataColumn,
Entity as GroupDataEntity, Model as GroupDataModel,
},
snapshots::SnapshotModel,
};
/// Get all group datas from a given snapshot. impl GroupDataModel {
pub async fn get_all_by_snapshot( /// Get all group datas from a given snapshot.
db: &DatabaseConnection, pub async fn get_all_by_snapshot(
snapshot: &snapshot::Model, db: &DatabaseConnection,
) -> Result<Vec<group_data::Model>> { snapshot: &SnapshotModel,
let groups = snapshot.find_related(group_data::Entity).all(db).await?; ) -> Result<Vec<Self>> {
Ok(groups) let groups = snapshot.find_related(GroupDataEntity).all(db).await?;
Ok(groups)
}
/// Get the group with the highest subscriber count from a given snapshot.
pub async fn get_highest_subscribers(
db: &DatabaseConnection,
snapshot: &SnapshotModel,
) -> Result<Option<Self>> {
let group = snapshot
.find_related(GroupDataEntity)
.order_by_desc(GroupDataColumn::Subscribers)
.one(db)
.await?;
Ok(group)
}
/// Get the N most recently saved group datas from a given group name.
pub async fn get_n_most_recent(
db: &DatabaseConnection,
amount: u64,
name: &str,
) -> Result<Vec<Self>> {
let groups = GroupDataEntity::find()
.order_by_desc(GroupDataColumn::SnapshotId)
.filter(GroupDataColumn::Name.eq(name))
.limit(amount)
.all(db)
.await?;
Ok(groups)
}
} }

View File

@ -11,10 +11,13 @@ use {
tracing_subscriber::filter::{EnvFilter, LevelFilter}, tracing_subscriber::filter::{EnvFilter, LevelFilter},
}; };
pub mod charts;
pub mod cli; pub mod cli;
pub mod group_data; pub mod group_data;
pub mod migrations; pub mod migrations;
pub mod scss;
pub mod snapshots; pub mod snapshots;
pub mod templates;
pub mod utilities; pub mod utilities;
/// The entities code is auto-generated using `sea-orm-cli`. With a database /// The entities code is auto-generated using `sea-orm-cli`. With a database

47
source/scss/common.scss Normal file
View File

@ -0,0 +1,47 @@
html {
font-size: 62.5%;
}
body {
--small-spacing: 4px;
--medium-spacing: 8px;
--large-spacing: 16px;
--background-1: #222;
--background-2: #111;
--foreground-1: #fff;
--anchor-1: #f0f;
--anchor-2: #000;
background-color: var(--background-1);
color: var(--foreground-1);
font-size: 2rem;
}
a,
a:visited {
color: var(--anchor-1);
text-decoration: none;
&:hover {
background-color: var(--anchor-1);
color: var(--anchor-2);
}
}
h1,
h2,
h3,
p,
ol,
li {
margin: 0;
padding: 0;
}
.bold {
font-weight: bold;
}
.underline {
text-decoration: underline;
}

35
source/scss/index.scss Normal file
View File

@ -0,0 +1,35 @@
@mixin responsive-container($breakpoint) {
margin-left: auto;
margin-right: auto;
width: $breakpoint;
@media (max-width: $breakpoint) {
width: 100%;
}
}
.page-header,
.page-main,
.page-footer {
@include responsive-container(1200px);
margin-bottom: var(--large-spacing);
padding: var(--large-spacing);
}
.page-header {
border-bottom: 4px solid var(--background-2);
}
.page-main {
h2 {
margin-bottom: var(--medium-spacing);
}
}
.page-footer {
background-color: var(--background-2);
display: flex;
flex-direction: column;
gap: var(--large-spacing);
}

40
source/scss/mod.rs Normal file
View File

@ -0,0 +1,40 @@
//! All SCSS files.
use {
async_std::{
fs::{create_dir_all, write},
path::PathBuf,
},
color_eyre::{eyre::Context, Result},
};
const MODERN_NORMALIZE_CSS: &str =
include_str!("../../node_modules/modern-normalize/modern-normalize.css");
/// Generate the CSS files and write them.
pub async fn generate_css(parent: &PathBuf) -> Result<()> {
let parent = parent.join("css");
create_dir_all(&parent).await?;
let render = |scss: &str| -> Result<String> {
grass::from_string(scss.to_string(), &grass::Options::default())
.wrap_err("Failed SCSS render")
};
let css_to_create = vec![
("modern-normalize.css", MODERN_NORMALIZE_CSS, false),
("common.css", include_str!("common.scss"), true),
("index.css", include_str!("index.scss"), true),
];
for (file, css, is_scss) in css_to_create {
let path = parent.join(file);
if is_scss {
write(path, render(css)?).await?;
} else {
write(path, css).await?;
}
}
Ok(())
}

View File

@ -8,69 +8,71 @@ use {
}; };
use crate::{ use crate::{
entities::{group_data, snapshot}, group_data::{GroupDataActiveModel, GroupDataEntity},
snapshots::get_by_date, snapshots::{SnapshotActiveModel, SnapshotModel},
utilities::{create_http_client, download_html, today}, utilities::{create_http_client, download_html, today},
}; };
/// Create a snapshot for today. impl SnapshotModel {
pub async fn create(db: &DatabaseConnection, force: bool) -> Result<()> { /// Create a snapshot for today.
let snapshot_date = today(); pub async fn create(db: &DatabaseConnection, force: bool) -> Result<()> {
match (force, get_by_date(db, snapshot_date).await?) { let snapshot_date = today();
(true, Some(existing)) => { match (force, Self::get_by_date(db, snapshot_date).await?) {
info!("Removing existing snapshot {:?}", existing); (true, Some(existing)) => {
existing.delete(db).await?; info!("Removing existing snapshot {:?}", existing);
} existing.delete(db).await?;
}
(false, Some(existing)) => { (false, Some(existing)) => {
info!("Snapshot for today already exists"); info!("Snapshot for today already exists");
info!("Use --force to override snapshot {:?}", existing); info!("Use --force to override snapshot {:?}", existing);
return Ok(()); return Ok(());
} }
(_, None) => (), (_, None) => (),
}; };
let transaction = db.begin().await?; let transaction = db.begin().await?;
let snapshot = snapshot::ActiveModel { let snapshot = SnapshotActiveModel {
date: Set(snapshot_date), date: Set(snapshot_date),
..Default::default()
}
.insert(&transaction)
.await?;
info!("Scraping data for snapshot {:?}", snapshot);
let http = create_http_client()?;
let group_list = GroupList::from_html(
&download_html(&http, "https://tildes.net/groups").await?,
)?;
let mut groups_to_insert = vec![];
for summary in group_list.summaries {
debug!(summary = ?summary);
let group = Group::from_html(
&download_html(&http, format!("https://tildes.net/{}", summary.name))
.await?,
)?;
debug!(group = ?group);
groups_to_insert.push(group_data::ActiveModel {
description: Set(group.description),
name: Set(group.name),
snapshot_id: Set(snapshot.id),
subscribers: Set(group.subscribers.into()),
..Default::default() ..Default::default()
}); }
} .insert(&transaction)
info!("Inserting {} groups", groups_to_insert.len());
group_data::Entity::insert_many(groups_to_insert)
.exec(&transaction)
.await?; .await?;
transaction.commit().await?; info!("Scraping data for snapshot {:?}", snapshot);
Ok(()) let http = create_http_client()?;
let group_list = GroupList::from_html(
&download_html(&http, "https://tildes.net/groups").await?,
)?;
let mut groups_to_insert = vec![];
for summary in group_list.summaries {
debug!(summary = ?summary);
let group = Group::from_html(
&download_html(&http, format!("https://tildes.net/{}", summary.name))
.await?,
)?;
debug!(group = ?group);
groups_to_insert.push(GroupDataActiveModel {
description: Set(group.description),
name: Set(group.name),
snapshot_id: Set(snapshot.id),
subscribers: Set(group.subscribers.into()),
..Default::default()
});
}
info!("Inserting {} groups", groups_to_insert.len());
GroupDataEntity::insert_many(groups_to_insert)
.exec(&transaction)
.await?;
transaction.commit().await?;
Ok(())
}
} }

View File

@ -5,29 +5,44 @@ use {
sea_orm::{prelude::*, QueryOrder}, sea_orm::{prelude::*, QueryOrder},
}; };
use crate::entities::snapshot;
mod create; mod create;
pub use create::create; pub use crate::entities::snapshot::{
ActiveModel as SnapshotActiveModel, Column as SnapshotColumn,
Entity as SnapshotEntity, Model as SnapshotModel,
};
/// Get a snapshot for a given date. impl SnapshotModel {
pub async fn get_by_date( /// Get a snapshot for a given date.
db: &DatabaseConnection, pub async fn get_by_date(
date: ChronoDate, db: &DatabaseConnection,
) -> Result<Option<snapshot::Model>> { date: ChronoDate,
let existing = snapshot::Entity::find() ) -> Result<Option<Self>> {
.filter(snapshot::Column::Date.eq(date)) let existing = SnapshotEntity::find()
.order_by_desc(snapshot::Column::Date) .filter(SnapshotColumn::Date.eq(date))
.one(db) .order_by_desc(SnapshotColumn::Date)
.await?; .one(db)
.await?;
Ok(existing) Ok(existing)
} }
/// Get all snapshots. /// Get all snapshots.
pub async fn get_all(db: &DatabaseConnection) -> Result<Vec<snapshot::Model>> { pub async fn get_all(db: &DatabaseConnection) -> Result<Vec<Self>> {
let snapshots = snapshot::Entity::find().all(db).await?; let snapshots = SnapshotEntity::find().all(db).await?;
Ok(snapshots) Ok(snapshots)
}
/// Get the most recent snapshot.
pub async fn get_most_recent(
db: &DatabaseConnection,
) -> Result<Option<Self>> {
let snapshot = SnapshotEntity::find()
.order_by_desc(SnapshotColumn::Date)
.one(db)
.await?;
Ok(snapshot)
}
} }

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }}</title>
<link rel="stylesheet" href="/css/modern-normalize.css">
<link rel="stylesheet" href="/css/common.css">
{% block head %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block head %}
<link rel="stylesheet" href="/css/index.css">
{% endblock %}
{% block body %}
<header class="page-header">
<h1>Tildes Statistics</h1>
</header>
<main class="page-main">
<h2>General</h2>
<p>
There are currently an
<abbr title="Based on the group with the highest subscriber count.">estimated</abbr>
<span class="underline">{{ user_count }}</span>
registered users on Tildes.
</p>
<img src="/charts/user-count.svg" alt="User Count Chart">
</main>
<footer class="page-footer">
<p>
Last generated on
<time class="underline" datetime="{{ today }}">{{- today -}}</time>.
</p>
<p>
&copy; Code
<a href="https://git.bauke.xyz/Bauke/tildes-statistics">AGPL-3.0-or-later</a>,
charts & data
<a href="https://creativecommons.org/licenses/by-nc/4.0/">CC BY-NC 4.0</a>.
</p>
<p class="bold">
Consider joining <a href="https://tildes.net">Tildes</a>, a non-profit
community site driven by its users' interests.
</p>
</footer>
{% endblock %}

44
source/templates/mod.rs Normal file
View File

@ -0,0 +1,44 @@
//! All HTML templates.
use {
askama::Template,
async_std::{fs::write, path::PathBuf},
chrono::NaiveDate,
color_eyre::Result,
};
use crate::utilities::today;
/// The template for the home page.
#[derive(Template)]
#[template(path = "index.html")]
pub struct HomeTemplate {
/// The string for the `<title>` element.
pub page_title: String,
/// The date of today's snapshot.
pub today: NaiveDate,
/// The user count from the group with the most subscribers.
pub user_count: String,
}
impl HomeTemplate {
/// Create a new [`HomeTemplate`].
pub fn new(user_count: Option<i64>) -> Self {
Self {
page_title: "Tildes Statistics".to_string(),
today: today(),
user_count: user_count
.map(|n| n.to_string())
.unwrap_or_else(|| "unknown".to_string()),
}
}
/// Render the template and write it to file.
pub async fn render_to_file(&self, parent: &PathBuf) -> Result<()> {
write(parent.join("index.html"), self.render()?).await?;
Ok(())
}
}