import fixJson from "json-fixer"; import JSON5 from "json5"; import { jsonc } from "jsonc"; import { IParseOptions, IStringifyOptions, Reviver } from "jsonc/lib/interfaces"; import { inject, injectable } from "tsyringe"; import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; import { HashUtil } from "@spt-aki/utils/HashUtil"; import { VFS } from "@spt-aki/utils/VFS"; @injectable() export class JsonUtil { protected fileHashes = null; protected jsonCacheExists = false; protected jsonCachePath = "./user/cache/jsonCache.json"; constructor( @inject("VFS") protected vfs: VFS, @inject("HashUtil") protected hashUtil: HashUtil, @inject("WinstonLogger") protected logger: ILogger ) { } /** * From object to string * @param data object to turn into JSON * @param prettify Should output be prettified * @returns string */ public serialize(data: any, prettify = false): string { if (prettify) { return JSON.stringify(data, null, "\t"); } else { return JSON.stringify(data); } } /** * From object to string * @param data object to turn into JSON * @param replacer An array of strings and numbers that acts as an approved list for selecting the object properties that will be stringified. * @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. * @returns string */ public serializeAdvanced(data: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string { return JSON.stringify(data, replacer, space); } /** * From object to string * @param data object to turn into JSON * @param filename Name of file being serialized * @param options Stringify options or a replacer. * @returns The string converted from the JavaScript value */ public serializeJsonC( data: any, filename?: string | null, options?: IStringifyOptions | Reviver): string { try { return jsonc.stringify(data, options); } catch (error) { this.logger.error(`unable to stringify jsonC file: ${filename} message: ${error.message}, stack: ${error.stack}`); } } public serializeJson5( data: any, filename?: string | null, prettify = false): string { try { if (prettify) { return JSON5.stringify(data, null, "\t"); } else { return JSON5.stringify(data); } } catch (error) { this.logger.error(`unable to stringify json5 file: ${filename} message: ${error.message}, stack: ${error.stack}`); } } /** * From string to object * @param jsonString json string to turn into object * @param filename Name of file being deserialized * @returns object */ public deserialize(jsonString: string, filename = ""): T { try { return JSON.parse(jsonString); } catch (error) { this.logger.error(`unable to parse json file: ${filename} message: ${error.message}, stack: ${error.stack}`); } } /** * From string to object * @param jsonString json string to turn into object * @param filename Name of file being deserialized * @param options Parsing options * @returns object */ public deserializeJsonC(jsonString: string, filename = "", options?: IParseOptions): T { try { return jsonc.parse(jsonString, options); } catch (error) { this.logger.error(`unable to parse jsonC file: ${filename} message: ${error.message}, stack: ${error.stack}`); } } public deserializeJson5(jsonString: string, filename = ""): T { try { return JSON5.parse(jsonString); } catch (error) { this.logger.error(`unable to parse json file: ${filename} message: ${error.message}, stack: ${error.stack}`); } } public async deserializeWithCacheCheckAsync(jsonString: string, filePath: string): Promise { return new Promise((resolve) => { resolve(this.deserializeWithCacheCheck(jsonString, filePath)); }); } /** * From json string to object * @param jsonString String to turn into object * @param filePath Path to json file being processed * @returns Object */ public deserializeWithCacheCheck(jsonString: string, filePath: string): T { this.ensureJsonCacheExists(this.jsonCachePath); this.hydrateJsonCache(this.jsonCachePath); // Generate hash of string const generatedHash = this.hashUtil.generateSha1ForData(jsonString); // Get hash of file and check if missing or hash mismatch let savedHash = this.fileHashes[filePath]; if (!savedHash || savedHash !== generatedHash) { try { const { data, changed } = fixJson(jsonString); if (changed) // data invalid, return it { this.logger.error(`${filePath} - Detected faulty json, please fix your json file using VSCodium`); } else { // data valid, save hash and call function again this.fileHashes[filePath] = generatedHash; this.vfs.writeFile(this.jsonCachePath, this.serialize(this.fileHashes, true)); savedHash = generatedHash; } return data as T; } catch (error) { const errorMessage = `Attempted to parse file: ${filePath}. Error: ${error.message}`; this.logger.error(errorMessage); throw new Error(errorMessage); } } // Doesn't match if (savedHash !== generatedHash) { throw new Error(`Catastrophic failure processing file ${filePath}`); } // Match! return this.deserialize(jsonString); } /** * Create file if nothing found * @param jsonCachePath path to cache */ protected ensureJsonCacheExists(jsonCachePath: string): void { if (!this.jsonCacheExists) { if (!this.vfs.exists(jsonCachePath)) { // Create empty object at path this.vfs.writeFile(jsonCachePath, "{}"); } this.jsonCacheExists = true; } } /** * Read contents of json cache and add to class field * @param jsonCachePath Path to cache */ protected hydrateJsonCache(jsonCachePath: string) : void { // Get all file hashes if (!this.fileHashes) { this.fileHashes = this.deserialize(this.vfs.readFile(`${jsonCachePath}`)); } } /** * Convert into string and back into object to clone object * @param objectToClone Item to clone * @returns Cloned parameter */ public clone(objectToClone: T): T { return structuredClone(objectToClone); } }