Working database submission display

rust
Elnu 1 year ago
parent b20fa28198
commit e2a35a271b

43
Cargo.lock generated

@ -2410,6 +2410,28 @@ dependencies = [
"proc-macro2 1.0.59", "proc-macro2 1.0.59",
] ]
[[package]]
name = "r2d2"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
dependencies = [
"log 0.4.18",
"parking_lot",
"scheduled-thread-pool",
]
[[package]]
name = "r2d2_sqlite"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99f31323d6161385f385046738df520e0e8694fa74852d35891fc0be08348ddc"
dependencies = [
"r2d2",
"rusqlite",
"uuid",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.5"
@ -2885,6 +2907,15 @@ dependencies = [
"windows-sys 0.42.0", "windows-sys 0.42.0",
] ]
[[package]]
name = "scheduled-thread-pool"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
dependencies = [
"parking_lot",
]
[[package]] [[package]]
name = "scoped-tls" name = "scoped-tls"
version = "1.0.1" version = "1.0.1"
@ -3246,6 +3277,8 @@ dependencies = [
"dotenv", "dotenv",
"gettext", "gettext",
"poise", "poise",
"r2d2",
"r2d2_sqlite",
"reqwest", "reqwest",
"rocket 0.5.0-rc.3", "rocket 0.5.0-rc.3",
"rocket_contrib", "rocket_contrib",
@ -3830,6 +3863,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]]
name = "uuid"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d023da39d1fde5a8a3fe1f3e01ca9632ada0a63e9797de55a879d6e2236277be"
dependencies = [
"getrandom",
"rand",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.0" version = "0.1.0"

@ -14,6 +14,8 @@ derive_more = "0.99.17"
dotenv = "0.15.0" dotenv = "0.15.0"
gettext = "0.4.0" gettext = "0.4.0"
poise = "0.5.5" poise = "0.5.5"
r2d2 = "0.8.10"
r2d2_sqlite = "0.22.0"
reqwest = "0.11.18" reqwest = "0.11.18"
rocket = { version = "=0.5.0-rc.3", features = ["secrets", "json"] } rocket = { version = "=0.5.0-rc.3", features = ["secrets", "json"] }
rocket_contrib = { version = "0.4.11", features = ["templates"] } rocket_contrib = { version = "0.4.11", features = ["templates"] }

@ -52,8 +52,7 @@ async fn main() {
return; return;
} }
let http = http(); let http = http();
Database::new(false) load_database()
.expect("Failed to load database")
.load_legacy(&http).await .load_legacy(&http).await
.expect("Failed to load legacy submissions"); .expect("Failed to load legacy submissions");
}, },
@ -64,6 +63,10 @@ async fn main() {
} }
} }
fn load_database() -> Database {
Database::new(false).expect("Failed to load database")
}
fn http() -> Http { fn http() -> Http {
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
Http::new(&token) Http::new(&token)
@ -81,11 +84,13 @@ async fn rocket() -> Result<Rocket<Ignite>, rocket::Error> {
rocket::custom(config) rocket::custom(config)
.manage(Settings::new(&http).await.unwrap()) .manage(Settings::new(&http).await.unwrap())
.manage(http) .manage(http)
.manage(load_database())
.mount( .mount(
"/", "/",
routes![get_challenge, get_guilds, login, post_login, success, logout, testing], routes![get_challenge, get_guilds, login, post_login, success, logout, testing],
) )
.mount("/css", FileServer::from(relative!("styles/css"))) .mount("/css", FileServer::from(relative!("styles/css")))
.mount("/", FileServer::from(relative!("assets")).rank(2))
.mount("/", FileServer::from(relative!("static")).rank(1)) .mount("/", FileServer::from(relative!("static")).rank(1))
.attach(Template::custom(move |engines| { .attach(Template::custom(move |engines| {
use tera::Value; use tera::Value;

@ -1,18 +1,32 @@
use std::collections::HashSet;
use std::{fs::File, io::Read, collections::HashMap, path::Path}; use std::{fs::File, io::Read, collections::HashMap, path::Path};
use poise::serenity_prelude::Http; use poise::serenity_prelude::Http;
use rusqlite::{Connection, params}; use r2d2::{Pool, PooledConnection};
use r2d2_sqlite::SqliteConnectionManager;
use r2d2_sqlite::rusqlite::{self, params};
use derive_more::From;
use crate::{utils::get_challenge_number, models::User}; use crate::{utils::get_challenge_number, models::User};
use super::{LegacySubmission, Submission}; use super::{LegacySubmission, Submission};
pub struct Database { pub struct Database {
conn: Connection, // Must be Arc because Connection contains RefCell,
// which cannot be shared between threads safely
connection_pool: Pool<SqliteConnectionManager>,
} }
const DATABASE_FILENAME: &str = "database.db"; const DATABASE_FILENAME: &str = "database.db";
#[derive(From, Debug)]
pub enum DatabaseError {
Rusqlite(rusqlite::Error),
Pool(r2d2::Error),
}
type Result<T> = std::result::Result<T, DatabaseError>;
impl Database { impl Database {
pub fn file_exists() -> bool { pub fn file_exists() -> bool {
Path::new(DATABASE_FILENAME).exists() Path::new(DATABASE_FILENAME).exists()
@ -20,12 +34,14 @@ impl Database {
pub fn new( pub fn new(
testing: bool, testing: bool,
) -> rusqlite::Result<Self> { ) -> Result<Self> {
let conn = if testing { let connection_manager = if testing {
Connection::open_in_memory() SqliteConnectionManager::memory()
} else { } else {
Connection::open(DATABASE_FILENAME) SqliteConnectionManager::file(DATABASE_FILENAME)
}?; };
let connection_pool = Pool::new(connection_manager)?;
let conn = connection_pool.get()?;
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS Submission ( "CREATE TABLE IF NOT EXISTS Submission (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@ -46,21 +62,26 @@ impl Database {
)", )",
params![], params![],
)?; )?;
Ok(Self { conn }) Ok(Self { connection_pool })
} }
pub fn has_submitted(&self, user_id: u64) -> rusqlite::Result<bool> { fn conn(&self) -> std::result::Result<PooledConnection<SqliteConnectionManager>, r2d2::Error> {
Ok(self.conn self.connection_pool.get()
}
pub fn has_submitted(&self, user_id: u64) -> Result<bool> {
Ok(self.conn()?
.prepare("SELECT 1 FROM User WHERE id = ?1 LIMIT 1")? .prepare("SELECT 1 FROM User WHERE id = ?1 LIMIT 1")?
.query_row(params![user_id], |_row| Ok(())) .query_row(params![user_id], |_row| Ok(()))
.is_ok()) .is_ok())
} }
pub async fn load_legacy(&self, http: &Http) -> rusqlite::Result<()> { pub async fn load_legacy(&self, http: &Http) -> Result<()> {
let latest_challenge = get_challenge_number(); let latest_challenge = get_challenge_number();
// HashMap of archived users that are no longer sharing a server with 字ちゃん // HashMap of archived users that are no longer sharing a server with 字ちゃん
// Their historical usernames and discriminators will be used // Their historical usernames and discriminators will be used
let mut archived_users = HashMap::new(); let mut archived_users = HashMap::new();
let conn = self.conn()?;
for n in 1..=latest_challenge { for n in 1..=latest_challenge {
println!("Loading legacy challenge {n}/{latest_challenge}..."); println!("Loading legacy challenge {n}/{latest_challenge}...");
let mut file = File::open(format!("data/challenges/{n}.json")).unwrap(); let mut file = File::open(format!("data/challenges/{n}.json")).unwrap();
@ -72,7 +93,7 @@ impl Database {
.map(|legacy| (legacy, legacy.parse().unwrap())) { .map(|legacy| (legacy, legacy.parse().unwrap())) {
let mut already_updated = false; let mut already_updated = false;
for submission in submissions { for submission in submissions {
self.conn.execute( conn.execute(
"INSERT INTO Submission(author_id, timestamp, image, challenge) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO Submission(author_id, timestamp, image, challenge) VALUES (?1, ?2, ?3, ?4)",
params![ params![
&submission.author_id, &submission.author_id,
@ -120,7 +141,7 @@ impl Database {
} else { } else {
match User::fetch(http, submission.author_id).await { match User::fetch(http, submission.author_id).await {
Ok(user) => { Ok(user) => {
self.conn.execute( conn.execute(
"INSERT INTO User(id, name, discriminator, avatar) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO User(id, name, discriminator, avatar) VALUES (?1, ?2, ?3, ?4)",
params![user.id, user.name, user.discriminator, user.avatar] params![user.id, user.name, user.discriminator, user.avatar]
)?; )?;
@ -138,6 +159,51 @@ impl Database {
Ok(()) Ok(())
} }
pub fn get_challenge_user_data(&self, challenge: u32) -> Result<(Vec<Submission>, HashMap<String, User>)> {
let submissions = self.get_submissions(challenge)?;
let users = self.get_users({
let mut user_ids = HashSet::new();
for submission in &submissions {
user_ids.insert(submission.author_id);
}
user_ids
})?;
Ok((submissions, users))
}
pub fn get_submissions(&self, challenge: u32) -> Result<Vec<Submission>> {
Ok(self.conn()?
.prepare("SELECT author_id, timestamp, image FROM Submission WHERE challenge = ?1")?
.query_map(params![challenge], |row| {
Ok(Submission {
author_id: row.get(0)?,
timestamp: row.get(1)?,
image: row.get(2)?,
})
})?
.collect::<std::result::Result<Vec<Submission>, rusqlite::Error>>()?)
}
fn get_users(&self, users: HashSet<u64>) -> Result<HashMap<String, User>> {
let conn = self.conn()?;
// Not sure why derive_more::From is unable to convert these errors
users
.iter()
// u64 must be converted to String for templates
.filter_map(|id| -> Option<Result<(String, User)>> {
match conn.prepare("SELECT name, discriminator, avatar FROM User WHERE id = ?1") {
Ok(mut statement) => Some(statement.query_row(params![id], |row| Ok((id.to_string(), User {
id: *id,
name: row.get(0)?,
discriminator: row.get(1)?,
avatar: row.get(2)?,
}))).map_err(|error| DatabaseError::Rusqlite(error))),
Err(error) => Some(Err(DatabaseError::Rusqlite(error))),
}
})
.collect()
}
#[allow(dead_code)] #[allow(dead_code)]
pub fn refresh_users(&self) -> rusqlite::Result<()> { pub fn refresh_users(&self) -> rusqlite::Result<()> {
// Periodically refresh all changable user data (name, discriminator, avatar) // Periodically refresh all changable user data (name, discriminator, avatar)

@ -1,8 +1,11 @@
use chrono::{Utc, DateTime}; use chrono::{Utc, DateTime};
use serde::Serialize;
// Challenge submission // Challenge submission
// In the legacy site version, one submission held 1 or more images. // In the legacy site version, one submission held 1 or more images.
// Now, 1 submission = 1 image, and the leaderboard count will be labeled as "participations" // Now, 1 submission = 1 image, and the leaderboard count will be labeled as "participations"
#[derive(Serialize, Debug)]
pub struct Submission { pub struct Submission {
pub author_id: u64, pub author_id: u64,
// Some fields might be empty for legacy submissions // Some fields might be empty for legacy submissions

@ -18,7 +18,7 @@ pub trait Username {
fn username(&self) -> String; fn username(&self) -> String;
} }
#[derive(Default, Deserialize)] #[derive(Default, Deserialize, Debug)]
pub struct User { pub struct User {
#[serde(deserialize_with = "deserialize_id")] #[serde(deserialize_with = "deserialize_id")]
pub id: u64, pub id: u64,

@ -6,7 +6,7 @@ use rocket_dyn_templates::{context, Template};
use crate::{ use crate::{
cookies::LANG_COOKIE, cookies::LANG_COOKIE,
i18n::DEFAULT as DEFAULT_LANG, i18n::DEFAULT as DEFAULT_LANG,
models::{Challenge, Settings, SessionUser}, models::{Challenge, Settings, SessionUser, Database},
utils::AcceptLanguage, utils::AcceptLanguage,
}; };
@ -15,12 +15,16 @@ pub async fn get_challenge(
challenge: u32, challenge: u32,
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
settings: &State<Settings>, settings: &State<Settings>,
database: &State<Database>,
accept_language: AcceptLanguage, accept_language: AcceptLanguage,
) -> Template { ) -> Template {
let (submissions, users) = database.get_challenge_user_data(challenge).unwrap();
Template::render( Template::render(
"index", "index",
context! { context! {
challenge, challenge,
submissions,
users,
settings: settings.deref(), settings: settings.deref(),
lang: cookies lang: cookies
.get(LANG_COOKIE) .get(LANG_COOKIE)

@ -42,10 +42,10 @@ input[type=number] {
#content { #content {
width: 100%; width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden; overflow: hidden;
& > div {
padding: 1em;
}
nav { nav {
--bg: #{$fg}; --bg: #{$fg};
@ -97,6 +97,58 @@ input[type=number] {
border-radius: 100%; border-radius: 100%;
} }
} }
& > div:first-of-type {
display: flex;
height: 100%;
& > :first-child {
min-width: 40em;
padding: 1em;
}
& > * {
overflow: scroll;
}
}
}
* {
box-sizing: border-box !important;
}
#submissions {
display: flex;
flex-direction: column;
height: calc(100% - 2em);
background: rgba(0, 0, 0, 0.125);
$gap: 0.5em;
padding: $gap;
& > div {
width: 100%;
column-count: 3;
margin: 0;
gap: $gap;
& > figure {
background: rgba(0, 0, 0, 0.125);
padding: $gap;
width: 100%;
box-sizing: border-box;
margin: 0;
margin-bottom: $gap;
break-inside: avoid-column;
text-align: center;
& > img {
width: 100%;
cursor: pointer;
&:hover {
border: none;
filter: brightness(1.25);
}
}
}
}
} }
.dropdown:hover > .link, nav > .link:hover { .dropdown:hover > .link, nav > .link:hover {

@ -65,54 +65,88 @@
</a> </a>
</nav> </nav>
<div> <div>
<h1>Welcome to Tegaki Tuesday #{{ challenge }}!</h1> <div>
<div lang="ja"> <h1>Welcome to Tegaki Tuesday #{{ challenge }}!</h1>
<div lang="ja">
<script>
let kyujitai = false;
function kyujitaiToggle() {
document.querySelectorAll("[data-kyujitai]").forEach(segment => {
let newText = segment.getAttribute("data-kyujitai");
segment.setAttribute("data-kyujitai", segment.firstChild.data);
segment.firstChild.data = newText;
})
kyujitai = !kyujitai;
}
</script>
<button onclick="kyujitaiToggle(); this.innerHTML = kyujitai ? '新字体' : '旧字体'">旧字体</button>
{% for line in content.japanese %}
{% for subline in line %}
<p>
{%- for word in subline -%}
{%- if word.dictionary -%}
<a href="{{ word.dictionary }}" target="_blank"{% if word.pos %} class="{{ word.pos }}" title="{{ word.pos }}"{% endif %}>
{%- endif -%}
{% for segment in word.text -%}
<ruby{% if segment.kyujitai %} data-kyujitai="{{ segment.kyujitai }}"{% endif %}>{{ segment.kanji }}<rp>(</rp><rt>{{ segment.furigana | safe }}</rt><rp>)</rp></ruby>
{%- endfor -%}
{%- if word.dictionary -%}
</a>
{%- endif -%}
{%- endfor -%}
</p>
{% endfor %}
{% endfor %}
</div>
{% if content.translation %}
<p>
Translation
{% if content.translation.author %}by {{ content.translation.author }}{% endif %}
{% if content.translation.site %} via
{% if content.translation.site.link %}
<a href="{{ content.translation.site.link }}" target="_blank">{{ content.translation.site.name }}</a>
{% else %}
{{ content.translation.site.name }}
{% endif %}
{%- endif -%}
</p>
{%- endif -%}
{%- if content.suggester -%}
<p><em>This challenge was suggested by <strong>{{ content.suggester }}</strong> using the <code>-h suggest</code> command.</code></em></p>
{%- endif -%}
</div>
<div id="submissions">
<script> <script>
let kyujitai = false; const submissionModal = image => {
function kyujitaiToggle() { const dialog = document.createElement("dialog");
document.querySelectorAll("[data-kyujitai]").forEach(segment => { dialog.style.padding = "0";
let newText = segment.getAttribute("data-kyujitai"); const img = document.createElement("img");
segment.setAttribute("data-kyujitai", segment.firstChild.data); img.src = "/{{ challenge }}/" + image;
segment.firstChild.data = newText; const defaultZoom = "75vh";
}) img.style.height = defaultZoom;
kyujitai = !kyujitai; img.style.cursor = "zoom-in";
} img.style.display = "block";
img.style.margin = "0";
img.onclick = event => img.style.height = img.style.height ? "" : defaultZoom;
dialog.appendChild(img);
dialog.addEventListener("close", event => document.body.removeChild(dialog));
document.body.appendChild(dialog);
dialog.showModal();
};
</script> </script>
<button onclick="kyujitaiToggle(); this.innerHTML = kyujitai ? '新字体' : '旧字体'">旧字体</button> <div>
{% for line in content.japanese %} {% for submission in submissions %}
{% for subline in line %} {% set author = users[submission.author_id] %}
<p> <figure>
{%- for word in subline -%} <img src="/{{ challenge }}/{{ submission.image }}" alt="{{ author.username }}'s submission" onclick="submissionModal('{{ submission.image }}')">
{%- if word.dictionary -%} <figcaption><a href="https://discord.com/users/{{ author.id }}" target="_blank">{{ author.username }}</a></figcaption>
<a href="{{ word.dictionary }}" target="_blank"{% if word.pos %} class="{{ word.pos }}" title="{{ word.pos }}"{% endif %}> </figure>
{%- endif -%}
{% for segment in word.text -%}
<ruby{% if segment.kyujitai %} data-kyujitai="{{ segment.kyujitai }}"{% endif %}>{{ segment.kanji }}<rp>(</rp><rt>{{ segment.furigana | safe }}</rt><rp>)</rp></ruby>
{%- endfor -%}
{%- if word.dictionary -%}
</a>
{%- endif -%}
{%- endfor -%}
</p>
{% endfor %} {% endfor %}
{% endfor %} </div>
<div>
some random shit goes here
</div>
</div> </div>
{% if content.translation %}
<p>
Translation
{% if content.translation.author %}by {{ content.translation.author }}{% endif %}
{% if content.translation.site %} via
{% if content.translation.site.link %}
<a href="{{ content.translation.site.link }}" target="_blank">{{ content.translation.site.name }}</a>
{% else %}
{{ content.translation.site.name }}
{% endif %}
{%- endif -%}
</p>
{%- endif -%}
{%- if content.suggester -%}
<p><em>This challenge was suggested by <strong>{{ content.suggester }}</strong> using the <code>-h suggest</code> command.</code></em></p>
{%- endif -%}
</div> </div>
</div> </div>
</body> </body>

Loading…
Cancel
Save