Rewrite from TypeScript to Rust

This commit is contained in:
mtkennerly 2023-11-28 14:16:54 +08:00
parent a8b8b549d0
commit 25d71ba0f2
No known key found for this signature in database
GPG key ID: E764BE00BE6E6408
29 changed files with 221685 additions and 232325 deletions

View file

@ -12,19 +12,19 @@ jobs:
- uses: actions/checkout@v3
with:
ref: ${{ github.head_ref }}
- uses: actions/setup-node@v3
- uses: actions/setup-python@v4
with:
node-version: '16'
python-version: '3.10'
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/cache@v3
with:
path: data/wiki-meta-cache.yaml
key: wiki-meta-cache-v2-${{ github.run_id }}
restore-keys: |
wiki-meta-cache-v2
- run: |
npm install
npm run recent
npm run schema
- run: cargo build
- run: cargo run -- bulk --recent-changes
- uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Import recent changes from PCGamingWiki

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
node_modules/
out/
target/
tmp/
data/wiki-meta-cache.yaml
.mypy_cache/

View file

@ -8,11 +8,8 @@ repos:
rev: v1.1.7
hooks:
- id: forbid-tabs
- repo: local
- repo: https://github.com/mtkennerly/pre-commit-hooks
rev: v0.3.0
hooks:
- id: schema
name: schema
language: system
entry: npm run schema
types: [yaml]
pass_filenames: false
- id: cargo-fmt
- id: cargo-clippy

View file

