Initial Commit
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "entry"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "sekai_sync_lib"
|
||||
crate-type = ["rlib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
assets-updater = { path = "../assets-updater" }
|
||||
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
log = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
simplelog = "0.12.2"
|
||||
@@ -0,0 +1,123 @@
|
||||
use crate::CONFIG;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub cwd: String,
|
||||
pub name: String,
|
||||
pub as_path: String, // AssetStudio
|
||||
}
|
||||
impl Config {
|
||||
pub fn cache_dir(&self) -> PathBuf {
|
||||
PathBuf::from(&self.cwd).join("cache").join(&self.name)
|
||||
}
|
||||
pub fn data_dir(&self) -> PathBuf {
|
||||
PathBuf::from(&self.cwd).join("data").join(&self.name)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OptConfig {
|
||||
pub proxy: Option<String>,
|
||||
pub ghp: Option<String>,
|
||||
}
|
||||
|
||||
impl OptConfig {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
proxy: None,
|
||||
ghp: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
pub git_hash: Option<String>,
|
||||
pub master_hash: Option<String>,
|
||||
pub event_id: Option<u64>,
|
||||
}
|
||||
impl State {
|
||||
fn load(x: &str) -> Self {
|
||||
serde_json::from_str(x).unwrap()
|
||||
}
|
||||
|
||||
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
git_hash: None,
|
||||
master_hash: None,
|
||||
event_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(&mut self) -> anyhow::Result<()> {
|
||||
let path = CONFIG.get().unwrap().data_dir().join("state.json");
|
||||
if path.exists() {
|
||||
let i = Self::load(&std::fs::read_to_string(path)?);
|
||||
self.git_hash = i.git_hash;
|
||||
self.master_hash = i.master_hash;
|
||||
self.event_id = i.event_id;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let d = CONFIG.get().unwrap().data_dir();
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let json = serde_json::to_string(self).unwrap();
|
||||
std::fs::write(d.join("state.json"), json).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventStory {
|
||||
pub id: u64,
|
||||
#[serde(rename = "eventId")]
|
||||
pub event_id: u64,
|
||||
// outline: String,
|
||||
#[serde(rename = "assetbundleName")]
|
||||
pub assetbundle_name: String,
|
||||
}
|
||||
|
||||
// 用这个找是因为这个文件小一点(((
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CardEpisode {
|
||||
pub id: u64,
|
||||
pub seq: u64,
|
||||
#[serde(rename = "cardId")]
|
||||
pub card_id: u64,
|
||||
#[serde(rename = "assetbundleName")]
|
||||
pub assetbundle_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventCard {
|
||||
pub id: u64,
|
||||
#[serde(rename = "cardId")]
|
||||
pub card_id: u64,
|
||||
#[serde(rename = "eventId")]
|
||||
pub event_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrentVersion {
|
||||
#[serde(rename = "appHash")]
|
||||
pub app_hash: String,
|
||||
#[serde(rename = "systemProfile")]
|
||||
pub system_profile: String,
|
||||
#[serde(rename = "appVersion")]
|
||||
pub app_version: String,
|
||||
#[serde(rename = "assetVersion")]
|
||||
pub asset_version: String,
|
||||
#[serde(rename = "dataVersion")]
|
||||
pub data_version: String,
|
||||
#[serde(rename = "assetHash")]
|
||||
pub asset_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FetchResult{
|
||||
pub event_id: u64,
|
||||
pub card_paths: Vec<PathBuf>,
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use crate::{CONFIG, OPT_CONFIG};
|
||||
use crate::github::get_master_data;
|
||||
use assets_updater::core::asset_execution::AssetExecutionContext;
|
||||
use assets_updater::core::config::{AppConfig, CryptoConfig, ExecutionConfig, RegionConfig, RegionProviderConfig, RegionRuntimeConfig, ToolsConfig};
|
||||
use assets_updater::core::errors::AssetExecutionError;
|
||||
use common::updater::SyncContext;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct Downloader {
|
||||
exec: AssetExecutionContext,
|
||||
app_config: AppConfig,
|
||||
}
|
||||
impl Downloader {
|
||||
pub async fn new() -> anyhow::Result<Downloader> {
|
||||
let master_data = get_master_data().await?;
|
||||
let mut execution = ExecutionConfig::default();
|
||||
// if let Ok(opt_conf) = &OPT_CONFIG.read()
|
||||
// && let Some(proxy) = &opt_conf.proxy
|
||||
// {
|
||||
// execution.proxy = Some(proxy.to_string());
|
||||
// }
|
||||
let app_config = AppConfig {
|
||||
execution,
|
||||
tools: ToolsConfig{
|
||||
asset_studio_cli_path : Some(CONFIG.get().unwrap().as_path.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut exec = AssetExecutionContext::new(&app_config, &SyncContext{
|
||||
region: "jp".to_string(),
|
||||
export: Default::default(),
|
||||
asset_version: Some(master_data.asset_version),
|
||||
asset_hash: Some(master_data.asset_hash),
|
||||
app_version: Some(master_data.app_version),
|
||||
}, &RegionConfig {
|
||||
provider: RegionProviderConfig::ColorfulPalette {
|
||||
asset_info_url_template: "https://{env}-{hash}-assetbundle-info.sekai.colorfulpalette.org/api/version/{asset_version}/{asset_hash}/os/ios".to_owned(),
|
||||
asset_bundle_url_template: "https://{env}-{hash}-assetbundle.sekai.colorfulpalette.org/{asset_version}/{asset_hash}/ios/{bundle_path}".to_owned(),
|
||||
profile_hash: "cf2d2388".to_owned(),
|
||||
},
|
||||
crypto: CryptoConfig{
|
||||
aes_iv_hex: Some("6732666343305a637a4e394d544a3631".to_owned()),
|
||||
aes_key_hex: Some("6d737833495630693958453575595a31".to_owned()),
|
||||
},
|
||||
runtime: RegionRuntimeConfig{
|
||||
unity_version: "2022.3.21f1".to_owned()
|
||||
},
|
||||
})?;
|
||||
exec.fetch_runtime_cookies().await?;
|
||||
Ok(Downloader { exec, app_config })
|
||||
}
|
||||
pub async fn pull(
|
||||
&self,
|
||||
bundle_path: &str,
|
||||
) -> Result<(PathBuf, /*single file*/ bool), AssetExecutionError> {
|
||||
self.exec.download(bundle_path, &self.app_config).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use crate::data::CurrentVersion;
|
||||
use crate::{OPT_CONFIG, STORE, UA};
|
||||
use anyhow::anyhow;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
const COMMIT_URL: &str = "https://api.github.com/repos/Team-Haruki/haruki-sekai-master/commits?path=master/events.json&per_page=1";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommitResponse {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
fn http_client() -> anyhow::Result<Client> {
|
||||
let mut builder = Client::builder();
|
||||
if let Ok(opt_conf) = &OPT_CONFIG.read() {
|
||||
if let Some(proxy) = &opt_conf.proxy {
|
||||
builder = builder.proxy(reqwest::Proxy::all(proxy)?);
|
||||
}
|
||||
if let Some(ghp) = &opt_conf.ghp {
|
||||
builder = builder.default_headers({
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {ghp}").parse()?,
|
||||
);
|
||||
headers.insert(reqwest::header::USER_AGENT, UA.parse()?);
|
||||
headers
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
|
||||
pub async fn fetch_last_hash() -> anyhow::Result<String> {
|
||||
let resp = http_client()?.get(COMMIT_URL).send().await?;
|
||||
let text = resp.text().await?;
|
||||
let data: Vec<CommitResponse> = serde_json::from_str(&text)?;
|
||||
Ok(data.first().ok_or(anyhow!(""))?.sha.clone())
|
||||
}
|
||||
|
||||
pub fn raw_url(path: &str) -> String {
|
||||
"https://raw.githubusercontent.com/Team-Haruki/haruki-sekai-master/refs/heads/main/".to_owned()
|
||||
+ path
|
||||
}
|
||||
|
||||
pub async fn sync_data() -> anyhow::Result<()> {
|
||||
let client = http_client()?;
|
||||
let events_cards = &client
|
||||
.get(raw_url("master/eventCards.json"))
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
let event_stories = &client
|
||||
.get(raw_url("master/eventStories.json"))
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
let card_episodes = &client
|
||||
.get(raw_url("master/cardEpisodes.json"))
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
let mut store = STORE.write().map_err(|e| anyhow!("{}", e))?;
|
||||
store.set_event_cards(serde_json::from_str(events_cards)?);
|
||||
store.set_event_stories(serde_json::from_str(event_stories)?);
|
||||
store.set_card_episodes(serde_json::from_str(card_episodes)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_master_data() -> anyhow::Result<CurrentVersion> {
|
||||
let client = http_client()?;
|
||||
let text = client
|
||||
.get(raw_url("versions/current_version.json"))
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
mod data;
|
||||
mod download;
|
||||
mod github;
|
||||
mod main_loop;
|
||||
mod store;
|
||||
|
||||
use crate::data::{Config, FetchResult, OptConfig, State};
|
||||
use crate::main_loop::main;
|
||||
use crate::store::Store;
|
||||
use log::{LevelFilter, debug};
|
||||
use simplelog::{ColorChoice, TermLogger, TerminalMode};
|
||||
use std::ffi::{CStr, CString, c_char};
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
pub static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||
pub static OPT_CONFIG: RwLock<OptConfig> = RwLock::new(OptConfig::new());
|
||||
pub static PROVIDE: RwLock<Option<FetchResult>> = RwLock::new(None);
|
||||
pub static STORE: RwLock<Store> = RwLock::new(Store::new());
|
||||
pub static STATE: RwLock<State> = RwLock::new(State::new());
|
||||
|
||||
pub const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
|
||||
|
||||
unsafe fn parse_str(x: *const c_char) -> String {
|
||||
if x.is_null() {
|
||||
return "".to_owned();
|
||||
}
|
||||
let c_str = unsafe { CStr::from_ptr(x) };
|
||||
c_str.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
macro_rules! def_opt_str_setter {
|
||||
($name: ident) => {
|
||||
/// # Safety
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn $name(x: *const c_char) {
|
||||
let x = unsafe { parse_str(x) };
|
||||
if x.is_empty() {
|
||||
OPT_CONFIG.write().unwrap().$name = None;
|
||||
} else {
|
||||
OPT_CONFIG.write().unwrap().$name = Some(x);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
def_opt_str_setter!(proxy);
|
||||
def_opt_str_setter!(ghp);
|
||||
|
||||
/// # Safety
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn boot(
|
||||
cwd: *const c_char,
|
||||
name: *const c_char,
|
||||
as_path: *const c_char,
|
||||
) -> i32 {
|
||||
unsafe {
|
||||
CONFIG
|
||||
.set(Config {
|
||||
cwd: parse_str(cwd),
|
||||
name: parse_str(name),
|
||||
as_path: parse_str(as_path),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
STATE
|
||||
.write()
|
||||
.unwrap()
|
||||
.init()
|
||||
.expect("Failed to initialize state");
|
||||
*STORE.write().unwrap() = Store::load();
|
||||
// TermLogger::init(
|
||||
// LevelFilter::Debug,
|
||||
// simplelog::Config::default(),
|
||||
// TerminalMode::Mixed,
|
||||
// ColorChoice::Auto,
|
||||
// )
|
||||
// .unwrap();
|
||||
thread::spawn(|| {
|
||||
let rt = Runtime::new().expect("Failed to create Tokio runtime");
|
||||
|
||||
rt.block_on(keep_loop());
|
||||
});
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fetch_data() -> *mut c_char {
|
||||
let mut provide = PROVIDE.write().unwrap();
|
||||
if provide.is_none() {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
let result = provide.take().unwrap();
|
||||
*provide = None;
|
||||
|
||||
let json_string = serde_json::to_string(&result).unwrap();
|
||||
|
||||
let c_str = CString::new(json_string).unwrap();
|
||||
c_str.into_raw()
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn free_string(s: *mut c_char) {
|
||||
if s.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let _ = CString::from_raw(s);
|
||||
}
|
||||
}
|
||||
|
||||
async fn keep_loop() {
|
||||
loop {
|
||||
debug!("loop_start");
|
||||
if let Err(err) = main().await {
|
||||
println!("Error: {}", err);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use crate::data::{FetchResult, State};
|
||||
use crate::download::Downloader;
|
||||
use crate::github::{fetch_last_hash, sync_data};
|
||||
use crate::{CONFIG, PROVIDE, STATE, STORE};
|
||||
use anyhow::anyhow;
|
||||
use assets_updater::core::export_pipeline::find_files;
|
||||
use std::fs;
|
||||
use log::debug;
|
||||
|
||||
pub async fn main() -> anyhow::Result<()> {
|
||||
let current_hash = fetch_last_hash().await?;
|
||||
let old_state = {
|
||||
let mut l = STATE.read();
|
||||
if l.is_err() {
|
||||
STATE.clear_poison();
|
||||
drop(l);
|
||||
let mut s = STATE.write().map_err(|e| anyhow!("{}", e))?;
|
||||
*s = State::new();
|
||||
let _ = s.init();
|
||||
drop(s);
|
||||
l = STATE.read();
|
||||
}
|
||||
if let Ok(state) = l {
|
||||
state.clone()
|
||||
} else {
|
||||
State::new()
|
||||
}
|
||||
};
|
||||
if let Some(old_hash) = &old_state.git_hash
|
||||
&& old_hash == ¤t_hash
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// hash updated
|
||||
|
||||
sync_data().await?;
|
||||
let store = STORE.read().map_err(|e| anyhow!("{}", e))?;
|
||||
let event = store.find_last_event();
|
||||
if event
|
||||
.as_ref()
|
||||
.zip(old_state.event_id.as_ref())
|
||||
.is_none_or(|(e, id)| &e.event_id == id)
|
||||
{
|
||||
let mut state = STATE.write().map_err(|e| anyhow!("{}", e))?;
|
||||
state.git_hash = Some(current_hash);
|
||||
if let Some(event) = event {
|
||||
state.event_id = Some(event.event_id);
|
||||
}
|
||||
state.save();
|
||||
return Ok(());
|
||||
}
|
||||
let event = event.unwrap();
|
||||
// event updated
|
||||
|
||||
let cards = store.find_cards_by_event(event.event_id);
|
||||
drop(store);
|
||||
let downloader = Downloader::new().await?;
|
||||
let mut images = Vec::new();
|
||||
let cache_dir = CONFIG.get().unwrap().cache_dir();
|
||||
for card in cards {
|
||||
debug!("Pulling card {} from bundle {}", card.card_id, card.assetbundle_name);
|
||||
let dir = downloader
|
||||
.pull(&format!("character/member/{}", card.assetbundle_name))
|
||||
.await?;
|
||||
let files = find_files(&dir.0)?;
|
||||
for file in files {
|
||||
if file.is_file() && file.extension().map(|ext| ext == "png").unwrap_or(false) {
|
||||
let new_filename = format!(
|
||||
"{}_{}",
|
||||
card.assetbundle_name,
|
||||
file.file_name().unwrap().to_str().unwrap()
|
||||
);
|
||||
let new_file = cache_dir.join(new_filename);
|
||||
fs::copy(&file, &new_file)?;
|
||||
fs::remove_file(&file)?;
|
||||
images.push(new_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*PROVIDE.write().map_err(|e| anyhow!("{}", e))? = Some(FetchResult {
|
||||
event_id: event.event_id,
|
||||
card_paths: images,
|
||||
});
|
||||
let mut state = STATE.write().map_err(|e| anyhow!("{}", e))?;
|
||||
state.git_hash = Some(current_hash);
|
||||
state.event_id = Some(event.event_id);
|
||||
state.save();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::CONFIG;
|
||||
use crate::data::{CardEpisode, EventCard, EventStory};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Store {
|
||||
pub event_stories: Vec<EventStory>,
|
||||
pub card_episodes: Vec<CardEpisode>,
|
||||
pub event_cards: Vec<EventCard>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
event_cards: Vec::new(),
|
||||
card_episodes: Vec::new(),
|
||||
event_stories: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
let cache_dir = CONFIG.get().unwrap().cache_dir();
|
||||
|
||||
// 确保缓存目录存在
|
||||
if !cache_dir.exists() {
|
||||
let _ = fs::create_dir_all(&cache_dir);
|
||||
}
|
||||
|
||||
Self {
|
||||
event_stories: Self::load_file(&cache_dir.join("event_stories.json")),
|
||||
card_episodes: Self::load_file(&cache_dir.join("card_episodes.json")),
|
||||
event_cards: Self::load_file(&cache_dir.join("event_cards.json")),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_file<T: DeserializeOwned>(path: &PathBuf) -> Vec<T> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|content| serde_json::from_str(&content).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_file<T: Serialize>(path: &PathBuf, data: &Vec<T>) {
|
||||
if let Ok(content) = serde_json::to_string_pretty(data) {
|
||||
let _ = fs::write(path, content);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_event_stories(&mut self, data: Vec<EventStory>) {
|
||||
self.event_stories = data;
|
||||
let path = CONFIG.get().unwrap().cache_dir().join("event_stories.json");
|
||||
Self::save_file(&path, &self.event_stories);
|
||||
}
|
||||
|
||||
pub fn set_card_episodes(&mut self, data: Vec<CardEpisode>) {
|
||||
self.card_episodes = data;
|
||||
let path = CONFIG.get().unwrap().cache_dir().join("card_episodes.json");
|
||||
Self::save_file(&path, &self.card_episodes);
|
||||
}
|
||||
|
||||
pub fn set_event_cards(&mut self, data: Vec<EventCard>) {
|
||||
self.event_cards = data;
|
||||
let path = CONFIG.get().unwrap().cache_dir().join("event_cards.json");
|
||||
Self::save_file(&path, &self.event_cards);
|
||||
}
|
||||
|
||||
pub fn save_all(&self) {
|
||||
let cache_dir = CONFIG.get().unwrap().cache_dir();
|
||||
Self::save_file(&cache_dir.join("event_stories.json"), &self.event_stories);
|
||||
Self::save_file(&cache_dir.join("card_episodes.json"), &self.card_episodes);
|
||||
Self::save_file(&cache_dir.join("event_cards.json"), &self.event_cards);
|
||||
}
|
||||
|
||||
pub fn find_last_event(&self) -> Option<EventStory> {
|
||||
self.event_stories
|
||||
.iter()
|
||||
.max_by_key(|s| s.event_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn find_cards_by_event(&self, event_id: u64) -> Vec<CardEpisode> {
|
||||
self.event_cards
|
||||
.iter()
|
||||
.filter(|e| e.event_id == event_id)
|
||||
.filter_map(|c| {
|
||||
self.card_episodes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|e| e.card_id == c.card_id)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user