diff --git a/Cargo.lock b/Cargo.lock index 7775c62..b41e7b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,24 @@ dependencies = [ "serde", ] +[[package]] +name = "async-compression" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07dbbf24db18d609b1462965249abdf49129ccad073ec257da372adc83259c60" +dependencies = [ + "bzip2", + "deflate64", + "flate2", + "futures-core", + "futures-io", + "memchr", + "pin-project-lite", + "xz2", + "zstd", + "zstd-safe", +] + [[package]] name = "async-stream" version = "0.3.2" @@ -136,6 +154,22 @@ dependencies = [ "syn 2.0.47", ] +[[package]] +name = "async_zip" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" +dependencies = [ + "async-compression", + "chrono", + "crc32fast", + "futures-lite", + "pin-project", + "thiserror", + "tokio", + "tokio-util 0.7.3", +] + [[package]] name = "atomic" version = "0.5.0" @@ -257,9 +291,9 @@ checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" [[package]] name = "byteorder" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" @@ -267,6 +301,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + [[package]] name = "bzip2-sys" version = "0.1.11+1.0.8" @@ -385,9 +429,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.2.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81156fece84ab6a9f2afdb109ce3ae577e42b1228441eded99bd77f627953b1a" +checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" dependencies = [ "cfg-if", ] @@ -422,6 +466,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" +[[package]] +name = "deflate64" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83ace6c86376be0b6cdcf3fb41882e81d94b31587573d1cfa9d01cd06bba210d" + [[package]] name = "deranged" version = "0.3.11" @@ -563,11 +613,18 @@ dependencies = [ "serde", ] +[[package]] +name = "fastrand" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" + [[package]] name = "fctrl" version = "0.1.0" dependencies = [ "async-stream", + "async_zip", "base64 0.22.0", "bincode", "bytes", @@ -588,6 +645,7 @@ dependencies = [ "reqwest 0.12.1", "rocket", "rocksdb", + "sanitize-filename", "serde", "serde_json", "serenity", @@ -722,6 +780,19 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +[[package]] +name = "futures-lite" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.30" @@ -1377,6 +1448,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.14" @@ -1463,6 +1540,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + [[package]] name = "parking_lot" version = "0.12.1" @@ -2018,6 +2101,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "sanitize-filename" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ed72fbaf78e6f2d41744923916966c4fbe3d7c74e3037a8ee482f1115572603" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "schannel" version = "0.1.19" @@ -2400,13 +2493,14 @@ dependencies = [ [[package]] name = "time" -version = "0.3.31" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa 1.0.10", "libc", + "num-conv", "num_threads", "powerfmt", "serde", @@ -2422,10 +2516,11 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ + "num-conv", "time-core", ] @@ -2555,6 +2650,7 @@ checksum = "cc463cd8deddc3770d20f9852143d50bf6094e640b485cb2e189a2099085ff45" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -3185,11 +3281,29 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +[[package]] +name = "zstd" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d789b1514203a1120ad2429eae43a7bd32b90976a7bb8a05f7ec02fa88cc23a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd99b45c6bc03a018c8b8a86025678c87e55526064e38f9df301989dce7ec0a" +dependencies = [ + "zstd-sys", +] + [[package]] name = "zstd-sys" -version = "2.0.9+zstd.1.5.5" +version = "2.0.10+zstd.1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656" +checksum = "c253a4914af5bafc8fa8c86ee400827e83cf6ec01195ec1f1ed8441bf00d65aa" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index cbce1ef..c9bfed7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ publish = false [dependencies] async-stream = "0.3" +async_zip = { version = "0.0.17", features = [ "full" ] } base64 = "0.22" bincode = "1.3" bytes = "1.0" @@ -27,6 +28,7 @@ regex = "1.4" reqwest = { version = "0.12.1", features = [ "json" ] } rocksdb = "0.22" rocket = { version = "0.5", features = [ "json" ] } +sanitize-filename = "0.5" serde = { version = "1.0", features = [ "derive" ] } serde_json = "1.0" serenity = { version = "0.12", default-features = false, features = [ "client", "gateway", "rustls_backend", "model", "cache" ] } diff --git a/openapi/mgmt-server-rest.yaml b/openapi/mgmt-server-rest.yaml index 17bfc62..55c981c 100644 --- a/openapi/mgmt-server-rest.yaml +++ b/openapi/mgmt-server-rest.yaml @@ -2,7 +2,7 @@ openapi: 3.0.0 info: title: fctrl mgmt-server REST API description: REST API exposed by fctrl mgmt-server - version: 0.1.1 + version: 0.1.2 servers: - url: /api/v1 @@ -147,7 +147,7 @@ paths: parameters: - name: savefile_id in: path - description: Name of the savefile to be delete + description: Name of the savefile to delete required: true schema: type: string @@ -178,6 +178,23 @@ paths: responses: '200': description: Ok + /server/savefiles/{savefile_id}/mods: + get: + summary: Extract the list of mods from the savefile + parameters: + - name: savefile_id + in: path + description: Name of the savefile to extract mod list from + required: true + schema: + type: string + responses: + '200': + description: A JSON array of objects representing mods from the savefile + content: + application/json: + schema: + $ref: '#/components/schemas/ServerModList' /server/config/adminlist: get: summary: Gets the adminlist the Factorio server is configured to use. diff --git a/src/agent/error.rs b/src/agent/error.rs index 6078d5d..26c1c3a 100644 --- a/src/agent/error.rs +++ b/src/agent/error.rs @@ -19,6 +19,9 @@ pub enum Error { RconEmptyCommand, RconNotConnected, + // SaveHeader + HeaderNotFound, + // Generic Aggregate(Vec), @@ -30,6 +33,7 @@ pub enum Error { Reqwest(reqwest::Error), TomlDe(toml::de::Error), TomlSer(toml::ser::Error), + Zip(async_zip::error::ZipError), } impl std::error::Error for Error {} @@ -81,3 +85,9 @@ impl From for Error { Error::TomlSer(e) } } + +impl From for Error { + fn from(e: async_zip::error::ZipError) -> Self { + Error::Zip(e) + } +} diff --git a/src/agent/main.rs b/src/agent/main.rs index 5a9a816..e1f6fad 100644 --- a/src/agent/main.rs +++ b/src/agent/main.rs @@ -340,6 +340,10 @@ impl AgentController { self.mod_list_get(operation_id).await; } + AgentRequest::ModListExtractFromSave(save_name) => { + self.mod_list_extract_from_save(save_name, operation_id).await; + } + AgentRequest::ModListSet(mod_list) => { self.mod_list_set(mod_list, operation_id).await; } @@ -1102,6 +1106,36 @@ impl AgentController { } } + async fn mod_list_extract_from_save(&self, save_name: String, operation_id: OperationId) { + match util::saves::exists_savefile(&save_name).await { + Ok(true) => { + match util::saves::read_header(&save_name).await { + Ok(header) => { + let base_mod_name = header.base_mod; + let ret = header.mods.into_iter().filter(|shm| { + shm.name == base_mod_name + }).map(|shm| { + ModObject { + name: shm.name, + version: shm.version.to_string(), + } + }).collect(); + self.reply_success(AgentOutMessage::ModsList(ret), operation_id).await; + }, + Err(e) => self.reply_failed( + AgentOutMessage::Error(format!("Failed to read savefile header: {:?}", e)), + operation_id + ).await, + } + }, + Ok(false) => self.reply_failed(AgentOutMessage::SaveNotFound, operation_id).await, + Err(e) => self.reply_failed( + AgentOutMessage::Error(format!("Failed to read savefile: {:?}", e)), + operation_id + ).await, + } + } + async fn mod_list_set(&self, mod_list: Vec, operation_id: OperationId) { match ModManager::read_or_apply_default().await { Ok(mut m) => match Secrets::read().await { diff --git a/src/agent/util/saves.rs b/src/agent/util/saves.rs index 3f58358..e518133 100644 --- a/src/agent/util/saves.rs +++ b/src/agent/util/saves.rs @@ -1,10 +1,14 @@ -use std::{io::SeekFrom, path::{Path, PathBuf}}; +use std::{convert::TryFrom, io::SeekFrom, path::{Path, PathBuf}}; +use async_zip::tokio::read::fs::ZipFileReader; +use factorio_file_parser::SaveHeader; use fctrl::schema::{Save, SaveBytes}; +use futures::AsyncReadExt; use log::{error, info, warn}; -use tokio::{fs::{self, OpenOptions}, io::{AsyncSeekExt, AsyncWriteExt}}; +use tokio::{fs::{self, OpenOptions}, io::{AsyncSeekExt, AsyncWriteExt, BufReader}}; +use tokio_util::compat::TokioAsyncReadCompatExt; -use crate::{consts::*, error::Result}; +use crate::{consts::*, error::{Error, Result}}; pub fn get_savefile_path(save_name: impl AsRef) -> PathBuf { SAVEFILE_DIR.join(format!("{}.zip", save_name.as_ref())) @@ -95,6 +99,28 @@ pub async fn set_savefile(save_name: impl AsRef, savebytes: SaveBytes) -> R } } +pub async fn read_header(save_name: impl AsRef) -> Result { + // 1. open zip + let reader = ZipFileReader::new(get_savefile_path(save_name.as_ref())).await?; + for index in 0..reader.file().entries().len() { + let entry = reader.file().entries().get(index).unwrap(); + // 2. locate level-init.dat and read into memory + if let Ok(filename_str) = entry.filename().as_str() { + if filename_str.ends_with("level-init.dat") { + let mut entry_reader = reader.reader_without_entry(index).await?; + let mut buf = vec![]; + entry_reader.read_to_end(&mut buf).await?; + // 3. Parse as SaveHeader + let save_header = SaveHeader::try_from(buf.as_ref())?; + return Ok(save_header); + } + } else { + warn!("unable to convert zip entry filename '{:?}' to UTF-8, skipping", entry.filename()); + } + } + Err(Error::HeaderNotFound) +} + fn parse_from_path>(path: P) -> Result { if let Some(ext) = path.as_ref().extension() { if ext == "zip" { @@ -116,3 +142,10 @@ fn parse_from_path>(path: P) -> Result { Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid save file").into()) } + +fn sanitise_file_path(path: impl AsRef) -> PathBuf { + path.as_ref().replace('\\', "/") + .split('/') + .map(sanitize_filename::sanitize) + .collect() +} diff --git a/src/mgmt-server/clients.rs b/src/mgmt-server/clients.rs index 802f229..99bd85b 100644 --- a/src/mgmt-server/clients.rs +++ b/src/mgmt-server/clients.rs @@ -207,6 +207,21 @@ impl AgentApiClient { .await } + pub async fn mod_list_extract_from_save(&self, savefile_name: String) -> Result> { + if savefile_name.trim().is_empty() { + return Err(Error::BadRequest("Empty savefile name".to_owned())); + } + + let request = AgentRequest::ModListExtractFromSave(savefile_name); + let (_id, sub) = self.send_request_and_subscribe(request).await?; + + response_or_timeout(sub, Duration::from_millis(500), |r| match r.content { + AgentOutMessage::ModsList(mods) => Ok(mods), + m => Err(default_message_handler(m)), + }) + .await + } + pub async fn mod_list_set( &self, mods: Vec, diff --git a/src/mgmt-server/main.rs b/src/mgmt-server/main.rs index 1b741ed..e285239 100644 --- a/src/mgmt-server/main.rs +++ b/src/mgmt-server/main.rs @@ -157,6 +157,7 @@ async fn main() -> std::result::Result<(), Box> { routes::server::upgrade_install, routes::server::get_install, routes::server::get_savefile, + routes::server::extract_mod_list_from_savefile, routes::server::delete_savefile, routes::server::put_savefile, routes::server::get_savefiles, diff --git a/src/mgmt-server/routes/server.rs b/src/mgmt-server/routes/server.rs index da4ebae..4e4f146 100644 --- a/src/mgmt-server/routes/server.rs +++ b/src/mgmt-server/routes/server.rs @@ -144,7 +144,7 @@ pub async fn delete_savefile( } #[get("/server/savefiles/")] -pub async fn get_savefile<'a>( +pub async fn get_savefile( _a: AuthorizedUser, link_download_manager: &State>, id: String, @@ -153,6 +153,23 @@ pub async fn get_savefile<'a>( Ok(LinkDownloadResponder::new(link_id)) } +#[get("/server/savefiles//mods")] +pub async fn extract_mod_list_from_savefile( + _a: AuthorizedUser, + agent_client: &State>, + id: String, +) -> Result>> { + let mod_list = agent_client.mod_list_extract_from_save(id).await?; + let resp = mod_list + .into_iter() + .map(|mo| ModObject { + name: mo.name, + version: mo.version, + }) + .collect(); + Ok(Json(resp)) +} + #[put("/server/savefiles/", data = "")] pub async fn put_savefile( _a: AuthorizedUser, diff --git a/src/schema.rs b/src/schema.rs index 5349620..890a967 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use factorio_file_parser::SaveHeader; use serde::{Deserialize, Serialize}; use strum_macros::{AsRefStr, EnumString}; @@ -97,6 +98,8 @@ pub enum AgentRequest { // /// Get a list of mods installed on the server. ModListGet, + /// Extract a list of mods from an existing savefile. + ModListExtractFromSave(String), /// Applies the desired mod list on the server. /// /// **This is a long-running operation.** diff --git a/web/src/app/app.component.html b/web/src/app/app.component.html index b75bed7..3b7560a 100644 --- a/web/src/app/app.component.html +++ b/web/src/app/app.component.html @@ -43,3 +43,10 @@

fctrl

+ +
+
+

agent: {{agentBuildInfo?.commit_hash}}@{{agentBuildInfo?.timestamp}}

+

mgmt-server: {{agentBuildInfo?.commit_hash}}@{{mgmtServerBuildInfo?.timestamp}}

+
+
diff --git a/web/src/app/app.component.ts b/web/src/app/app.component.ts index 038c352..1923c37 100644 --- a/web/src/app/app.component.ts +++ b/web/src/app/app.component.ts @@ -4,6 +4,8 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { faChartBar, faCogs, faTerminal, faWrench } from '@fortawesome/free-solid-svg-icons'; import { filter, map, mergeMap } from 'rxjs/operators'; import { OperationService } from './operation.service'; +import { MgmtServerRestApiService } from './mgmt-server-rest-api/services'; +import { BuildInfoObject, BuildVersion } from './mgmt-server-rest-api/models'; @Component({ selector: 'app-root', @@ -18,11 +20,15 @@ export class AppComponent implements OnInit { navModsIcon = faWrench; navLogsIcon = faTerminal; + agentBuildInfo: BuildVersion | undefined = undefined; + mgmtServerBuildInfo: BuildVersion | undefined = undefined; + constructor( private router: Router, private activatedRoute: ActivatedRoute, private title: Title, private operationService: OperationService, + private apiClient: MgmtServerRestApiService, ) { } ngOnInit(): void { @@ -38,6 +44,11 @@ export class AppComponent implements OnInit { }), mergeMap((route) => route.data) ).subscribe((event) => this.title.setTitle(event.title)); + + this.apiClient.buildinfoGet().subscribe(bi => { + this.agentBuildInfo = bi.agent; + this.mgmtServerBuildInfo = bi.mgmt_server; + }); } public onBurgerClick(): void { diff --git a/web/src/app/mods/mod-list/mod-list.component.html b/web/src/app/mods/mod-list/mod-list.component.html index 6afea87..68a639b 100644 --- a/web/src/app/mods/mod-list/mod-list.component.html +++ b/web/src/app/mods/mod-list/mod-list.component.html @@ -8,7 +8,7 @@

Mod list


-
+
@@ -39,9 +39,7 @@

Mod list

@@ -51,8 +49,38 @@

Mod list

+ + + + + + diff --git a/web/src/app/mods/mod-list/mod-list.component.ts b/web/src/app/mods/mod-list/mod-list.component.ts index e57050d..3ab5a98 100644 --- a/web/src/app/mods/mod-list/mod-list.component.ts +++ b/web/src/app/mods/mod-list/mod-list.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { faCheck, faPlus, faSave, faExternalLink } from '@fortawesome/free-solid-svg-icons'; +import { faCheck, faPlus, faSave, faExternalLink, faRefresh } from '@fortawesome/free-solid-svg-icons'; import { Option } from 'prelude-ts'; import { EMPTY, Observable, of, Subject, timer } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, expand, map, reduce, switchMap } from 'rxjs/operators'; @@ -9,6 +9,7 @@ import { MgmtServerRestApiService } from 'src/app/mgmt-server-rest-api/services' import { OperationService } from 'src/app/operation.service'; import { ModInfo } from './mod-info'; import { compareVersions } from 'compare-versions'; +import { ServerModList } from 'src/app/mgmt-server-rest-api/models'; @Component({ selector: 'app-mod-list', @@ -23,11 +24,18 @@ export class ModListComponent implements OnInit { addModPrefetch: ModInfo | null; saveButtonLoading = false; - showTickIcon = false; + saveShowTickIcon = false; + syncButtonLoading = false; + syncShowTickIcon = false; addIcon = faPlus; saveIcon = faSave; tickIcon = faCheck; linkIcon = faExternalLink; + syncIcon = faRefresh; + + syncModalActive = false; + syncSelectedSavename: string | null; + savenames: string[]; ready = false; @@ -37,10 +45,13 @@ export class ModListComponent implements OnInit { private operationService: OperationService, ) { this.addModPrefetch = null; + this.syncSelectedSavename = null; + this.savenames = []; } ngOnInit(): void { this.fetchModList(); + this.fetchSavenames(); this.addModNameSubject.pipe( debounceTime(300), distinctUntilChanged(), @@ -73,43 +84,46 @@ export class ModListComponent implements OnInit { fetchModList(): void { this.apiClient.serverModsListGet().subscribe(modList => { - if (modList.length === 0) { - this.modInfoList = []; - this.ready = true; - } else { - let namelist = modList.map(mo => mo.name); - let all = this.fetchModListInfoSinglePage(namelist, 1) - .pipe( - expand((data, _) => { - return data.nextPage.match({ - Some: nextPage => this.fetchModListInfoSinglePage(namelist, nextPage), - None: () => EMPTY, - }); - }), - reduce((acc: ModInfoBatch[], data) => { - return acc.concat(data.results); - }, []), - ) - .subscribe(modInfoBatch => { - const infoList: ModInfo[] = []; - for (const remoteInfo of modInfoBatch) { - infoList.push({ - name: remoteInfo.name ?? '', - title: remoteInfo.title ?? '', - summary: remoteInfo.summary ?? '', - selectedVersion: modList.find(mo => mo.name === remoteInfo.name)?.version ?? '', - versions: remoteInfo.releases?.map((r: { version: any; }) => r.version).sort(compareVersions).reverse() ?? [], - }); - } - // sort by friendly name, since this is what the in-game mod manager does - this.modInfoList = infoList.sort((lhs, rhs) => lhs.title.localeCompare(rhs.title)); - - this.ready = true; - }); - } + this.updateModList(modList); }); } + updateModList(modList: ServerModList): void { + if (modList.length === 0) { + this.modInfoList = []; + this.ready = true; + } else { + let namelist = modList.map(mo => mo.name); + let all = this.fetchModListInfoSinglePage(namelist, 1) + .pipe( + expand((data, _) => { + return data.nextPage.match({ + Some: nextPage => this.fetchModListInfoSinglePage(namelist, nextPage), + None: () => EMPTY, + }); + }), + reduce((acc: ModInfoBatch[], data) => { + return acc.concat(data.results); + }, []), + ) + .subscribe(modInfoBatch => { + const infoList: ModInfo[] = []; + for (const remoteInfo of modInfoBatch) { + infoList.push({ + name: remoteInfo.name ?? '', + title: remoteInfo.title ?? '', + summary: remoteInfo.summary ?? '', + selectedVersion: modList.find(mo => mo.name === remoteInfo.name)?.version ?? '', + versions: remoteInfo.releases?.map((r: { version: any; }) => r.version).sort(compareVersions).reverse() ?? [], + }); + } + // sort by friendly name, since this is what the in-game mod manager does + this.modInfoList = infoList.sort((lhs, rhs) => lhs.title.localeCompare(rhs.title)); + this.ready = true; + }); + } + } + pushModList(): void { this.saveButtonLoading = true; @@ -130,9 +144,9 @@ export class ModListComponent implements OnInit { async () => { console.log('push mod list succeeded'); this.saveButtonLoading = false; - this.showTickIcon = true; + this.saveShowTickIcon = true; timer(3000).subscribe(_ => { - this.showTickIcon = false; + this.saveShowTickIcon = false; }); }, async err => { @@ -147,6 +161,19 @@ export class ModListComponent implements OnInit { }); } + syncModsWithSave(savefile_id: string): void { + this.apiClient.serverSavefilesSavefileIdModsGet({ savefile_id }).subscribe(resp => { + this.updateModList(resp); + this.syncModalActive = false; + }) + } + + fetchSavenames(): void { + this.apiClient.serverSavefilesGet().subscribe(resp => { + this.savenames = resp.map(sf => sf.name); + }) + } + addMod(): void { if (this.addModPrefetch !== null) { this.modInfoList.push(this.addModPrefetch);