@ -1,14 +1,14 @@
## Development
Requires Node.js.
Use the latest version of Rust and Python.
Generally, you just need these commands:
* Add all recent changes (defaults to last 7 days, but then it remembers when you last ran it):
* `npm run recent`
* Validate the manifest against the schema:
* `npm run schema`
* Install script dependencies (one time):
* `pip install "steam[client]"`
* Add all recent changes (defaults to last day, but then it remembers when you last ran it):
* `cargo run -- bulk --recent-changes`
* List some stats about the data set:
* `npm run stats`
* `cargo run -- stats`
* Activate pre-commit hooks (requires Python):
```
pip install --user pre-commit
@ -18,10 +18,9 @@ Generally, you just need these commands:
There are some lower-level commands for finer control or full imports:
* Add new games to wiki-game-cache.yaml (required in order to add them to the manifest):
* `npm run cache`
* Update the manifest with games from the cache:
* All games in cache: `npm run manifest`
* Specific games: `npm run manifest -- "Game 1" "Game 2"`
* `cargo run -- bulk --missing-pages`
* Validate schema:
* `cargo run -- schema`
## API etiquette
When running or modifying the importer script, please be mindful not to
@ -34,4 +33,4 @@ suggest that:
> to finish before sending a new request, should result in a safe request rate.
I am not sure about guidelines for the Steam API, but the cache file should mean
that we only ever need to reach out to Steam once per game.
that we are not making excessive requests.

2150
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

24
Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "ludusavi-manifest"
version = "0.1.0"
authors = ["mtkennerly <mtkennerly@gmail.com>"]
edition = "2021"
description = "Ludusavi manifest importer"
repository = "https://github.com/mtkennerly/ludusavi-manifest"
readme = "README.md"
license = "MIT"
[dependencies]
chrono = { version = "0.4.31", features = ["serde"] }
clap = { version = "4.4.8", features = ["derive"] }
itertools = "0.12.0"
jsonschema = "0.17.1"
mediawiki = "0.2.11"
once_cell = "1.18.0"
regex = "1.10.2"
serde = { version = "1.0.139", features = ["derive"] }
serde_json = "1.0.108"
serde_yaml = "0.8.25"
thiserror = "1.0.50"
tokio = { version = "1.34.0", features = ["full"] }
wikitext-parser = "0.3.3"

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

3286
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,35 +0,0 @@
{
"name": "ludusavi-manifest",
"version": "0.0.0",
"description": "Game data backup info",
"author": "Matthew T. Kennerly <mtkennerly@gmail.com>",
"license": "MIT",
"scripts": {
"wiki": "ts-node ./src/bin.ts --wiki",
"manifest": "ts-node ./src/bin.ts --manifest",
"recent": "ts-node ./src/bin.ts --limit 0 --wiki --manifest --recent",
"schema": "npm run schema:normal && npm run schema:strict",
"schema:normal": "ajv validate -s ./data/schema.yaml -d ./data/manifest.yaml",
"schema:strict": "ajv validate -s ./data/schema.strict.yaml -d ./data/manifest.yaml",
"stats": "ts-node ./src/bin.ts --stats",
"duplicates": "ts-node ./src/bin.ts --duplicates",
"steam": "ts-node ./src/bin.ts --steam"
},
"devDependencies": {
"@types/js-yaml": "^3.12.4",
"@types/minimist": "^1.2.2",
"@types/node": "^20.3.1",
"ajv-cli": "^5.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.1.3"
},
"dependencies": {
"js-yaml": "^3.14.0",
"minimist": "^1.2.8",
"moment": "^2.29.4",
"nodemw": "^0.19.0",
"steam-user": "^4.28.6",
"wikiapi": "^1.19.4",
"wikiparse": "^1.0.27"
}
}

1
rustfmt.toml Normal file
View file

@ -0,0 +1 @@
max_width = 120

View file

@ -0,0 +1,18 @@
import json
import sys
from steam.client import SteamClient
def main():
app_id = int(sys.argv[1])
client = SteamClient()
client.anonymous_login()
info = client.get_product_info(apps=[app_id])
print(json.dumps(info, indent=2))
if __name__ == "__main__":
main()

View file

@ -1,143 +0,0 @@
import minimist from "minimist";
import { DEFAULT_GAME_LIMIT } from ".";
import { ManifestFile, ManifestOverrideFile } from "./manifest";
import { SteamGameCacheFile, getSteamClient } from "./steam";
import { WikiGameCacheFile, WikiMetaCacheFile } from "./wiki";
import { saveMissingGames } from "./missing";
interface Cli {
wiki?: boolean,
manifest?: boolean,
stats?: boolean,
duplicates?: boolean,
all?: boolean,
irregular?: boolean,
irregularPathUntagged?: boolean,
skipUntil?: string,
recent?: boolean,
missing?: boolean,
limit?: number,
steam?: boolean,
}
async function main() {
const args = minimist<Cli>(process.argv.slice(2), {
boolean: [
"wiki",
"manifest",
"stats",
"all",
"irregular",
"irregularPathUntagged",
"steam",
"missing",
],
string: [
"skipUntil",
],
});
const wikiCache = new WikiGameCacheFile();
wikiCache.load();
const wikiMetaCache = new WikiMetaCacheFile();
wikiMetaCache.load();
const steamCache = new SteamGameCacheFile(getSteamClient);
steamCache.load();
const manifest = new ManifestFile();
manifest.load();
const manifestOverride = new ManifestOverrideFile();
manifestOverride.load();
if (args.stats) {
console.log(`Total games in manifest: ${Object.keys(manifest.data).length}`);
console.log(`Total games in manifest with files or registry: ${Object.values(manifest.data).filter(x => x.files !== undefined || x.registry !== undefined).length}`);
console.log(`Total games in manifest without files and registry: ${Object.values(manifest.data).filter(x => x.files === undefined && x.registry === undefined).length}`);
console.log(`Total games in wiki cache: ${Object.keys(wikiCache.data).length}`);
process.exit(0);
}
if (args.duplicates) {
const data: {[key: string]: Array<{ name: string; pageId: number }>} = {};
for (const [name, info] of Object.entries(manifest.data)) {
const key = JSON.stringify(info);
let safe = false;
for (const file of Object.keys(info.files ?? {})) {
if (file.includes("<game>") || file.includes("<base>")) {
safe = true;
}
}
if (safe) {
continue;
}
if (!(key in data)) {
data[key] = [];
}
data[key].push({ name, pageId: wikiCache.data[name]?.pageId ?? 0 });
}
for (const games of Object.values(data)) {
if (games.length > 1) {
const lines = games.map(({ name, pageId }) => `[${pageId}] ${name}`);
console.log(`\nSame manifest entry:\n - ${lines.join("\n - ")}`);
}
}
process.exit(0);
}
try {
if (args.wiki) {
if (args.recent) {
await wikiCache.flagRecentChanges(wikiMetaCache);
} else if (args.missing) {
await wikiCache.addNewGames();
}
await wikiCache.refresh(
args.skipUntil,
args.limit ?? DEFAULT_GAME_LIMIT,
args.all ?? false,
args._ ?? [],
);
}
if (args.steam) {
await steamCache.refresh(
args.skipUntil,
args.irregular ?? false,
args.irregularPathUntagged ?? false,
args.limit ?? DEFAULT_GAME_LIMIT,
args._.map(x => x.toString()) ?? [],
);
}
if (args.manifest) {
await manifest.updateGames(
wikiCache.data,
args._ ?? [],
steamCache,
manifestOverride,
);
}
wikiCache.save();
wikiMetaCache.save();
steamCache.save();
manifest.save();
saveMissingGames(wikiCache.data, manifest.data, manifestOverride.data);
if (steamCache.steamClient) {
steamCache.steamClient.logOff();
}
process.exit(0);
} catch (e) {
wikiCache.save();
steamCache.save();
manifest.save();
saveMissingGames(wikiCache.data, manifest.data, manifestOverride.data);
if (steamCache.steamClient) {
steamCache.steamClient.logOff();
}
throw e;
}
}
main();

164
src/cli.rs Normal file
View file

@ -0,0 +1,164 @@
use std::collections::HashMap;
use crate::{
manifest::{placeholder, Manifest, ManifestOverride},
schema,
steam::SteamCache,
wiki::{WikiCache, WikiMetaCache},
Error,
};
#[derive(clap::Parser, Clone, Debug, PartialEq, Eq)]
#[clap(name = "ludusavi", version, term_width = 79)]
pub struct Cli {
#[clap(subcommand)]
pub sub: Subcommand,
}
#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)]
pub enum Subcommand {
/// Fetch bulk updates from the data sources.
/// By default, this only updates entries that are marked as outdated.
Bulk {
/// Do a full sync.
#[clap(long)]
full: bool,
/// Do a partial update based on the wiki's recent changes.
#[clap(long)]
recent_changes: bool,
/// Do a partial update based on the wiki's game pages that are not yet cached.
#[clap(long)]
missing_pages: bool,
},
/// Fetch a named subset of games.
Solo {
#[clap(long)]
local: bool,
#[clap()]
games: Vec<String>,
},
/// Validate the manifest against its schema.
Schema,
/// Display some stats about the manifest.
Stats,
/// Find duplicate manifest entries.
Duplicates,
}
pub fn parse() -> Cli {
use clap::Parser;
Cli::parse()
}
pub async fn run(
sub: Subcommand,
manifest: &mut Manifest,
manifest_override: &mut ManifestOverride,
wiki_cache: &mut WikiCache,
wiki_meta_cache: &mut WikiMetaCache,
steam_cache: &mut SteamCache,
) -> Result<(), Error> {
match sub {
Subcommand::Bulk {
full,
recent_changes,
missing_pages,
} => {
let outdated_only = !full;
if recent_changes {
wiki_cache.flag_recent_changes(wiki_meta_cache).await?;
}
if missing_pages {
wiki_cache.add_new_games().await?;
}
wiki_cache.refresh(outdated_only, None).await?;
steam_cache.transition_states_from(wiki_cache);
steam_cache.refresh(outdated_only, None)?;
manifest.refresh(manifest_override, wiki_cache, steam_cache, None)?;
schema::validate_manifest(manifest)?;
}
Subcommand::Solo { local, games } => {
let outdated_only = false;
if !local {
wiki_cache.refresh(outdated_only, Some(games.clone())).await?;
let steam_ids: Vec<_> = games
.iter()
.filter_map(|x| wiki_cache.0.get(x).and_then(|x| x.steam))
.collect();
steam_cache.transition_states_from(wiki_cache);
steam_cache.refresh(outdated_only, Some(steam_ids))?;
}
manifest.refresh(manifest_override, wiki_cache, steam_cache, Some(games))?;
schema::validate_manifest(manifest)?;
}
Subcommand::Schema => {
schema::validate_manifest(manifest)?;
}
Subcommand::Stats => {
let games = manifest.0.keys().count();
let files_or_registry = manifest
.0
.values()
.filter(|x| !x.files.is_empty() || !x.registry.is_empty())
.count();
let no_files_or_registry = manifest
.0
.values()
.filter(|x| x.files.is_empty() && x.registry.is_empty())
.count();
let in_wiki_cache = wiki_cache.0.keys().count();
println!("Total games in manifest: {}", games);
println!("Total games in manifest with files or registry: {}", files_or_registry);
println!(
"Total games in manifest without files and registry: {}",
no_files_or_registry
);
println!("Total games in wiki cache: {}", in_wiki_cache);
}
Subcommand::Duplicates => {
struct Duplicate {
name: String,
page_id: u64,
}
let mut data = HashMap::<String, Vec<Duplicate>>::new();
'games: for (name, info) in &manifest.0 {
for file in info.files.keys() {
if file.contains(placeholder::GAME) || file.contains(placeholder::BASE) {
continue 'games;
}
}
let key = serde_json::to_string(info).unwrap();
data.entry(key).or_default().push(Duplicate {
name: name.clone(),
page_id: wiki_cache.0.get(name).map(|x| x.page_id).unwrap_or(0),
});
}
for duplicates in data.values() {
if duplicates.len() > 1 {
let lines: Vec<_> = duplicates
.iter()
.map(|x| format!("[{}] {}", x.page_id, x.name))
.collect();
println!("\nSame manifest entry:\n - {}", lines.join("\n - "));
}
}
}
}
Ok(())
}

View file

@ -1,62 +0,0 @@
import * as pathMod from "path";
import * as fs from "fs";
import * as yaml from "js-yaml";
export const REPO = pathMod.dirname(__dirname);
export const DELAY_BETWEEN_GAMES_MS = 250;
export const DEFAULT_GAME_LIMIT = 25;
export class UnsupportedError extends Error {
constructor(message?: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class UnsupportedOsError extends UnsupportedError {
constructor(message?: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class UnsupportedPathError extends UnsupportedError {
constructor(message?: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export enum PathType {
FileSystem,
Registry,
}
export abstract class YamlFile<T = object> {
data: T;
abstract path: string;
abstract defaultData: T;
load(): void {
if (fs.existsSync(this.path)) {
this.data = yaml.safeLoad(fs.readFileSync(this.path, "utf8")) as T;
} else {
this.data = this.defaultData;
}
}
save(): void {
fs.writeFileSync(
this.path,
yaml.safeDump(
this.data,
{
sortKeys: true,
indent: 2,
skipInvalid: true,
lineWidth: 120,
}
)
);
}
}

105
src/main.rs Normal file
View file

@ -0,0 +1,105 @@
mod cli;
mod manifest;
mod missing;
mod resource;
mod schema;
mod steam;
mod wiki;
use crate::{
manifest::{Manifest, ManifestOverride},
resource::ResourceFile,
steam::SteamCache,
wiki::{WikiCache, WikiMetaCache},
};
pub const REPO: &str = env!("CARGO_MANIFEST_DIR");
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum State {
/// This entry needs to be re-fetched from the data source.
Outdated,
/// This entry has been re-fetched, but is awaiting recognition by another step.
Updated,
/// This entry has been fully processed.
#[default]
Handled,
}
impl State {
pub fn is_handled(&self) -> bool {
*self == Self::Handled
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Wiki client: {0}")]
WikiClient(#[from] mediawiki::media_wiki_error::MediaWikiError),
#[error("Wiki data missing or malformed: {0}")]
WikiData(&'static str),
#[error("Unable to find page by title or ID")]
PageMissing,
#[error("Could not find product info")]
SteamProductInfo,
#[error("Schema validation failed for manifest")]
ManifestSchema,
#[error("Subprocess: {0}")]
Subprocess(#[from] std::io::Error),
}
impl Error {
pub fn should_discard_work(&self) -> bool {
match self {
Error::WikiClient(_)
| Error::WikiData(_)
| Error::PageMissing
| Error::SteamProductInfo
| Error::Subprocess(_) => false,
Error::ManifestSchema => true,
}
}
}
#[tokio::main]
async fn main() {
let cli = cli::parse();
let mut wiki_cache = WikiCache::load().unwrap();
let mut wiki_meta_cache = WikiMetaCache::load().unwrap();
let mut steam_cache = SteamCache::load().unwrap();
let mut manifest = Manifest::load().unwrap();
let mut manifest_override = ManifestOverride::load().unwrap();
let mut success = true;
let mut discard = false;
if let Err(e) = cli::run(
cli.sub,
&mut manifest,
&mut manifest_override,
&mut wiki_cache,
&mut wiki_meta_cache,
&mut steam_cache,
)
.await
{
eprintln!("{e}");
success = false;
discard = e.should_discard_work();
}
if !discard {
if success {
wiki_meta_cache.save();
}
wiki_cache.save();
steam_cache.save();
manifest.save();
missing::save_missing_games(&wiki_cache, &manifest, &manifest_override);
}
if !success {
std::process::exit(1);
}
}

458
src/manifest.rs Normal file
View file

@ -0,0 +1,458 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::{
resource::ResourceFile,
steam::{SteamCache, SteamCacheEntry},
wiki::{PathKind, WikiCache, WikiCacheEntry},
Error,
};
pub mod placeholder {
pub const ROOT: &str = "<root>";
pub const GAME: &str = "<game>";
pub const BASE: &str = "<base>";
pub const HOME: &str = "<home>";
pub const STORE_USER_ID: &str = "<storeUserId>";
pub const OS_USER_NAME: &str = "<osUserName>";
pub const WIN_APP_DATA: &str = "<winAppData>";
pub const WIN_LOCAL_APP_DATA: &str = "<winLocalAppData>";
pub const WIN_DOCUMENTS: &str = "<winDocuments>";
pub const WIN_PUBLIC: &str = "<winPublic>";
pub const WIN_PROGRAM_DATA: &str = "<winProgramData>";
pub const WIN_DIR: &str = "<winDir>";
pub const XDG_DATA: &str = "<xdgData>";
pub const XDG_CONFIG: &str = "<xdgConfig>";
}
fn do_launch_paths_match(from_steam: Option<String>, from_manifest: Option<String>) -> bool {
match (from_steam, from_manifest) {
(None, None) => true,
(Some(from_steam), from_manifest) => normalize_launch_path(&from_steam) == from_manifest,
_ => false,
}
}
fn normalize_launch_path(raw: &str) -> Option<String> {
if raw.contains("://") {
return Some(raw.to_string());
}
let standardized = raw.replace('\\', "/").replace("//", "/");
let standardized = standardized
.trim_end_matches('/')
.trim_start_matches("./")
.trim_start_matches('/');
if standardized.is_empty() || standardized == "." {
None
} else {
Some(format!("{}/{}", placeholder::BASE, standardized))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Os {
Dos,
Windows,
Mac,
Linux,
#[default]
#[serde(other)]
Other,
}
impl From<&str> for Os {
fn from(value: &str) -> Self {
match value.to_lowercase().trim() {
"windows" => Self::Windows,
"linux" => Self::Linux,
"mac" | "macos" => Self::Mac,
"dos" => Self::Dos,
_ => Self::Other,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Store {
Ea,
Epic,
Gog,
GogGalaxy,
Heroic,
Lutris,
Microsoft,
Origin,
Prime,
Steam,
Uplay,
OtherHome,
OtherWine,
#[default]
#[serde(other)]
Other,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Tag {
Config,
Save,
#[default]
#[serde(other)]
Other,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Manifest(pub BTreeMap<String, Game>);
impl ResourceFile for Manifest {
const FILE_NAME: &'static str = "data/manifest.yaml";
}
impl Manifest {
pub fn refresh(
&mut self,
overrides: &ManifestOverride,
wiki_cache: &WikiCache,
steam_cache: &SteamCache,
games: Option<Vec<String>>,
) -> Result<(), Error> {
for (title, info) in &wiki_cache.0 {
if let Some(games) = &games {
if !games.contains(title) {
continue;
}
}
if overrides.0.get(title).map(|x| x.omit).unwrap_or(false) {
continue;
}
let mut game = Game::default();
game.integrate_wiki(info, title);
if let Some(id) = game.steam.id {
if let Some(info) = steam_cache.0.get(id.to_string().as_str()) {
game.integrate_steam(info);
}
}
if let Some(overridden) = overrides.0.get(title) {
game.integrate_overrides(overridden);
}
if !game.usable() {
continue;
}
self.0.insert(title.to_string(), game);
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Game {
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub files: BTreeMap<String, GameFileEntry>,
#[serde(skip_serializing_if = "GogMetadata::is_empty")]
pub gog: GogMetadata,
#[serde(skip_serializing_if = "IdMetadata::is_empty")]
pub id: IdMetadata,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub install_dir: BTreeMap<String, GameInstallDirEntry>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub launch: BTreeMap<String, Vec<LaunchEntry>>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub registry: BTreeMap<String, GameRegistryEntry>,
#[serde(skip_serializing_if = "SteamMetadata::is_empty")]
pub steam: SteamMetadata,
}
impl Game {
pub fn integrate_wiki(&mut self, cache: &WikiCacheEntry, title: &str) {
self.steam = SteamMetadata { id: cache.steam };
self.gog = GogMetadata { id: cache.gog };
self.id = IdMetadata {
flatpak: None,
gog_extra: cache.gog_side.clone(),
steam_extra: cache.steam_side.clone(),
};
let paths = cache.parse_paths(title.to_string());
for path in paths {
let tags = path.tags.clone();
let tags2 = path.tags.clone();
match path.kind {
None | Some(PathKind::File) => {
let constraint = GameFileConstraint {
os: path.os,
store: path.store,
};
let constraint2 = constraint.clone();
self.files
.entry(path.composite)
.and_modify(|x| {
x.tags.extend(tags);
if !constraint.is_empty() {
x.when.insert(constraint);
}
})
.or_insert_with(|| GameFileEntry {
tags: tags2.into_iter().collect(),
when: (if constraint2.is_empty() {
vec![]
} else {
vec![constraint2]
})
.into_iter()
.collect(),
});
}
Some(PathKind::Registry) => {
let constraint = GameRegistryConstraint { store: path.store };
let constraint2 = constraint.clone();
self.registry
.entry(path.composite)
.and_modify(|x| {
x.tags.extend(tags);
if !constraint.is_empty() {
x.when.insert(constraint);
}
})
.or_insert_with(|| GameRegistryEntry {
tags: tags2.into_iter().collect(),
when: (if constraint2.is_empty() {
vec![]
} else {
vec![constraint2]
})
.into_iter()
.collect(),
});
}
}
}
}
pub fn integrate_steam(&mut self, cache: &SteamCacheEntry) {
if let Some(install_dir) = &cache.install_dir {
self.install_dir.insert(install_dir.to_string(), GameInstallDirEntry {});
}
for incoming in &cache.launch {
if incoming.executable.is_none()
|| incoming.executable.as_ref().map(|x| x.contains("://")).unwrap_or(false)
|| !matches!(incoming.r#type.as_deref(), None | Some("default" | "none"))
|| incoming.config.betakey.is_some()
|| incoming.config.ownsdlc.is_some()
{
continue;
}
let os = match incoming.config.oslist.as_deref() {
Some("windows") => Some(Os::Windows),
Some("macos" | "macosx") => Some(Os::Mac),
Some("linux") => Some(Os::Linux),
_ => None,
};
let bit = match incoming.config.osarch.as_deref() {
Some("32") => Some(32),
Some("64") => Some(64),
_ => None,
};
let constraint = LaunchConstraint {
bit,
os,
store: Some(Store::Steam),
};
let mut found_existing = false;
for (existing_executable, existing_options) in self.launch.iter_mut() {
for existing in existing_options {
if incoming.arguments == existing.arguments
&& do_launch_paths_match(incoming.executable.clone(), Some(existing_executable.to_string()))
&& do_launch_paths_match(incoming.workingdir.clone(), existing.working_dir.clone())
{
found_existing = true;
existing.when.insert(constraint.clone());
}
}
}
if !found_existing {
let Some(key) = incoming.executable.as_ref().and_then(|x| normalize_launch_path(x)) else {
continue;
};
let candidate = LaunchEntry {
arguments: incoming.arguments.clone(),
when: vec![constraint.clone()].into_iter().collect(),
working_dir: incoming.workingdir.as_ref().and_then(|x| normalize_launch_path(x)),
};
self.launch
.entry(key)
.and_modify(|x| x.push(candidate.clone()))
.or_insert_with(|| vec![candidate]);
}
}
}
pub fn integrate_overrides(&mut self, overridden: &OverrideGame) {
if let Some(id) = overridden.game.steam.id {
self.steam.id = Some(id);
}
if let Some(id) = overridden.game.gog.id {
self.gog.id = Some(id);
}
if let Some(flatpak) = overridden.game.id.flatpak.as_ref() {
self.id.flatpak = Some(flatpak.clone());
}
self.install_dir.extend(overridden.game.install_dir.clone());
}
pub fn usable(&self) -> bool {
!(self.files.is_empty()
&& self.registry.is_empty()
&& self.steam.is_empty()
&& self.gog.is_empty()
&& self.id.is_empty())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GameFileEntry {
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub tags: BTreeSet<Tag>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub when: BTreeSet<GameFileConstraint>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GameInstallDirEntry {}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GameRegistryEntry {
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub tags: BTreeSet<Tag>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub when: BTreeSet<GameRegistryConstraint>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LaunchEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<String>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub when: BTreeSet<LaunchConstraint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GameFileConstraint {
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<Os>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<Store>,
}
impl GameFileConstraint {
pub fn is_empty(&self) -> bool {
self.os.is_none() && self.store.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GameRegistryConstraint {
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<Store>,
}
impl GameRegistryConstraint {
pub fn is_empty(&self) -> bool {
self.store.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LaunchConstraint {
#[serde(skip_serializing_if = "Option::is_none")]
pub bit: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<Os>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<Store>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SteamMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u32>,
}
impl SteamMetadata {
pub fn is_empty(&self) -> bool {
self.id.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GogMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
}
impl GogMetadata {
pub fn is_empty(&self) -> bool {
self.id.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct IdMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub flatpak: Option<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub gog_extra: BTreeSet<u64>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub steam_extra: BTreeSet<u32>,
}
impl IdMetadata {
pub fn is_empty(&self) -> bool {
self.flatpak.is_none() && self.gog_extra.is_empty() && self.steam_extra.is_empty()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ManifestOverride(pub BTreeMap<String, OverrideGame>);
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OverrideGame {
pub omit: bool,
#[serde(flatten)]
pub game: Game,
}
impl ResourceFile for ManifestOverride {
const FILE_NAME: &'static str = "data/manifest-override.yaml";
}

View file

@ -1,292 +0,0 @@
import { REPO, YamlFile } from ".";
import { SteamGameCache, SteamGameCacheFile } from "./steam";
import { WikiGameCache, parseTemplates } from "./wiki";
const U32_MAX = 4_294_967_295;
const U64_MAX = 18_446_744_073_709_551_615;
export type Os = "dos" | "linux" | "mac" | "windows";
export type Bit = 32 | 64;
export type Store = "epic" | "gog" | "microsoft" | "steam" | "uplay" | "origin";
export type Tag = "config" | "save";
export interface Manifest {
[game: string]: Game;
}
export interface Game {
files?: {
[path: string]: {
when?: Array<Omit<Constraint, "bit">>,
tags?: Array<Tag>,
}
};
installDir?: {
[name: string]: {}
};
launch?: {
[path: string]: Array<{
arguments?: string;
workingDir?: string;
when?: Array<Constraint>,
}>
},
registry?: {
[path: string]: {
when?: Array<Omit<Constraint, "bit" | "os">>,
tags?: Array<Tag>,
}
};
steam?: {
id?: number
};
gog?: {
id?: number,
};
id?: {
flatpak?: string,
gogExtra?: Array<number>,
steamExtra?: Array<number>,
};
}
type OverriddenGame = Game & { omit?: boolean };
export interface Constraint {
os?: Os;
bit?: Bit;
store?: Store;
}
function normalizeLaunchPath(raw: string): string | undefined {
if (raw.includes("://")) {
return raw;
}
const standardized = raw
.replace(/\\/g, "/")
.replace(/\/\//g, "/")
.replace(/\/(?=$)/g, "")
.replace(/^.\//, "")
.replace(/^\/+/, "")
.trim();
if (standardized.length === 0 || standardized === ".") {
return undefined;
}
return `<base>/${standardized}`;
}
function doLaunchPathsMatch(fromSteam: string | undefined, fromManifest: string | undefined): boolean {
if (fromSteam === undefined) {
return fromManifest === undefined;
} else {
return normalizeLaunchPath(fromSteam) === fromManifest;
}
}
function integrateWikiData(game: Game, cache: WikiGameCache[""]): void {
game.id = {};
if (cache.steam !== undefined && cache.steam <= U32_MAX) {
game.steam = { id: cache.steam };
}
if (cache.steamSide !== undefined) {
game.id.steamExtra = cache.steamSide.filter(x => x <= U32_MAX);
}
if (cache.gog !== undefined && cache.gog <= U64_MAX) {
game.gog = { id: cache.gog };
}
if (cache.gogSide !== undefined) {
game.id.gogExtra = cache.gogSide.filter(x => x <= U64_MAX);
}
const info = parseTemplates(cache.templates ?? []);
game.files = info.files;
game.registry = info.registry;
if (game.id.flatpak === undefined && game.id.gogExtra === undefined && game.id.steamExtra === undefined) {
delete game.id;
}
}
function integrateSteamData(game: Game, appInfo: SteamGameCache[""] | undefined): void {
if (appInfo === undefined) {
return;
}
if (appInfo.installDir !== undefined) {
game.installDir = { [appInfo.installDir]: {} };
}
if (appInfo.launch !== undefined) {
delete game.launch;
for (const incoming of appInfo.launch) {
if (
incoming.executable === undefined ||
incoming.executable.includes("://") ||
![undefined, "default", "none"].includes(incoming.type) ||
incoming.config?.betakey !== undefined ||
incoming.config?.ownsdlc !== undefined
) {
continue;
}
const os: Os | undefined = {
"windows": "windows",
"macos": "mac",
"macosx": "mac",
"linux": "linux",
}[incoming.config?.oslist] as Os;
const bit: Bit | undefined = {
"32": 32,
"64": 64,
}[incoming.config?.osarch] as Bit;
const when: Constraint = { os, bit, store: "steam" };
if (when.os === undefined) {
delete when.os;
}
if (when.bit === undefined) {
delete when.bit;
}
let foundExisting = false;
for (const [existingExecutable, existingOptions] of Object.entries(game.launch ?? {})) {
for (const existing of existingOptions) {
if (
incoming.arguments === existing.arguments &&
doLaunchPathsMatch(incoming.executable, existingExecutable) &&
doLaunchPathsMatch(incoming.workingdir, existing.workingDir)
) {
foundExisting = true;
if (existing.when === undefined) {
existing.when = [];
}
if (existing.when.every(x => x.os !== os && x.bit !== bit && x.store !== "steam")) {
existing.when.push(when);
}
if (existing.when.length === 0) {
delete existing.when;
}
}
}
}
if (!foundExisting) {
const key = normalizeLaunchPath(incoming.executable);
if (key === undefined) {
continue;
}
const candidate: Game["launch"][""][0] = { when: [when] };
if (incoming.arguments !== undefined) {
candidate.arguments = incoming.arguments;
}
if (incoming.workingdir !== undefined) {
const workingDir = normalizeLaunchPath(incoming.workingdir);
if (workingDir !== undefined) {
candidate.workingDir = workingDir;
}
}
if (game.launch === undefined) {
game.launch = {};
}
if (game.launch[key] === undefined) {
game.launch[key] = [];
}
game.launch[key].push(candidate);
}
}
}
}
function integrateOverriddenData(game: Game, override: OverriddenGame) {
if (override.steam) {
game.steam = override.steam;
}
if (override.gog) {
game.gog = override.gog;
}
if (override.id) {
if (game.id === undefined) {
game.id = {};
}
if (override.id.flatpak) {
game.id.flatpak = override.id.flatpak;
}
}
if (override.installDir) {
if (game.installDir === undefined) {
game.installDir = {};
}
for (const key in override.installDir) {
game.installDir[key] = {};
}
}
}
function hasAnyData(game: Game): boolean {
return game.files !== undefined || game.registry !== undefined || game.steam?.id !== undefined || game.gog?.id !== undefined || game.id !== undefined;
}
export class ManifestFile extends YamlFile<Manifest> {
path = `${REPO}/data/manifest.yaml`;
defaultData = {};
async updateGames(
wikiCache: WikiGameCache,
games: Array<string>,
steamCache: SteamGameCacheFile,
override: ManifestOverrideFile,
): Promise<void> {
this.data = {};
for (const [title, info] of Object.entries(wikiCache).sort()) {
const overridden = override.data[title];
if (overridden?.omit) {
continue;
}
if (games?.length > 0 && !games.includes(title)) {
continue;
}
const game: Game = {};
integrateWikiData(game, info);
if (game.steam?.id !== undefined) {
const appInfo = await steamCache.getAppInfo(game.steam.id);
integrateSteamData(game, appInfo);
}
if (overridden) {
integrateOverriddenData(game, overridden);
}
if (!hasAnyData(game)) {
continue;
}
this.data[title] = game;
}
for (const [title, info] of Object.entries(override.data).sort()) {
if (title in this.data || info.omit) {
continue;
}
delete info.omit;
this.data[title] = info;
}
}
}
export interface ManifestOverride {
[game: string]: OverriddenGame
}
export class ManifestOverrideFile extends YamlFile<ManifestOverride> {
path = `${REPO}/data/manifest-override.yaml`;
defaultData = {};
}

26
src/missing.rs Normal file
View file

@ -0,0 +1,26 @@
use itertools::Itertools;
use crate::{
manifest::{Manifest, ManifestOverride},
wiki::WikiCache,
REPO,
};
pub fn save_missing_games(wiki_cache: &WikiCache, manifest: &Manifest, overrides: &ManifestOverride) {
let lines: Vec<String> = wiki_cache
.0
.iter()
.sorted_by(|(k1, _), (k2, _)| k1.to_lowercase().cmp(&k2.to_lowercase()))
.filter(|(k, _)| {
manifest
.0
.get(*k)
.map(|x| x.files.is_empty() && x.registry.is_empty())
.unwrap_or(true)
})
.filter(|(k, _)| overrides.0.get(*k).map(|x| !x.omit).unwrap_or(true))
.map(|(k, v)| format!("* [{}](https://www.pcgamingwiki.com/wiki/?curid={})", k, v.page_id))
.collect();
_ = std::fs::write(format!("{}/data/missing.md", REPO), lines.join("\n") + "\n");
}

View file

@ -1,16 +0,0 @@
import * as fs from "fs";
import { REPO } from ".";
import { Manifest, ManifestOverride } from "./manifest";
import { WikiGameCache } from "./wiki";
export function saveMissingGames(cache: WikiGameCache, manifest: Manifest, override: ManifestOverride): void {
fs.writeFileSync(
`${REPO}/data/missing.md`,
Object.entries(cache)
.sort((x, y) => x[0].localeCompare(y[0]))
.filter(([k, _]) => (manifest[k]?.files ?? []).length === 0 && (manifest[k]?.registry ?? []).length === 0)
.filter(([k, _]) => override[k]?.omit !== true)
.map(([k, v]) => `* [${k}](https://www.pcgamingwiki.com/wiki/?curid=${v.pageId})`)
.join("\n") + "\n",
);
}

