feat(card_db): Import card database from edopro GitHub repo

This commit is contained in:
Tristan Daniël Maat 2025-03-15 03:02:31 +08:00
parent 88da43e208
commit a3ce7f8008
Signed by: tlater
GPG key ID: 49670FD774E43268
14 changed files with 691 additions and 108 deletions

View file

@ -0,0 +1,14 @@
use draft_manager::database::BrowserDatabase;
fn main() {
console_error_panic_hook::set_once();
wasm_logger::init(wasm_logger::Config::default());
wasm_bindgen_futures::spawn_local(async {
let a = BrowserDatabase::init_worker().await;
if let Err(e) = a {
log::error!("{:?}", e);
}
})
}

View file

@ -1,13 +1,13 @@
use leptos::prelude::*;
use crate::database::models::CardDetails;
use crate::database::models::CardData;
#[component]
pub fn Card(details: Option<CardDetails>) -> impl IntoView {
pub fn Card(details: Option<CardData>) -> impl IntoView {
match details {
Some(details) => view! {
<p>{details.name}</p>
<span style="white-space: pre-line">{details.description}</span>
<p>{details.id}</p>
<span style="white-space: pre-line">{details.level}</span>
}
.into_any(),
None => view! { <p>"Placeholder"</p> }.into_any(),

View file

@ -1,54 +1,60 @@
use crate::database::{self, models::FrameType, schema::cards};
use database::models::CardDetails;
use diesel::{prelude::*, Connection};
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use thiserror::Error;
use gloo::worker::{Registrable, Spawnable, WorkerBridge};
pub mod models;
pub mod schema;
mod worker;
const DB_URL: &str = "cards.db";
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
use sqlite_wasm_rs::export::{install_opfs_sahpool, OpfsSAHError};
use thiserror_ext::AsReport;
use worker::{DatabaseWorker, Query};
pub struct BrowserDatabase(SqliteConnection);
use crate::utils::github_fetch_file;
pub struct BrowserDatabase {
worker: WorkerBridge<DatabaseWorker>,
}
impl BrowserDatabase {
pub fn open() -> Result<Self> {
let mut connection = SqliteConnection::establish(DB_URL)?;
connection.run_pending_migrations(MIGRATIONS).unwrap();
Ok(Self(connection))
pub fn new() -> Self {
let bridge = DatabaseWorker::spawner()
.callback(move |o| {
log::info!("{:?}", o);
})
.spawn_with_loader("/database_worker_loader.js");
log::debug!("Shoulda spawned a worker");
Self { worker: bridge }
}
pub async fn get_card(&mut self, password: i32) -> Result<Option<CardDetails>> {
match cards::dsl::cards.find(password).first(&mut self.0) {
Ok(details) => Ok(Some(details)),
Err(diesel::result::Error::NotFound) => Ok(None),
Err(other) => Err(other.into()),
}
pub fn send_message(&self) {
self.worker.send(Query::ByPassword(1861629));
}
pub fn load_data(&mut self) -> Result<()> {
let decode_talker = CardDetails {
password: 1861629,
name: "Decode Talker".to_owned(),
description: "2+ Effect Monsters\r\nGains 500 ATK for each monster it points to. When your opponent activates a card or effect that targets a card(s) you control (Quick Effect): You can Tribute 1 monster this card points to; negate the activation, and if you do, destroy that card.".to_owned(),
frame: FrameType::Link,
image: "https://images.ygoprodeck.com/images/cards/1861629.jpg".to_owned()
/// Initialize the database worker.
///
/// Must be executed in a worker script, not on the main thread.
pub async fn init_worker() -> Result<(), OpfsSAHError> {
log::debug!("Initializing database worker...");
let sah_pool_util = install_opfs_sahpool(None, false).await?;
let babel_cdb = match github_fetch_file("ProjectIgnis", "BabelCDB", "cards.cdb").await {
Ok(cdb) => cdb,
Err(err) => {
log::error!("{}", err.to_report_string());
panic!("{:?}", err);
}
};
diesel::insert_into(cards::table)
.values(decode_talker)
.execute(&mut self.0)?;
sah_pool_util.import_db("/cards.db", &babel_cdb)?;
DatabaseWorker::registrar().register();
log::debug!("Database worker set up!");
Ok(())
}
}
#[derive(Debug, Error)]
pub enum BrowserDatabaseError {
#[error("failed to connect to database")]
DieselConnectionError(#[from] diesel::ConnectionError),
#[error("failed to query database")]
DieselQueryError(#[from] diesel::result::Error),
impl Default for BrowserDatabase {
fn default() -> Self {
Self::new()
}
}
type Result<T> = std::result::Result<T, BrowserDatabaseError>;

View file

@ -1,38 +1,79 @@
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use crate::database::schema::cards;
use crate::database::schema::datas;
use crate::database::schema::texts;
#[derive(Clone, Debug, Serialize, Deserialize, Insertable, Selectable, Queryable)]
#[diesel(table_name = cards)]
#[derive(Debug, Selectable, Queryable)]
#[diesel(table_name = datas)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct CardData {
pub id: i32,
pub ot: i32,
pub alias: i32,
pub setcode: i32,
pub card_type: i32,
pub atk: i32,
pub def: i32,
pub level: i32,
pub race: i32,
pub attribute: i32,
pub category: i32,
}
#[derive(Debug, Associations, Selectable, Queryable)]
#[diesel(table_name = texts)]
#[diesel(belongs_to(CardData, foreign_key = id))]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct CardTexts {
pub id: i32,
pub name: String,
pub desc: String,
pub str1: String,
pub str2: String,
pub str3: String,
pub str4: String,
pub str5: String,
pub str6: String,
pub str7: String,
pub str8: String,
pub str9: String,
pub str10: String,
pub str11: String,
pub str12: String,
pub str13: String,
pub str14: String,
pub str15: String,
pub str16: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardDetails {
pub password: i32,
pub name: String,
pub description: String,
pub frame: FrameType,
pub image: String,
pub desc: String,
pub card_type: i32,
pub atk: i32,
pub def: i32,
pub level: i32,
pub race: i32,
pub attribute: i32,
pub category: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, diesel_derive_enum::DbEnum)]
#[serde(rename_all = "snake_case")]
pub enum FrameType {
Normal,
Effect,
Ritual,
Fusion,
Synchro,
Xyz,
Link,
NormalPendulum,
EffectPendulum,
RitualPendulum,
FusionPendulum,
SynchroPendulum,
XyzPendulum,
Spell,
Trap,
Token,
impl CardDetails {
pub fn from_data(data: CardData, texts: CardTexts) -> Self {
Self {
password: data.id,
name: texts.name,
desc: texts.desc,
card_type: data.card_type,
atk: data.atk,
def: data.def,
level: data.level,
race: data.race,
attribute: data.attribute,
category: data.category,
}
}
}

View file

@ -1,9 +1,40 @@
diesel::table! {
cards (password) {
password -> Integer,
name -> VarChar,
description -> Text,
frame -> crate::database::models::FrameTypeMapping,
image -> Text,
datas {
id -> Integer,
ot -> Integer,
alias -> Integer,
setcode -> Integer,
#[sql_name = "type"]
card_type -> Integer,
atk -> Integer,
def -> Integer,
level -> Integer,
race -> Integer,
attribute -> Integer,
category -> Integer,
}
}
diesel::table! {
texts {
id -> Integer,
name -> Text,
desc -> Text,
str1 -> Text,
str2 -> Text,
str3 -> Text,
str4 -> Text,
str5 -> Text,
str6 -> Text,
str7 -> Text,
str8 -> Text,
str9 -> Text,
str10 -> Text,
str11 -> Text,
str12 -> Text,
str13 -> Text,
str14 -> Text,
str15 -> Text,
str16 -> Text,
}
}

140
src/database/worker.rs Normal file
View file

@ -0,0 +1,140 @@
use diesel::{prelude::*, Connection};
use futures::TryFutureExt;
use gloo::worker::{HandlerId, Worker, WorkerScope};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use thiserror_ext::AsReport;
use wasm_bindgen_futures::spawn_local;
use super::{
models::CardDetails,
schema::{datas, texts},
};
const DB_URL: &str = "file:/cards.db?vfs=opfs-sahpool&mode=ro";
#[derive(Serialize, Deserialize)]
pub enum Query {
ByPassword(i32),
}
#[derive(Serialize, Deserialize, Debug)]
pub enum Response {
ByPassword(Option<CardDetails>),
Error(String),
}
pub struct DatabaseWorker {}
impl DatabaseWorker {
async fn run_query(query: &Query) -> Result<Response> {
let res = match query {
Query::ByPassword(pw) => Response::ByPassword(Self::get_card(*pw).await?),
};
Ok(res)
}
/// Open a connection to the database.
///
/// We do not hold a connection in memory because that requires
/// lots of awkward multi-thread data management.
fn open_connection() -> Result<SqliteConnection> {
let connection = SqliteConnection::establish(DB_URL)?;
Ok(connection)
}
async fn get_card(password: i32) -> Result<Option<CardDetails>> {
let mut connection = Self::open_connection()?;
let res = {
let data = datas::dsl::datas.find(password).first(&mut connection)?;
let texts = texts::dsl::texts.find(password).first(&mut connection)?;
Ok((data, texts))
};
let Some((data, texts)) = match res {
Ok(details) => Ok(Some(details)),
Err(diesel::result::Error::NotFound) => Ok(None),
Err(other) => Err(other),
}?
else {
return Ok(None);
};
// match datas::dsl::datas.find(password).first(&mut connection) {
// Ok(details) => Ok(Some(details)),
// Err(diesel::result::Error::NotFound) => Ok(None),
// Err(other) => Err(other.into()),
// };
Ok(Some(CardDetails::from_data(data, texts)))
}
// pub async fn load_card_data(&mut self) -> Result<()> {
// let last_updated =
// utils::github_fetch_last_updated("ProjectIgnis", "BabelCDB", "cards.cdb").await?;
// if last_updated > time::OffsetDateTime::UNIX_EPOCH {
// let card_data =
// utils::github_fetch_file("ProjectIgnis", "BabelCDB", "cards.cdb").await?;
// self.opfs_util.import_db("cards.cdb", &card_data)?;
// }
// let decode_talker = CardDetails {
// password: 1861629,
// name: "Decode Talker".to_owned(),
// description: "2+ Effect Monsters\r\nGains 500 ATK for each monster it points to. When your opponent activates a card or effect that targets a card(s) you control (Quick Effect): You can Tribute 1 monster this card points to; negate the activation, and if you do, destroy that card.".to_owned(),
// frame: FrameType::Link,
// image: "https://images.ygoprodeck.com/images/cards/1861629.jpg".to_owned()
// };
// diesel::insert_into(cards::table)
// .values(decode_talker)
// .execute(&mut self.connection)?;
// Ok(())
// }
}
impl Worker for DatabaseWorker {
type Input = Query;
type Message = ();
type Output = std::result::Result<Response, String>;
fn create(_scope: &WorkerScope<Self>) -> Self {
Self {}
}
fn update(&mut self, _scope: &WorkerScope<Self>, _msg: Self::Message) {
log::debug!("Unimplemented!");
}
fn received(&mut self, scope: &WorkerScope<Self>, query: Self::Input, id: HandlerId) {
let scope = scope.clone();
spawn_local(async move {
let res = DatabaseWorker::run_query(&query)
.map_err(|err| {
log::error!("{}", err.to_report_string_pretty());
format!("{}", err.as_report())
})
.await;
scope.respond(id, res);
})
}
}
#[derive(Debug, Error)]
pub enum BrowserDatabaseError {
// #[error("failed to set up browser database storage")]
// VFSError(#[from] sqlite_wasm_rs::export::OpfsSAHError),
// #[error("could not fetch updated card database")]
// CardDbFetchError(#[from] GithubError),
#[error("failed to connect to database")]
DieselConnectionError(#[from] diesel::ConnectionError),
#[error("failed to query database")]
DieselQueryError(#[from] diesel::result::Error),
}
type Result<T> = std::result::Result<T, BrowserDatabaseError>;

View file

@ -1,3 +1,4 @@
pub mod components;
pub mod draft_list;
pub mod database;
pub mod draft_list;
pub mod utils;

View file

@ -1,32 +1,37 @@
use draft_manager::{components::card::Card, database::BrowserDatabase};
use std::time::Duration;
use draft_manager::database::BrowserDatabase;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
fn main() {
console_error_panic_hook::set_once();
wasm_logger::init(wasm_logger::Config::default());
log::debug!("Startin app database");
let db = BrowserDatabase::new();
log::debug!("Database initialized");
leptos::mount::mount_to_body(|| view! { <App /> });
spawn_local(async move {
let db = db;
log::info!("Running loop!");
db.send_message();
// We create a loop so that listeners can be held for forever.
loop {
gloo::timers::future::sleep(Duration::from_secs(3600)).await;
}
});
}
#[component]
fn App() -> impl IntoView {
let card = LocalResource::new(|| async move {
let mut database = BrowserDatabase::open()?;
database.load_data()?;
database.get_card(1861629).await
});
view! {
<Transition fallback=move || {
view! { <p>"Loading database..."</p> }
}>
{move || {
card.read()
.as_deref()
.and_then(|card| {
view! { <Card details=card.as_ref().unwrap().clone() /> }.into()
})
}}
</Transition>
}>{move || {}}</Transition>
}
}

60
src/utils.rs Normal file
View file

@ -0,0 +1,60 @@
use gloo::net::http;
use thiserror::Error;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
const GITHUB_API: &str = "https://api.github.com";
pub async fn github_fetch_last_updated(
owner: &str,
repo: &str,
path: &str,
) -> Result<OffsetDateTime, GithubError> {
let commit: Vec<serde_json::Value> = http::Request::get(&format!(
"{GITHUB_API}/repos/{owner}/{repo}/commits?path={path}&page=1&per_page=1"
))
.send()
.await?
.json()
.await?;
commit[0]
.get("commit")
.and_then(|commit| commit.get("committer"))
.and_then(|committer| committer.get("date"))
.map(|date| {
OffsetDateTime::parse(
date.as_str().ok_or(GithubError::MissingCommitDateError)?,
&Rfc3339,
)
.map_err(|err| err.into())
})
.unwrap_or(Err(GithubError::MissingCommitDateError))
}
pub async fn github_fetch_file(
owner: &str,
repo: &str,
path: &str,
) -> Result<Vec<u8>, GithubError> {
http::Request::get(&format!(
"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}"
))
.header("Accept", "application/vnd.github.raw+json")
.send()
.await?
.binary()
.await
.map_err(|err| err.into())
}
#[derive(Debug, Error)]
pub enum GithubError {
#[error("no file returned from the given GitHub API")]
NoSuchFile,
#[error("failed to fetch from GitHub API")]
RequestError(#[from] gloo::net::Error),
#[error("file fetched from the GitHub API does not have a commit date")]
MissingCommitDateError,
#[error("date/time format reported for GitHub commit is invalid")]
InvalidDateTimeError(#[from] time::error::Parse),
}