63
src/resource.rs Normal file
View file

@ -0,0 +1,63 @@
use crate::REPO;
pub type AnyError = Box<dyn std::error::Error>;
pub trait ResourceFile
where
Self: Default + serde::Serialize + serde::de::DeserializeOwned,
{
const FILE_NAME: &'static str;
fn path() -> std::path::PathBuf {
let mut path = std::path::PathBuf::new();
path.push(REPO);
path.push(Self::FILE_NAME);
path
}
/// If the resource file does not exist, use default data and apply these modifications.
fn initialize(self) -> Self {
self
}
/// Update any legacy settings on load.
fn migrate(self) -> Self {
self
}
fn load() -> Result<Self, AnyError> {
Self::load_from(&Self::path())
}
fn load_from(path: &std::path::PathBuf) -> Result<Self, AnyError> {
if !path.exists() {
return Ok(Self::default().initialize());
}
let content = Self::load_raw(path)?;
Self::load_from_string(&content)
}
fn load_raw(path: &std::path::PathBuf) -> Result<String, AnyError> {
Ok(std::fs::read_to_string(path)?)
}
fn load_from_string(content: &str) -> Result<Self, AnyError> {
Ok(ResourceFile::migrate(serde_yaml::from_str(content)?))
}
fn serialize(&self) -> String {
serde_yaml::to_string(&self).unwrap()
}
fn save(&self) {
let new_content = serde_yaml::to_string(&self).unwrap();
if let Ok(old_content) = Self::load_raw(&Self::path()) {
if old_content == new_content {
return;
}
}
let _ = std::fs::write(Self::path(), new_content.as_bytes());
}
}

32
src/schema.rs Normal file
View file

@ -0,0 +1,32 @@
use crate::{manifest::Manifest, resource::ResourceFile, Error, REPO};
pub fn validate_manifest(manifest: &Manifest) -> Result<(), Error> {
let manifest: serde_json::Value = serde_yaml::from_str(&manifest.serialize()).unwrap();
let normal: serde_json::Value = serde_yaml::from_str(&read_data("schema.yaml")).unwrap();
let strict: serde_json::Value = serde_yaml::from_str(&read_data("schema.strict.yaml")).unwrap();
for schema in [normal, strict] {
if !check(&schema, &manifest) {
return Err(Error::ManifestSchema);
}
}
Ok(())
}
fn read_data(file: &str) -> String {
std::fs::read_to_string(format!("{}/data/{}", REPO, file)).unwrap()
}
fn check(schema: &serde_json::Value, instance: &serde_json::Value) -> bool {
let mut valid = true;
let compiled = jsonschema::JSONSchema::compile(schema).unwrap();
if let Err(errors) = compiled.validate(instance) {
valid = false;
for error in errors {
println!("Schema error: {} | {}", error, error.instance_path);
}
}
valid
}

218
src/steam.rs Normal file
View file

@ -0,0 +1,218 @@
use std::{collections::BTreeMap, process::Command};
use crate::{resource::ResourceFile, wiki::WikiCache, Error, State, REPO};
const SAVE_INTERVAL: u32 = 100;
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct SteamCache(pub BTreeMap<String, SteamCacheEntry>);
impl ResourceFile for SteamCache {
const FILE_NAME: &'static str = "data/steam-game-cache.yaml";
}
impl SteamCache {
pub fn refresh(&mut self, outdated_only: bool, app_ids: Option<Vec<u32>>) -> Result<(), Error> {
let mut i = 0;
let app_ids: Vec<_> = app_ids.unwrap_or_else(|| {
self.0
.iter()
.filter(|(_, v)| !outdated_only || v.state == State::Outdated)
.filter_map(|(k, _)| k.parse::<u32>().ok())
.collect()
});
for app_id in app_ids {
let latest = SteamCacheEntry::fetch_from_id(app_id)?;
self.0.insert(app_id.to_string(), latest);
i += 1;
if i % SAVE_INTERVAL == 0 {
self.save();
println!("\n:: saved\n");
}
}
Ok(())
}
pub fn transition_states_from(&mut self, wiki_cache: &mut WikiCache) {
for wiki in wiki_cache.0.values_mut() {
if wiki.state == State::Updated {
if let Some(id) = wiki.steam {
self.0
.entry(id.to_string())
.and_modify(|x| {
x.state = State::Outdated;
})
.or_insert(SteamCacheEntry {
state: State::Outdated,
..Default::default()
});
}
wiki.state = State::Handled;
}
}
}
}
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SteamCacheEntry {
#[serde(skip_serializing_if = "State::is_handled")]
pub state: State,
#[serde(skip_serializing_if = "Option::is_none")]
pub install_dir: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub launch: Vec<Launch>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub name_localized: BTreeMap<String, String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Launch {
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<String>,
#[serde(skip_serializing_if = "LaunchConfig::is_empty")]
pub config: LaunchConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub executable: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workingdir: Option<String>,
}
impl Launch {
pub fn is_empty(&self) -> bool {
self.arguments.is_none()
&& self.config.is_empty()
&& self.description.is_none()
&& self.executable.is_none()
&& self.r#type.is_none()
&& self.workingdir.is_none()
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LaunchConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub betakey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub osarch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub oslist: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ownsdlc: Option<String>,
}
impl LaunchConfig {
pub fn is_empty(&self) -> bool {
self.betakey.is_none() && self.osarch.is_none() && self.oslist.is_none() && self.ownsdlc.is_none()
}
}
mod product_info {
use super::*;
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct Response {
pub apps: BTreeMap<String, App>,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct App {
pub common: AppCommon,
pub config: AppConfig,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct AppCommon {
pub name_localized: BTreeMap<String, String>,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub installdir: Option<String>,
pub launch: BTreeMap<String, AppLaunch>,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct AppLaunch {
pub executable: Option<String>,
pub arguments: Option<String>,
pub workingdir: Option<String>,
pub r#type: Option<String>,
pub config: AppLaunchConfig,
pub description: Option<String>,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[serde(default)]
pub struct AppLaunchConfig {
pub betakey: Option<String>,
pub osarch: Option<String>,
pub oslist: Option<String>,
pub ownsdlc: Option<String>,
}
}
impl SteamCacheEntry {
pub fn fetch_from_id(app_id: u32) -> Result<Self, Error> {
println!("Steam: {}", app_id);
let mut cmd = Command::new("python");
cmd.arg(format!("{}/scripts/get-steam-app-info.py", REPO));
cmd.arg(app_id.to_string());
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("Steam product info failure: {}", &stderr);
return Err(Error::SteamProductInfo);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let response = serde_json::from_str::<product_info::Response>(&stdout).map_err(|_| Error::SteamProductInfo)?;
let app = response
.apps
.get(&app_id.to_string())
.ok_or(Error::SteamProductInfo)?
.clone();
let launch: Vec<_> = app
.config
.launch
.into_values()
.map(|x| Launch {
executable: x.executable,
arguments: x.arguments,
workingdir: x.workingdir,
r#type: x.r#type,
description: x.description,
config: LaunchConfig {
betakey: x.config.betakey,
osarch: x.config.osarch,
oslist: x.config.oslist,
ownsdlc: x.config.ownsdlc,
},
})
.filter(|x| !x.is_empty())
.collect();
Ok(Self {
state: State::Handled,
install_dir: app.config.installdir,
name_localized: app.common.name_localized,
launch,
})
}
}

View file

@ -1,158 +0,0 @@
import { DELAY_BETWEEN_GAMES_MS, REPO, YamlFile } from ".";
import SteamUser from "steam-user";
export type SteamGameCache = {
[appId: string]: {
installDir?: string,
unknown?: boolean,
nameLocalized?: Map<string, string>;
launch?: Array<{
executable?: string;
arguments?: string;
workingdir?: string;
type?: string;
config?: {
betakey?: string;
osarch?: string;
oslist?: string;
ownsdlc?: string;
},
}>;
irregular?: boolean;
};
};
export class SteamGameCacheFile extends YamlFile<SteamGameCache> {
path = `${REPO}/data/steam-game-cache.yaml`;
defaultData = {};
steamClient: SteamUser | null = null;
constructor(private makeSteamClient: () => Promise<SteamUser>) {
super();
}
hasIrregularKeys(info: object): boolean {
return Object.keys(info).some(x => x.endsWith('"'));
}
isIrregularString(info: string): boolean {
return info.includes('"\n\t');
}
async getAppInfo(appId: number, update: boolean = false): Promise<SteamGameCache[""] | undefined> {
const key = appId.toString();
if (!update && this.data.hasOwnProperty(key)) {
return this.data[key];
}
console.log(`Steam: ${appId}`);
if (this.steamClient === null) {
this.steamClient = await this.makeSteamClient();
}
const info: SteamProductInfoResponse = await this.steamClient.getProductInfo([appId], []);
if (info.unknownApps.includes(appId)) {
this.data[key] = { unknown: true };
return undefined;
}
this.data[key] = {};
const installDir = info.apps[key].appinfo.config?.installdir;
if (installDir !== undefined) {
this.data[key].installDir = installDir;
}
const nameLocalized = info.apps[key].appinfo.common?.name_localized;
if (nameLocalized !== undefined && Object.keys(nameLocalized).length > 0) {
this.data[key].nameLocalized = nameLocalized;
}
const launch = info.apps[key].appinfo.config?.launch;
if (launch !== undefined) {
const keys = Object.keys(launch).sort((x, y) => parseInt(x) - parseInt(y));
const launchArray = keys.map(x => launch[x]);
this.data[key].launch = launchArray;
}
return this.data[key];
}
async refresh(skipUntil: string | undefined, irregularTagged: boolean, irregularUntagged: boolean, limit: number, appIds: Array<string>): Promise<void> {
let i = 0;
let foundSkipUntil = false;
for (const appId of Object.keys(this.data).sort()) {
if (skipUntil && !foundSkipUntil) {
if (appId === skipUntil) {
foundSkipUntil = true;
} else {
continue;
}
}
if (appIds.length > 0 && !appIds.includes(appId)) {
continue;
}
if (irregularTagged) {
if (!this.data[appId].irregular) {
continue;
}
}
if (irregularUntagged) {
const irregular = (this.data[appId].launch ?? []).some(x => this.hasIrregularKeys(x)) ||
this.isIrregularString(this.data[appId].installDir ?? "");
if (this.data[appId].irregular || !irregular) {
continue;
}
}
// console.log(`Refreshing Steam app ${appId}`);
await this.getAppInfo(parseInt(appId), true);
i++;
if (limit > 0 && i >= limit) {
break;
}
// main() will save at the end, but we do a periodic save as well
// in case something goes wrong or the script gets cancelled:
if (i % 250 === 0) {
this.save();
console.log(":: saved");
}
await new Promise(resolve => setTimeout(resolve, DELAY_BETWEEN_GAMES_MS));
}
}
}
interface SteamProductInfoResponse {
apps: {
[appId: string]: {
appinfo: {
common?: {
name_localized?: Map<string, string>,
},
config?: {
installdir?: string,
launch?: object,
},
},
},
},
unknownApps: Array<number>,
}
export async function getSteamClient(): Promise<SteamUser> {
const client = new SteamUser();
client.logOn({ anonymous: true });
await new Promise<void>(resolve => {
client.on("loggedOn", () => {
resolve();
});
});
return client;
}

937
src/wiki.rs Normal file
View file

@ -0,0 +1,937 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use once_cell::sync::Lazy;
use regex::Regex;
use wikitext_parser::{Attribute, TextPiece};
use crate::{
manifest::{placeholder, Os, Store, Tag},
resource::ResourceFile,
Error, State,
};
const SAVE_INTERVAL: u32 = 100;
async fn make_client() -> Result<mediawiki::api::Api, Error> {
mediawiki::api::Api::new("https://www.pcgamingwiki.com/w/api.php")
.await
.map_err(Error::WikiClient)
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct WikiCache(pub BTreeMap<String, WikiCacheEntry>);
impl ResourceFile for WikiCache {
const FILE_NAME: &'static str = "data/wiki-game-cache.yaml";
}
async fn get_page_title(id: u64) -> Result<Option<String>, Error> {
let wiki = make_client().await?;
let params = wiki.params_into(&[("action", "query"), ("pageids", id.to_string().as_str())]);
let res = wiki.get_query_api_json_all(&params).await?;
for page in res["query"]["pages"]
.as_object()
.ok_or(Error::WikiData("query.pages"))?
.values()
{
let found_id = page["pageid"].as_u64().ok_or(Error::WikiData("query.pages[].pageid"))?;
if found_id == id {
let title = page["title"].as_str();
return Ok(title.map(|x| x.to_string()));
}
}
Ok(None)
}
async fn is_game_article(query: &str) -> Result<bool, Error> {
let wiki = make_client().await?;
let params = wiki.params_into(&[("action", "query"), ("prop", "categories"), ("titles", query)]);
let res = wiki.get_query_api_json_all(&params).await?;
for page in res["query"]["pages"]
.as_object()
.ok_or(Error::WikiData("query.pages"))?
.values()
{
let title = page["title"].as_str().ok_or(Error::WikiData("query.pages[].title"))?;
if title == query {
for category in page["categories"]
.as_array()
.ok_or(Error::WikiData("query.pages[].categories"))?
{
let category_name = category["title"]
.as_str()
.ok_or(Error::WikiData("query.pages[].categories[].title"))?;
if category_name == "Category:Games" {
return Ok(true);
}
}
}
}
Ok(false)
}
impl WikiCache {
pub async fn flag_recent_changes(&mut self, meta: &mut WikiMetaCache) -> Result<(), Error> {
struct RecentChange {
page_id: u64,
}
let start = meta.last_checked_recent_changes - chrono::Duration::minutes(1);
let end = chrono::Utc::now();
println!("Getting recent changes from {} to {}", start, end);
let wiki = make_client().await?;
let params = wiki.params_into(&[
("action", "query"),
("list", "recentchanges"),
("rcprop", "title|ids|redirect"),
("rcdir", "newer"),
("rcstart", &start.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
("rcend", &end.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
("rclimit", "500"),
("rcnamespace", "0"),
("rctype", "edit|new"),
]);
let res = wiki.get_query_api_json_all(&params).await?;
let mut changes = BTreeMap::<String, RecentChange>::new();
for change in res["query"]["recentchanges"]
.as_array()
.ok_or(Error::WikiData("query.recentchanges"))?
{
let title = change["title"]
.as_str()
.ok_or(Error::WikiData("query.recentchanges[].title"))?
.to_string();
let page_id = change["pageid"]
.as_u64()
.ok_or(Error::WikiData("query.recentchanges[].pageid"))?;
let redirect = change["redirect"].is_string();
if !redirect {
// We don't need the entries for the redirect pages themselves.
// We'll update our data when we get to the entry for the new page name.
changes.insert(title, RecentChange { page_id });
}
}
for (title, RecentChange { page_id }) in changes {
if self.0.contains_key(&title) {
// Existing entry has been edited.
println!("[E ] {}", &title);
self.0
.entry(title.to_string())
.and_modify(|x| x.state = State::Outdated);
} else {
// Check for a rename.
let mut old_name = None;
for (existing_name, existing_info) in &self.0 {
if existing_info.page_id == page_id {
// We have a confirmed rename.
println!("[ M ] {} <<< {}", &title, existing_name);
old_name = Some(existing_name.clone());
break;
}
}
match old_name {
None => {
// Brand new page.
match is_game_article(&title).await {
Ok(true) => {
// It's a game, so add it to the cache.
println!("[ C] {}", &title);
self.0.insert(
title.to_string(),
WikiCacheEntry {
page_id,
state: State::Outdated,
..Default::default()
},
);
}
Ok(false) => {
// Ignore since it's not relevant.
}
Err(e) => {
eprintln!("Unable to check if article is for a game: {} | {}", &title, e);
}
}
}
Some(old_name) => {
self.0.entry(title.to_string()).and_modify(|x| {
x.page_id = page_id;
x.state = State::Outdated;
x.renamed_from.push(old_name);
});
}
}
}
}
meta.last_checked_recent_changes = end;
Ok(())
}
pub async fn add_new_games(&mut self) -> Result<(), Error> {
let wiki = make_client().await?;
let params = wiki.params_into(&[
("action", "query"),
("list", "categorymembers"),
("cmtitle", "Category:Games"),
("cmlimit", "500"),
]);
let res = wiki.get_query_api_json_all(&params).await?;
for page in res["query"]["categorymembers"]
.as_array()
.ok_or(Error::WikiData("query.categorymembers"))?
{
let title = page["title"]
.as_str()
.ok_or(Error::WikiData("query.categorymembers[].title"))?;
let page_id = page["pageid"]
.as_u64()
.ok_or(Error::WikiData("query.categorymembers[].pageid"))?;
if self.0.contains_key(title) {
continue;
}
let mut old_name = None;
for (existing_name, existing_info) in &self.0 {
if existing_info.page_id == page_id {
old_name = Some(existing_name.to_string());
}
}
match old_name {
None => {
self.0.insert(
title.to_string(),
WikiCacheEntry {
page_id,
state: State::Outdated,
..Default::default()
},
);
}
Some(old_name) => {
let mut data = self.0[&old_name].clone();
data.state = State::Outdated;
if !data.renamed_from.contains(&old_name) {
data.renamed_from.push(old_name.clone());
}
self.0.insert(title.to_string(), data);
self.0.remove(&old_name);
}
}
}
Ok(())
}
pub async fn refresh(&mut self, outdated_only: bool, titles: Option<Vec<String>>) -> Result<(), Error> {
let mut i = 0;
let solo = titles.is_some();
let titles: Vec<_> = titles.unwrap_or_else(|| self.0.keys().cloned().collect());
for title in &titles {
let cached = self.0.get(title).cloned().unwrap_or_default();
if outdated_only && cached.state != State::Outdated {
continue;
}
println!("Wiki: {}", title);
let latest = WikiCacheEntry::fetch_from_page(title.clone()).await;
match latest {
Ok(mut latest) => {
latest.renamed_from = cached.renamed_from.clone();
self.0.insert(title.to_string(), latest);
}
Err(Error::PageMissing) => {
if solo {
return Err(Error::PageMissing);
}
// Couldn't find it by name, so try again by ID.
// This can happen for pages moved without leaving a redirect.
// (If they have a redirect, then the recent changes code takes care of it.)
let Some(new_title) = get_page_title(cached.page_id).await? else {
return Err(Error::PageMissing);
};
println!(
":: refresh: page {} called '{}' renamed to '{}'",
cached.page_id, title, &new_title
);
let mut data = self.0[title].clone();
data.renamed_from.push(title.clone());
self.0.insert(new_title.clone(), data);
self.0.remove(title);
let mut latest = WikiCacheEntry::fetch_from_page(title.clone()).await?;
latest.renamed_from = cached.renamed_from.clone();
self.0.insert(new_title.clone(), latest);
}
Err(e) => {
return Err(e);
}
}
i += 1;
if i % SAVE_INTERVAL == 0 {
self.save();
println!("\n:: saved\n");
}
}
Ok(())
}
}
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct WikiCacheEntry {
#[serde(skip_serializing_if = "State::is_handled")]
pub state: State,
#[serde(skip_serializing_if = "Option::is_none")]
pub gog: Option<u64>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub gog_side: BTreeSet<u64>,
pub page_id: u64,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub renamed_from: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub steam: Option<u32>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub steam_side: BTreeSet<u32>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub templates: Vec<String>,
}
impl WikiCacheEntry {
pub async fn fetch_from_page(article: String) -> Result<Self, Error> {
let mut out = WikiCacheEntry {
state: State::Updated,
..Default::default()
};
let wiki = make_client().await?;
let params = wiki.params_into(&[("action", "parse"), ("prop", "wikitext"), ("page", &article)]);
let res = wiki
.get_query_api_json_all(&params)
.await
.map_err(|_| Error::PageMissing)?;
out.page_id = res["parse"]["pageid"].as_u64().ok_or(Error::WikiData("parse.pageid"))?;
let raw_wikitext = res["parse"]["wikitext"]["*"]
.as_str()
.ok_or(Error::WikiData("parse.wikitext"))?;
let wikitext = wikitext_parser::parse_wikitext(raw_wikitext, article, |_| ());
for template in wikitext.list_double_brace_expressions() {
if let wikitext_parser::TextPiece::DoubleBraceExpression { tag, attributes } = &template {
match tag.to_string().to_lowercase().trim() {
"infobox game" => {
for attribute in attributes {
match attribute.name.as_deref() {
Some("steam appid") => {
if let Ok(value) = attribute.value.to_string().parse::<u32>() {
if value > 0 {
out.steam = Some(value);
}
}
}
Some("steam appid side") => {
out.steam_side = attribute
.value
.to_string()
.split(',')
.filter_map(|x| x.trim().parse::<u32>().ok())
.filter(|x| *x > 0)
.collect();
}
Some("gogcom id") => {
if let Ok(value) = attribute.value.to_string().parse::<u64>() {
if value > 0 {
out.gog = Some(value);
}
}
}
Some("gogcom id side") => {
out.gog_side = attribute
.value
.to_string()
.split(',')
.filter_map(|x| x.trim().parse::<u64>().ok())
.filter(|x| *x > 0)
.collect();
}
_ => {}
}
}
}
"game data" => {
for attribute in attributes {
for template in &attribute.value.pieces {
if let wikitext_parser::TextPiece::DoubleBraceExpression { tag, .. } = &template {
let is_save = tag.to_string() == "Game data/saves";
let is_config = tag.to_string() == "Game data/config";
if is_save || is_config {
out.templates.push(template.to_string());
}
}
}
}
}
_ => {}
}
}
}
Ok(out)
}
/// The parser does not handle HTML tags, so we remove some tags that are only used for annotations.
/// Others, like `code` and `sup`, are used both for path segments and annotations,
/// so we can't assume how to replace them properly.
fn preprocess_template(template: &str) -> String {
let mut out = template.to_string();
static HTML_COMMENT: Lazy<Regex> = Lazy::new(|| Regex::new(r"<!--.+?-->").unwrap());
static HTML_REF: Lazy<Regex> = Lazy::new(|| Regex::new(r"<ref>.+?</ref>").unwrap());
for (pattern, replacement) in [(&HTML_COMMENT, ""), (&HTML_REF, "")] {
out = pattern.replace_all(&out, replacement).to_string();
}
out
}
pub fn parse_paths(&self, article: String) -> Vec<WikiPath> {
let mut out = vec![];
for raw in &self.templates {
let preprocessed = Self::preprocess_template(raw);
let parsed = wikitext_parser::parse_wikitext(&preprocessed, article.clone(), |_| ());
for template in parsed.list_double_brace_expressions() {
if let wikitext_parser::TextPiece::DoubleBraceExpression { tag, attributes } = &template {
let is_save = tag.to_string() == "Game data/saves";
let is_config = tag.to_string() == "Game data/config";
if (!is_save && !is_config) || attributes.len() < 2 {
continue;
}
let platform = attributes[0].value.to_string();
for attribute in attributes.iter().skip(1) {
let info = flatten_path(attribute)
.with_platform(&platform)
.with_tags(is_save, is_config)
.normalize();
if info.usable() {
out.push(info);
}
}
}
}
}
out
}
}
#[derive(Debug, Clone, Copy)]
pub enum PathKind {
File,
Registry,
}
#[derive(Debug, Default)]
pub struct WikiPath {
pub composite: String,
pub irregular: bool,
pub kind: Option<PathKind>,
pub store: Option<Store>,
pub os: Option<Os>,
pub tags: BTreeSet<Tag>,
}
impl WikiPath {
fn incorporate(&mut self, other: Self) {
if other.irregular {
self.irregular = true;
}
if other.kind.is_some() {
self.kind = other.kind;
}
if other.store.is_some() {
self.store = other.store;
}
if other.os.is_some() {
self.os = other.os;
}
}
pub fn incorporate_text(&mut self, text: &str) {
if text.contains(['<', '>']) {
self.irregular = true;
} else {
self.composite += text;
}
}
pub fn incorporate_raw(&mut self, other: Self) {
self.incorporate_text(&other.composite);
self.incorporate(other)
}
pub fn incorporate_path(&mut self, other: Self) {
if let Some(mapped) = MAPPED_PATHS.get(other.composite.to_lowercase().as_str()) {
self.composite += mapped.manifest;
if mapped.kind.is_some() {
self.kind = mapped.kind;
}
if mapped.store.is_some() {
self.store = mapped.store;
}
if mapped.os.is_some() {
self.os = mapped.os;
}
}
self.incorporate(other)
}
pub fn normalize(mut self) -> Self {
self.composite = self.composite.trim().trim_end_matches(['/', '\\']).replace('\\', "/");
if self.composite == "~" || self.composite.starts_with("~/") {
self.composite = self.composite.replacen('~', placeholder::HOME, 1);
}
static CONSECUTIVE_SLASHES: Lazy<Regex> = Lazy::new(|| Regex::new(r"/{2,}").unwrap());
static ENDING_WILDCARD: Lazy<Regex> = Lazy::new(|| Regex::new(r"(/\*)+$").unwrap());
static APP_DATA: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)%appdata%").unwrap());
static APP_DATA_ROAMING: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)%userprofile%/AppData/Roaming").unwrap());
static APP_DATA_LOCAL: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)%localappdata%").unwrap());
static APP_DATA_LOCAL_2: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)%userprofile%/AppData/Local/").unwrap());
static USER_PROFILE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)%userprofile%").unwrap());
static DOCUMENTS: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)%userprofile%/Documents").unwrap());
for (pattern, replacement) in [
(&CONSECUTIVE_SLASHES, "/"),
(&ENDING_WILDCARD, ""),
(&APP_DATA, placeholder::WIN_APP_DATA),
(&APP_DATA_ROAMING, placeholder::WIN_APP_DATA),
(&APP_DATA_LOCAL, placeholder::WIN_LOCAL_APP_DATA),
(&APP_DATA_LOCAL_2, &format!("{}/", placeholder::WIN_LOCAL_APP_DATA)),
(&USER_PROFILE, placeholder::HOME),
(&DOCUMENTS, placeholder::WIN_DOCUMENTS),
] {
self.composite = pattern.replace_all(&self.composite, replacement).to_string();
}
if self.kind.is_none() {
self.kind = Some(PathKind::File);
}
self
}
pub fn with_platform(mut self, platform: &str) -> Self {
match platform.to_lowercase().trim() {
"windows" => {
self.os = Some(Os::Windows);
}
"os x" => {
self.os = Some(Os::Mac);
}
"linux" => {
self.os = Some(Os::Linux);
}
"dos" => {
self.os = Some(Os::Dos);
}
"steam" => {
self.store = Some(Store::Steam);
}
"microsoft store" => {
self.os = Some(Os::Windows);
self.store = Some(Store::Microsoft);
}
"gog.com" => {
self.store = Some(Store::Gog);
}
"epic games" => {
self.store = Some(Store::Epic);
}
"uplay" => {
self.store = Some(Store::Uplay);
}
"origin" => {
self.store = Some(Store::Origin);
}
_ => {}
}
self
}
pub fn with_tags(mut self, save: bool, config: bool) -> Self {
if save {
self.tags.insert(Tag::Save);
}
if config {
self.tags.insert(Tag::Config);
}
self
}
fn too_broad(&self) -> bool {
use placeholder::{BASE, HOME, ROOT, STORE_USER_ID, WIN_DIR, WIN_DOCUMENTS};
let placeholders: Vec<_> = MAPPED_PATHS.values().map(|x| x.manifest).collect();
if placeholders.iter().any(|x| *x == self.composite) {
return true;
}
// These paths are present whether or not the game is installed.
// If possible, they should be narrowed down on the wiki.
let broad = vec![
format!("{BASE}/{STORE_USER_ID}"), // because `<storeUserId>` is handled as `*`
format!("{HOME}/Documents"),
format!("{HOME}/Saved Games"),
format!("{HOME}/AppData"),
format!("{HOME}/AppData/Local"),
format!("{HOME}/AppData/Local/Packages"),
format!("{HOME}/AppData/LocalLow"),
format!("{HOME}/AppData/Roaming"),
format!("{HOME}/Documents/My Games"),
format!("{HOME}/Library/Application Support"),
format!("{HOME}/Library/Preferences"),
format!("{HOME}/Telltale Games"),
format!("{ROOT}/config"),
format!("{WIN_DIR}/win.ini"),
format!("{WIN_DOCUMENTS}/My Games"),
format!("{WIN_DOCUMENTS}/Telltale Games"),
];
if broad.iter().any(|x| *x == self.composite) {
return true;
}
// Several games/episodes are grouped together here.
if self
.composite
.starts_with(&format!("{WIN_DOCUMENTS}/Telltale Games/*/"))
{
return true;
}
// Drive letters:
static DRIVES: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-zA-Z]:$").unwrap());
if DRIVES.is_match(&self.composite) {
return true;
}
// Root:
if self.composite == "/" {
return true;
}
// Relative path wildcard:
if self.composite.starts_with('*') {
return true;
}
false
}
fn irregular(&self) -> bool {
self.irregular || self.composite.contains("{{")
}
pub fn usable(&self) -> bool {
!self.composite.is_empty() && !self.irregular() && !self.too_broad()
}
}
#[derive(Debug, Default)]
pub struct MappedPath {
pub manifest: &'static str,
pub os: Option<Os>,
pub store: Option<Store>,
pub kind: Option<PathKind>,
}
pub fn flatten_path(attribute: &Attribute) -> WikiPath {
let mut out = WikiPath::default();
for piece in &attribute.value.pieces {
match piece {
TextPiece::Text { text, .. } => {
out.incorporate_text(text);
}
TextPiece::DoubleBraceExpression { tag, attributes } => match tag.to_string().to_lowercase().trim() {
"p" | "path" => {
for attribute in attributes {
let flat = flatten_path(attribute);
out.incorporate_path(flat);
}
}
"code" | "file" => {
out.composite += "*";
}
"localizedpath" => {
for attribute in attributes {
let flat = flatten_path(attribute);
out.incorporate_raw(flat);
}
}
"note" => {
// Ignored.
}
_ => {
out.irregular = true;
}
},
TextPiece::InternalLink { .. } => {}
TextPiece::ListItem { .. } => {}
}
}
out
}
static MAPPED_PATHS: Lazy<HashMap<&'static str, MappedPath>> = Lazy::new(|| {
HashMap::from_iter([
(
"game",
MappedPath {
manifest: placeholder::BASE,
..Default::default()
},
),
(
"uid",
MappedPath {
manifest: placeholder::STORE_USER_ID,
..Default::default()
},
),
(
"steam",
MappedPath {
manifest: placeholder::ROOT,
store: Some(Store::Steam),
..Default::default()
},
),
(
"uplay",
MappedPath {
manifest: placeholder::ROOT,
store: Some(Store::Uplay),
..Default::default()
},
),
(
"ubisoftconnect",
MappedPath {
manifest: placeholder::ROOT,
store: Some(Store::Uplay),
..Default::default()
},
),
(
"hkcu",
MappedPath {
manifest: "HKEY_CURRENT_USER",
os: Some(Os::Windows),
kind: Some(PathKind::Registry),
..Default::default()
},
),
(
"hklm",
MappedPath {
manifest: "HKEY_LOCAL_MACHINE",
os: Some(Os::Windows),
kind: Some(PathKind::Registry),
..Default::default()
},
),
(
"wow64",
MappedPath {
manifest: "WOW6432Node",
os: Some(Os::Windows),
kind: Some(PathKind::Registry),
..Default::default()
},
),
(
"username",
MappedPath {
manifest: placeholder::OS_USER_NAME,
os: Some(Os::Windows),
..Default::default()
},
),
(
"userprofile",
MappedPath {
manifest: placeholder::HOME,
os: Some(Os::Windows),
..Default::default()
},
),
(
"userprofile\\documents",
MappedPath {
manifest: placeholder::WIN_DOCUMENTS,
os: Some(Os::Windows),
..Default::default()
},
),
(
"userprofile\\appdata\\locallow",
MappedPath {
manifest: "<home>/AppData/LocalLow",
os: Some(Os::Windows),
..Default::default()
},
),
(
"appdata",
MappedPath {
manifest: placeholder::WIN_APP_DATA,
os: Some(Os::Windows),
..Default::default()
},
),
(
"localappdata",
MappedPath {
manifest: placeholder::WIN_LOCAL_APP_DATA,
os: Some(Os::Windows),
..Default::default()
},
),
(
"public",
MappedPath {
manifest: placeholder::WIN_PUBLIC,
os: Some(Os::Windows),
..Default::default()
},
),
(
"allusersprofile",
MappedPath {
manifest: placeholder::WIN_PROGRAM_DATA,
os: Some(Os::Windows),
..Default::default()
},
),
(
"programdata",
MappedPath {
manifest: placeholder::WIN_PROGRAM_DATA,
os: Some(Os::Windows),
..Default::default()
},
),
(
"windir",
MappedPath {
manifest: placeholder::WIN_DIR,
os: Some(Os::Windows),
..Default::default()
},
),
(
"syswow64",
MappedPath {
manifest: "<winDir>/SysWOW64",
os: Some(Os::Windows),
..Default::default()
},
),
(
"osxhome",
MappedPath {
manifest: placeholder::HOME,
os: Some(Os::Mac),
..Default::default()
},
),
(
"linuxhome",
MappedPath {
manifest: placeholder::HOME,
os: Some(Os::Linux),
..Default::default()
},
),
(
"xdgdatahome",
MappedPath {
manifest: placeholder::XDG_DATA,
os: Some(Os::Linux),
..Default::default()
},
),
(
"xdgconfighome",
MappedPath {
manifest: placeholder::XDG_CONFIG,
os: Some(Os::Linux),
..Default::default()
},
),
])
});
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WikiMetaCache {
pub last_checked_recent_changes: chrono::DateTime<chrono::Utc>,
}
impl ResourceFile for WikiMetaCache {
const FILE_NAME: &'static str = "data/wiki-meta-cache.yaml";
fn initialize(mut self) -> Self {
self.last_checked_recent_changes = chrono::Utc::now() - chrono::Duration::days(1);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_is_game_article() {
assert!(matches!(is_game_article("Celeste").await, Ok(true)));
assert!(matches!(is_game_article("Template:Path").await, Ok(false)));
}
}

View file

@ -1,818 +0,0 @@
import { DELAY_BETWEEN_GAMES_MS, REPO, PathType, UnsupportedOsError, UnsupportedPathError, YamlFile } from ".";
import { Constraint, Game, Store, Tag, Os } from "./manifest";
import moment from "moment";
import NodeMw from "nodemw";
import Wikiapi from "wikiapi";
import { parse as parseWiki } from 'wikiparse';
interface Template {
type: "template",
name: string,
parameters: {},
positionalParameters: Array<Array<WikiNode>>,
};
type WikiNode = string | Template | {
type: "code" | "pre",
content: Array<WikiNode>,
attributes: { [key: string]: string },
} | {
type: "comment" | "bold" | "italic" | "ref",
content: Array<WikiNode>,
} | {
type: "link",
to: string,
content: Array<WikiNode>,
} | {
type: "tag",
content: Array<WikiNode>,
attributes: { [key: string]: string },
name: string,
};
export type WikiGameCache = {
[title: string]: {
pageId: number,
dirty?: boolean,
renamedFrom?: Array<string>,
templates?: Array<string>,
steam?: number,
steamSide?: Array<number>,
gog?: number
gogSide?: Array<number>,
};
};
export type WikiMetaCache = {
lastCheckedRecentChanges: string;
};
export class WikiGameCacheFile extends YamlFile<WikiGameCache> {
path = `${REPO}/data/wiki-game-cache.yaml`;
defaultData = {};
async addNewGames(): Promise<void> {
const wiki = makeApiClient();
const pages: Array<{ pageid: number, title: string }> = JSON.parse(JSON.stringify(await wiki.categorymembers("Games")));
for (const page of pages) {
if (!this.data.hasOwnProperty(page.title)) {
let newGame = true;
for (const [k, v] of Object.entries(this.data)) {
if (v.pageId === page.pageid) {
newGame = false;
this.data[page.title] = v;
this.data[page.title].dirty = true;
if (!(v.renamedFrom ?? []).includes(k)) {
this.data[page.title].renamedFrom = [...(v.renamedFrom ?? []), k];
}
delete this.data[k];
break;
}
}
if (!newGame) {
continue;
}
this.data[page.title] = {
pageId: page.pageid,
dirty: true,
};
}
};
}
async refresh(skipUntil: string | undefined, limit: number, all: boolean, games: Array<string>): Promise<void> {
let i = 0;
let foundSkipUntil = false;
const client = makeApiClient();
for (const pageTitle of Object.keys(this.data).sort()) {
if (skipUntil && !foundSkipUntil) {
if (pageTitle === skipUntil) {
foundSkipUntil = true;
} else {
continue;
}
}
if (games.length > 0) {
if (!games.includes(pageTitle)) {
continue;
}
} else if (!all && !this.data[pageTitle].dirty) {
continue;
}
// console.log(`Refreshing wiki page: ${pageTitle}`);
await getGame(pageTitle, this.data, client);
i++;
if (limit > 0 && i >= limit) {
break;
}
// main() will save at the end, but we do a periodic save as well
// in case something goes wrong or the script gets cancelled:
if (i % 250 === 0) {
this.save();
console.log("\n:: saved\n");
}
await new Promise(resolve => setTimeout(resolve, DELAY_BETWEEN_GAMES_MS));
}
}
async flagRecentChanges(metaCache: WikiMetaCacheFile): Promise<void> {
const now = moment();
const changes = await getRecentChanges(now.toDate(), moment(metaCache.data.lastCheckedRecentChanges).subtract(1, "minute").toDate());
const client = makeApiClient2();
for (const [recentName, recentInfo] of Object.entries(changes).sort((x, y) => x[0].localeCompare(y[0]))) {
if (recentInfo.redirect) {
// This is an entry for the redirect page itself. We can ignore
// it, and we'll update our data when we get to the entry for
// the new page name.
continue;
} else if (this.data[recentName] !== undefined) {
// Existing entry has been edited.
console.log(`[E ] ${recentName}`);
this.data[recentName].dirty = true;
} else {
// Check for a rename.
let renamed = false;
for (const [existingName, existingInfo] of Object.entries(this.data)) {
if (existingInfo.pageId === recentInfo.pageId) {
// We have a confirmed rename.
console.log(`[ M ] ${recentName} <<< ${existingName}`);
renamed = true;
this.data[recentName] = {
...existingInfo,
pageId: recentInfo.pageId,
dirty: true,
renamedFrom: [...(existingInfo.renamedFrom ?? []), existingName]
};
delete this.data[existingName];
break;
}
}
if (!renamed) {
// Brand new page.
const [data, _] = await callMw<Array<string>>(client, "getArticleCategories", recentName);
if (data.includes("Category:Games")) {
// It's a game, so add it to the cache.
console.log(`[ C] ${recentName}`);
this.data[recentName] = { pageId: recentInfo.pageId, dirty: true };
}
}
}
}
metaCache.data.lastCheckedRecentChanges = now.toISOString();
}
}
export class WikiMetaCacheFile extends YamlFile<WikiMetaCache> {
path = `${REPO}/data/wiki-meta-cache.yaml`;
defaultData = {
lastCheckedRecentChanges: moment().subtract(1, "days").toISOString(),
};
}
interface RecentChanges {
[article: string]: {
pageId: number;
redirect: boolean;
};
}
// This defines how {{P|game}} and such are converted.
const PATH_ARGS: { [arg: string]: { mapped: string, when?: Constraint, registry?: boolean, ignored?: boolean } } = {
game: {
mapped: "<base>",
},
uid: {
mapped: "<storeUserId>",
},
steam: {
mapped: "<root>",
when: {
store: "steam",
},
},
uplay: {
mapped: "<root>",
when: {
store: "uplay"
},
},
ubisoftconnect: {
mapped: "<root>",
when: {
store: "uplay"
},
},
hkcu: {
mapped: "HKEY_CURRENT_USER",
when: { os: "windows" },
registry: true,
},
hklm: {
mapped: "HKEY_LOCAL_MACHINE",
when: { os: "windows" },
registry: true,
},
wow64: {
mapped: "WOW6432Node",
when: { os: "windows" },
registry: true,
},
username: {
mapped: "<osUserName>",
when: { os: "windows" },
},
userprofile: {
mapped: "<home>",
when: { os: "windows" },
},
"userprofile\\documents": {
mapped: "<winDocuments>",
when: { os: "windows" },
},
"userprofile\\appdata\\locallow": {
mapped: "<home>/AppData/LocalLow",
when: { os: "windows" },
},
appdata: {
mapped: "<winAppData>",
when: { os: "windows" },
},
localappdata: {
mapped: "<winLocalAppData>",
when: { os: "windows" },
},
public: {
mapped: "<winPublic>",
when: { os: "windows" },
},
allusersprofile: {
mapped: "<winProgramData>",
when: { os: "windows" },
},
programdata: {
mapped: "<winProgramData>",
when: { os: "windows" },
},
windir: {
mapped: "<winDir>",
when: { os: "windows" },
},
syswow64: {
mapped: "<winDir>/SysWOW64",
when: { os: "windows" },
},
osxhome: {
mapped: "<home>",
when: { os: "mac" },
},
linuxhome: {
mapped: "<home>",
when: { os: "linux" },
},
xdgdatahome: {
mapped: "<xdgData>",
when: { os: "linux" },
},
xdgconfighome: {
mapped: "<xdgConfig>",
when: { os: "linux" },
},
}
function makePathArgRegex(arg: string): RegExp {
const escaped = `{{P(ath)?|${arg}}}`
.replace(/\\/g, "\\\\")
.replace(/\|/g, "\\|")
.replace(/\{/g, "\\{")
.replace(/\}/g, "\\}");
return new RegExp(escaped, "gi");
}
function normalizePath(path: string): string {
return path
.replace(/\\/g, "/")
.replace(/\/{2,}/g, "/")
.replace(/\/(?=$)/g, "")
.replace(/^~(?=($|\/))/, "<home>");
}
/**
* https://www.pcgamingwiki.com/wiki/Template:Path
*/
function parsePath(path: string): [string, PathType] {
const pathType = getPathType(path);
for (const [arg, info] of Object.entries(PATH_ARGS)) {
if (pathContainsArg(path, arg) && info.ignored) {
throw new UnsupportedPathError(`Unsupported path argument: ${arg}`);
}
let limit = 100;
let i = 0;
while (pathContainsArg(path, arg)) {
path = path.replace(makePathArgRegex(arg), info.mapped);
i++;
if (i >= limit) {
throw new UnsupportedPathError(`Unable to resolve path arguments in: ${path}`);
}
}
}
path = normalizePath(path)
.replace(/%userprofile%\/AppData\/Roaming/i, "<winAppData>")
.replace(/%userprofile%\/AppData\/Local(?!Low)/i, "<winLocalAppData>")
.replace(/%userprofile%\/Documents/i, "<winDocuments>")
.replace(/%userprofile%/i, "<home>")
.replace(/%appdata%/i, "<winAppData>")
.replace(/%localappdata%/i, "<winLocalAppData>");
while (path.endsWith("/*")) {
path = path.slice(0, path.length - 2);
}
return [path.trim(), pathType];
}
export function pathIsTooBroad(path: string): boolean {
if (Object.values(PATH_ARGS).map(x => x.mapped).includes(path)) {
return true;
}
// These paths are present whether or not the game is installed.
// If possible, they should be narrowed down on the wiki.
if ([
"<base>/<storeUserId>", // because `<storeUserId>` is handled as `*`
"<home>/Documents",
"<home>/Saved Games",
"<home>/AppData",
"<home>/AppData/Local",
"<home>/AppData/Local/Packages",
"<home>/AppData/LocalLow",
"<home>/AppData/Roaming",
"<home>/Documents/My Games",
"<home>/Library/Application Support",
"<home>/Library/Preferences",
"<home>/Telltale Games",
"<root>/config",
"<winDir>/win.ini",
"<winDocuments>/My Games",
"<winDocuments>/Telltale Games",
].includes(path)) {
return true;
}
// Several games/episodes are grouped together here.
if (path.startsWith("<winDocuments>/Telltale Games/*/")) {
return true;
}
// Drive letters:
if (path.match(/^[a-zA-Z]:$/)) {
return true;
}
// Root:
if (path === "/") {
return true;
}
// Relative path wildcard:
if (path.startsWith("*")) {
return true;
}
return false;
}
function pathContainsArg(path: string, arg: string): boolean {
return path.match(makePathArgRegex(arg)) !== null;
}
function getPathType(path: string): PathType {
for (const [arg, info] of Object.entries(PATH_ARGS)) {
if (info.registry && path.match(makePathArgRegex(arg)) !== null) {
return PathType.Registry;
}
}
return PathType.FileSystem;
}
function getOsConstraintFromPath(path: string): Os | undefined {
for (const [arg, info] of Object.entries(PATH_ARGS)) {
if (pathContainsArg(path, arg) && info?.when?.os) {
return info?.when?.os;
}
}
}
function getStoreConstraintFromPath(path: string): Store | undefined {
for (const [arg, info] of Object.entries(PATH_ARGS)) {
if (pathContainsArg(path, arg) && info?.when?.store) {
return info?.when?.store;
}
}
}
function getConstraintFromSystem(system: string, path: string): Constraint {
const constraint: Constraint = {};
if (system.match(/steam/i)) {
constraint.store = "steam";
} else if (system.match(/microsoft store/i)) {
constraint.os = "windows";
constraint.store = "microsoft";
} else if (system.match(/gog\.com/i)) {
constraint.store = "gog";
} else if (system.match(/epic games/i)) {
constraint.store = "epic";
} else if (system.match(/uplay/i)) {
constraint.store = "uplay";
} else if (system.match(/origin/i)) {
constraint.store = "origin";
} else {
try {
constraint.os = parseOs(system);
} catch { }
}
const storeFromPath = getStoreConstraintFromPath(path);
if (storeFromPath !== undefined) {
constraint.store = storeFromPath;
}
return constraint;
}
function getTagFromTemplate(template: string): Tag | undefined {
switch (template.toLowerCase()) {
case "game data/saves":
return "save";
case "game data/config":
return "config";
default:
return undefined;
}
}
function parseOs(os: string): Os {
// Others seen: "Mac OS", "PC booter", "Amazon Games", "Ubisoft Connect"
switch (os) {
case "Windows":
return "windows";
case "OS X":
return "mac";
case "Linux":
return "linux";
case "DOS":
return "dos";
default:
throw new UnsupportedOsError(`Unsupported OS: ${os}`);
}
}
// Used for most functionality, but it seems like a less active project
// and it's hard to figure out what functionality is available,
// so we'll probably migrate to nodemw.
function makeApiClient() {
return new Wikiapi("https://www.pcgamingwiki.com/w/api.php");
}
// Used for the Recent Changes page and getting a single page's categories.
// Will probably also migrate to this in general.
function makeApiClient2(): any {
return new NodeMw({
protocol: "https",
server: "www.pcgamingwiki.com",
path: "/w/api.php",
debug: false,
userAgent: "ludusavi-manifest-importer/0.0.0",
concurrency: 1,
});
}
// Promise wrapper for nodemw.
function callMw<T = any>(client, method: string, ...args: Array<any>): Promise<[T, any]> {
return new Promise((resolve, reject) => {
client[method](...args, (err: any, data: T, next: any) => {
if (err) {
reject(err);
} else {
resolve([data, next]);
}
});
});
}
export async function getRecentChanges(newest: Date, oldest: Date): Promise<RecentChanges> {
console.log(`Getting recent changes from ${oldest.toISOString()} to ${newest.toISOString()}`);
const changes: RecentChanges = {};
const client = makeApiClient2();
const startTimestamp = newest.toISOString();
const endTimestamp = oldest.toISOString();
let rccontinue: string | undefined = undefined;
while (true) {
const params = {
action: "query",
list: "recentchanges",
rcprop: "title|ids|redirect",
rcstart: startTimestamp,
rcend: endTimestamp,
rclimit: 500,
rcnamespace: 0,
rctype: "edit|new",
rccontinue,
};
if (params.rccontinue === undefined) {
delete params.rccontinue;
}
const [data, next] = await callMw<{ recentchanges: Array<{ title: string; pageid: number, redirect?: string }> }>(
client.api, "call", params
);
for (const article of data.recentchanges) {
changes[article.title] = {
pageId: article.pageid,
redirect: article.redirect !== undefined,
};
}
if (next) {
rccontinue = next.rccontinue;
} else {
break;
}
}
return changes;
}
/**
* https://www.pcgamingwiki.com/wiki/Template:Game_data
*/
export async function getGame(pageTitle: string, cache: WikiGameCache, client: Wikiapi = null): Promise<string> {
console.log(`Wiki: ${pageTitle}`);
const wiki = client === null ? makeApiClient() : client;
let page = await wiki.page(pageTitle, { rvprop: "ids|content" });
if (page.missing !== undefined) {
// Couldn't find it by name, so try again by ID.
// This can happen for pages moved without leaving a redirect.
// (If they have a redirect, then the recent changes code takes care of it.)
const pageId = cache[pageTitle].pageId;
const client = makeApiClient2();
const params = {
action: "query",
pageids: [pageId],
};
try {
const [data, _] = await callMw<{ pages: { [id: string]: { title: string } } }>(
client.api, "call", params
);
const newTitle = data.pages[pageId.toString()].title;
if (newTitle === undefined) {
// This happened once intermittently; the cause is unclear.
throw new Error("Unable to retrieve page by ID");
}
console.log(`:: getGame: page ${pageId} called '${pageTitle}' renamed to '${newTitle}'`);
cache[newTitle] = cache[pageTitle];
delete cache[pageTitle];
if (cache[newTitle].renamedFrom === undefined) {
cache[newTitle].renamedFrom = [pageTitle];
} else {
cache[newTitle].renamedFrom.push(pageTitle);
}
page = await wiki.page(newTitle, { rvprop: "ids|content" });
pageTitle = newTitle;
} catch {
console.log(`:: page ${pageId} called '${pageTitle}' no longer exists`);
}
}
delete cache[pageTitle].templates;
page.parse().each("template", template => {
const templateName = template.name.toLowerCase();
if (templateName === "infobox game") {
const steamId = Number(template.parameters["steam appid"]);
if (!isNaN(steamId) && steamId > 0) {
cache[pageTitle].steam = steamId;
}
cache[pageTitle].steamSide = [];
const steamSides = template.parameters["steam appid side"] as string | undefined;
if (steamSides !== undefined && typeof(steamSides) === "string") {
for (const side of steamSides.split(",").map(Number)) {
if (!isNaN(side) && side > 0) {
cache[pageTitle].steamSide.push(side);
}
}
}
if (cache[pageTitle].steamSide.length === 0) {
delete cache[pageTitle].steamSide;
}
const gogId = Number(template.parameters["gogcom id"]);
if (!isNaN(gogId) && gogId > 0) {
cache[pageTitle].gog = gogId
}
cache[pageTitle].gogSide = [];
const gogSides = template.parameters["gogcom id side"] as string | undefined;
if (gogSides !== undefined && typeof(gogSides) === "string") {
for (const side of gogSides.split(",").map(Number)) {
if (!isNaN(side) && side > 0) {
cache[pageTitle].gogSide.push(side);
}
}
}
if (cache[pageTitle].gogSide.length === 0) {
delete cache[pageTitle].gogSide;
}
} else if (templateName === "game data/saves" || templateName === "game data/config") {
const reparsed = parseWiki(template.toString());
if (reparsed[0].positionalParameters[1]?.length > 0 ?? false) {
if (cache[pageTitle].templates === undefined) {
cache[pageTitle].templates = [];
}
cache[pageTitle].templates.push(template.toString());
}
}
});
delete cache[pageTitle].dirty;
return pageTitle;
}
function flattenParameter(nodes: Array<WikiNode>): [string, boolean] {
let composite = "";
let regular = true;
for (const node of nodes) {
if (typeof node === "string") {
composite += node;
} else switch (node.type) {
case "code":
case "pre":
composite += "*";
break;
case "template":
switch (node.name.toLowerCase()) {
case "p":
case "path":
const [flatP, regularP] = flattenParameter(node.positionalParameters[0]);
if (!regularP) {
regular = false;
}
composite += `{{${node.name}|${flatP}}}`;
break;
case "code":
case "file":
composite += "*";
break;
case "localizedpath":
const [flatC, regularC] = flattenParameter(node.positionalParameters[0]);
if (!regularC) {
regular = false;
}
composite += flatC;
break;
default:
break;
}
break;
case "comment":
case "bold":
case "italic":
case "ref":
break;
case "tag":
const [flatT, regularT] = flattenParameter(node.content);
if (!regularT) {
regular = false;
}
if (flatT.includes("/") || flatT.includes("\\")) {
// This is probably an unclosed tag with more path content after it,
// like `.../<game.version>/...`.
composite += `*/${flatT}`;
} else if (flatT.length > 0) {
// This is probably a closed tag, like `.../<sup>user ID</sup>/...`.
composite += "*";
}
break;
default:
regular = false;
break;
}
}
return [composite.trim(), regular];
}
export function parseTemplates(templates: Array<string>): Pick<Game, "files" | "registry"> {
const game: Pick<Game, "files" | "registry"> = { files: {}, registry: {} };
for (const template of templates.flatMap(parseWiki) as Array<Template>) {
if (template.type !== "template") {
console.log(`WARNING: unknown template type '${template.type}' from: '${JSON.stringify(template)}'`);
continue;
}
if (template.positionalParameters.length < 2) {
continue;
}
const [system, _] = flattenParameter(template.positionalParameters[0]);
for (const [rawPath, regular] of template.positionalParameters.slice(1).map(flattenParameter)) {
if (rawPath.length === 0 || !regular) {
// console.log(`IRREGULAR: ${rawPath}`);
continue;
}
const [pathUnsplit, pathType] = parsePath(rawPath);
if (pathUnsplit.includes("{{")) {
continue;
}
for (const path of pathUnsplit.split("|").map(normalizePath)) {
if (pathIsTooBroad(path)) {
continue;
}
if (pathType === PathType.FileSystem) {
const constraint = getConstraintFromSystem(system, rawPath);
if (!game.files.hasOwnProperty(path)) {
game.files[path] = {
when: [],
tags: [],
};
}
if (!game.files[path].when.some(x => x.os === constraint.os && x.store === constraint.store)) {
if (constraint.os !== undefined && constraint.store !== undefined) {
game.files[path].when.push(constraint);
} else if (constraint.os !== undefined) {
game.files[path].when.push({ os: constraint.os });
} else if (constraint.store !== undefined) {
game.files[path].when.push({ store: constraint.store });
}
}
const tag = getTagFromTemplate(template.name);
if (tag !== undefined && !game.files[path].tags.includes(tag)) {
game.files[path].tags.push(tag);
}
} else if (pathType === PathType.Registry) {
if (!game.registry.hasOwnProperty(path)) {
game.registry[path] = {
when: [],
tags: [],
};
}
const store = getStoreConstraintFromPath(rawPath);
if (store !== undefined && !game.registry[path].when.some(x => x.store === store)) {
game.registry[path].when.push({ store });
}
const tag = getTagFromTemplate(template.name);
if (tag !== undefined && !game.registry[path].tags.includes(tag)) {
game.registry[path].tags.push(tag);
}
}
}
}
}
if (Object.keys(game.files).length === 0) {
delete game.files;
} else {
for (const path of Object.keys(game.files)) {
if (game.files[path].when.length === 0) {
delete game.files[path].when;
}
if (game.files[path].tags.length === 0) {
delete game.files[path].tags;
}
}
}
if (Object.keys(game.registry).length === 0) {
delete game.registry;
} else {
for (const path of Object.keys(game.registry)) {
if (game.registry[path].when.length === 0) {
delete game.registry[path].when;
}
if (game.registry[path].tags.length === 0) {
delete game.registry[path].tags;
}
}
}
return game;
}

View file

@ -1,17 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"outDir": "out",
"lib": [
"es2019"
],
"esModuleInterop": true
},
"include": [
"src/*.ts"
],
"exclude": [
"node_modules"
]
}