UNPKG

@kayahr/ed-journal

Version:

Typescript library to read/watch the player journal of Frontier's game Elite Dangerous

734 lines 29.8 kB
/* * Copyright (C) 2022 Klaus Reimer <k@ailis.de> * See LICENSE.md for licensing information. */ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) { if (value !== null && value !== void 0) { if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected."); var dispose, inner; if (async) { if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined."); dispose = value[Symbol.asyncDispose]; } if (dispose === void 0) { if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined."); dispose = value[Symbol.dispose]; if (async) inner = dispose; } if (typeof dispose !== "function") throw new TypeError("Object not disposable."); if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } }; env.stack.push({ value: value, dispose: dispose, async: async }); } else if (async) { env.stack.push({ async: true }); } return value; }; var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) { return function (env) { function fail(e) { env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e; env.hasError = true; } var r, s = 0; function next() { while (r = env.stack.pop()) { try { if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next); if (r.dispose) { var result = r.dispose.call(r.value); if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); }); } else s |= 1; } catch (e) { fail(e); } } if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve(); if (env.hasError) throw env.error; } return next(); }; })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }); // Import events which registers event updates import "./events/carrier/CarrierNameChange.js"; import "./events/carrier/CarrierStats.js"; import "./events/travel/Docked.js"; import "./events/station/EngineerCraft.js"; import "./events/station/EngineerProgress.js"; import "./events/station/Market.js"; import "./events/travel/FSDJump.js"; import "./events/travel/Location.js"; import "./events/exploration/Scan.js"; import "./events/startup/Statistics.js"; import "./events/other/Synthesis.js"; import { open, readFile, readdir, watch } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { JournalError } from "./JournalError.js"; import { updateJournalEvent } from "./JournalEvent.js"; import { sleep } from "./util/async.js"; import { getErrorMessage, toError } from "./util/error.js"; import { isDirectory, isPathReadable } from "./util/fs.js"; import { LineReader } from "./util/LineReader.js"; import { Notifier } from "./util/Notifier.js"; /** * Compare function to sort journal file names by time. Empty string is always earlier than any journal file. * * @param a - First filename to compare. * @param b - Second filename to compare. * @returns The comparison result to sort the journal file names fro oldest to newest. */ function journalTimeCompare(a, b) { if (a.length === b.length) { // Same length means same date format which we can compare alphabetically return a.localeCompare(b); } else { // Different length means the date format has changed. The newer one is longer return a.length - b.length; } } /** Regular expression to match the name of a journal file. */ const journalFileRegExp = /^Journal\.\d{12}|\d{4}-\d{2}-\d{2}T\d{6}\.\d{2}\.log$/; /** * Checks if given filename is a journal file. * * @param The filename to check. * @returns True if filename is a journal file, false it not. */ function isJournalFile(filename) { return journalFileRegExp.test(filename); } /** * JSON reviver function which converts numbers of ID properties (property names ending with 'ID' or 'Address') to bigint if needed. * * @param key - The JSON property key. * @param value - The parsed JSON property value. * @param context - The reviver context containing the raw JSON source string. * @returns The already parsed JSON property value if suitable or the raw source converted into a bigint. */ function jsonReviver(key, value, context) { if (context != null && typeof value === "number" && (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER)) { const source = context.source; if (key.endsWith("ID") || key.endsWith("Address")) { return BigInt(source); } else if (/^[-+]?\d+$/.test(source)) { throw new JournalError(`Value of property '${key}' looks like a bigint (${source}) but was parsed as an imprecise number (${value})`); } } return value; } /** * Journal reader/watcher. * * Reads or watches a journal directory. It implements the AsyncIterable interface so for reading/watching the * journal you simply iterate of the instance of this class with a for..of loop for example. If you prefer you can * also use the {@link next} method to read the next event from the journal until this method returns null to indicate * the end of the journal. * * In watch mode the iteration does not end and is continued every time a new event is appended to the journal by the * game. Watch mode can be stopped by calling the {@link close} method. Iteration loops will end when journal is closed. */ export class Journal { /** The journal directory. */ directory; /** The journal position pointing to the last read event. */ lastPosition; /** The current journal position pointing to the next event to read. */ position; /** The generator used for reading journal events. */ generator; /** The currently open line reader. Null if currently none open. */ lineReader = null; /** Controller used to abort watchers when journal is closed. */ abortController; /** * Creates journal read from given directory at given position. * * @param directory - The journal directory. * @param position - The position to start reading from. * @param watch - True to watch journal, false to just read it. */ constructor(directory, position, watch) { this.directory = directory; this.position = position; this.lastPosition = position; this.abortController = new AbortController(); this.generator = this.createGenerator(watch); } /** @inheritdoc */ [Symbol.asyncDispose]() { return this.close(); } /** * Opens the journal. */ static async open({ directory, position = "start", watch = false } = {}) { directory ??= await this.findDirectory(); if (position === "start") { position = await this.findStart(directory); } else if (position === "end") { position = await this.findEnd(directory); } else if (typeof position === "string") { position = await this.findLastEvent(directory, position); } else { position = { ...position }; } return new Journal(directory, position, watch); } /** * Searches for the journal directory in common spaces. First it checks for existence of directory specified with * environment variable ED_JOURNAL_DIR. Then it looks into the standard directory on a windows system and then * it checks to standard directory within Proton (for Linux). * * If you know more common journal locations then please let me know so I can improve the search. * * @returns The found journal directory. * @throws JournalError - When journal directory was not found. */ static async findDirectory() { const nativeHome = homedir(); const protonHome = ".local/share/Steam/steamapps/compatdata/359320/pfx/drive_c/users/steamuser"; const eliteDir = "Saved Games/Frontier Developments/Elite Dangerous"; const candidates = [ join(nativeHome, eliteDir), join(nativeHome, protonHome, eliteDir) ]; const dirFromEnv = process.env["ED_JOURNAL_DIR"]; if (dirFromEnv != null) { // Check ED_JOURNAL_DIR environment variable first if present candidates.unshift(dirFromEnv); } for (const candidate of candidates) { if (await isPathReadable(candidate) && await isDirectory(candidate)) { return candidate; } } throw new JournalError("Unable to find Elite Dangerous Journal directory"); } /** * Returns the journal directory. * * @returns The journal directory. */ getDirectory() { return this.directory; } /** * Returns the current journal position which points to the next event to read. * * @returns The current journal position. */ getPosition() { return { ...this.position }; } /** * Returns the journal position which points to the last read event. * * @returns The journal position of the last read event. */ getLastPosition() { return { ...this.lastPosition }; } /** * Closes the journal by stopping the watcher (if any) and closing the line reader. */ async close() { this.abortController.abort(); if (this.lineReader != null) { await this.lineReader.close(); this.lineReader = null; } } /** * Finds the last position of the given event in the latest file of the journal and returns it. Returns end of journal if the latest journal file * does not contain this event. * * @param directory - The journal directory. * @param eventName - The event name to look for. * @returns Last position of given event in latest journal file or end of journal if not found. */ static async findLastEvent(directory, eventName) { const files = (await readdir(directory)).filter(isJournalFile).sort(journalTimeCompare).reverse(); let lastEventPosition = null; let lastPosition = { file: "", offset: 0, line: 1 }; for (const file of files) { const env_1 = { stack: [], error: void 0, hasError: false }; try { const lineReader = __addDisposableResource(env_1, await LineReader.create(join(directory, file)), true); lastPosition = { file, offset: lineReader.getOffset(), line: lineReader.getLine() }; for await (const line of lineReader) { try { const json = JSON.parse(line, jsonReviver); updateJournalEvent(json); if (json.event === eventName) { lastEventPosition = lastPosition; } lastPosition = { file, offset: lineReader.getOffset(), line: lineReader.getLine() }; } catch (error) { throw new JournalError(`Parse error in ${lastPosition.file}:${lastPosition.line}: ${getErrorMessage(error)}: ${line.trim()}`); } } if (lastEventPosition != null) { break; } } catch (e_1) { env_1.error = e_1; env_1.hasError = true; } finally { const result_1 = __disposeResources(env_1); if (result_1) await result_1; } } return lastEventPosition ?? this.findEnd(directory); } /** * Finds the end position of the journal and returns it. * * @returns End position of the journal. */ static async findStart(directory) { const filename = (await readdir(directory)).filter(isJournalFile).sort(journalTimeCompare)[0]; return { file: filename ?? "", offset: 0, line: 1 }; } /** * Finds the end position of the journal and returns it. * * @returns End position of the journal. */ static async findEnd(directory) { const filename = (await readdir(directory)).filter(isJournalFile).sort(journalTimeCompare).reverse()[0]; if (filename == null) { // No journal file found, return start as end return { file: "", offset: 0, line: 1 }; } // Find last line number and also create the end offset (which is the same as the file size) during the process const file = await open(join(directory, filename), "r"); try { const buffer = new Uint8Array(8192); let offset = 0; let line = 1; let read; while ((read = (await file.read({ buffer })).bytesRead) > 0) { offset += read; for (let i = 0; i < read; i++) { if (buffer[i] === 10) { line++; } } } return { file: filename, offset, line }; } finally { await file.close(); } } /** * Watches the journal for new or changed files and returns filenames sorted by date. An optional starting file * can be specified to define the starting point for the watcher. If not specified then the whole journal * directory is scanned and all found journal files are returned before starting to watch for changed or new * files. * * @param startFile - Optional starting file. If not specified then the watcher starts with the oldest available * journal file. * @yields New/changed journal files in chronological order. */ async *watchJournalFiles(startFile) { const signal = this.abortController.signal; const notifier = new Notifier(signal); const directory = resolve(this.directory); const files = []; let lastError = null; let initialized = false; // Monitors journal directory for changes. This starts immediately, even when initial directories are still read, so we don't miss any changed or // new file. When initialization is not done yet, then changed/new file is just recorded and taken into account during initialization. If // initialization is done then changed/new files are reported right away. const journalDirMonitor = (async () => { try { for await (const event of watch(directory, { signal })) { const filename = event.filename; if (filename != null && isJournalFile(filename)) { if (initialized) { if (journalTimeCompare(filename, startFile) >= 0) { startFile = filename; files.push(filename); notifier.notify(); } } /* node:coverage ignore next 2 */ /* Hard to time this situation in unit test */ else { files.push(filename); } } } } catch (error) { lastError = toError(error); notifier.notify(); } })(); // Asynchronous initialization. Reads all existing journal files and sorts them. While reading the directory the watcher can already contribute // new/changed files. const asyncInit = (async () => { try { for (const file of await this.listJournalFiles(startFile)) { if (signal.aborted) { break; } files.push(file); } files.sort(journalTimeCompare); startFile = files.at(-1) ?? ""; initialized = true; notifier.notify(); } catch (error) { lastError = toError(error); notifier.notify(); } })(); // Waits for reported new/changed files and yields them. Aborts on error and simply exits when user aborts the watcher. while (!signal.aborted) { if (lastError != null) { throw lastError; } const nextFile = files.shift(); if (nextFile != null) { yield nextFile; } else { await notifier.wait(); } } // Await background processes to make sure they existed correctly await asyncInit; await journalDirMonitor; } /** * Lists journal files. An optional starting file can be specified to define the starting point for the watcher. * If not specified then the whole journal directory is scanned and all found files are returned.. * * @param startFile - Optional starting file. If not specified then the reader starts with the oldest available * journal file. * @returns The found journal files. */ async listJournalFiles(startFile) { return (await readdir(this.directory)) .filter(filename => isJournalFile(filename) && journalTimeCompare(filename, startFile) >= 0) .sort(journalTimeCompare); } /** * Parses a single journal event line. Additionally to parsing the string into a JSON object this function also * updates old properties in the journal event to new ones if needed. * * @param line - The JSON line to parse. * @returns The parsed journal event. */ parseJournalEvent(line) { const json = JSON.parse(line, jsonReviver); updateJournalEvent(json); return json; } /** * Creates the journal event generator. * * @param watch - True to watch the journal instead of just reading it. * @yields The created journal event generator. */ async *createGenerator(watch) { // Get the list of journal files to read/watch. In watch mode this is a generator which produces changed/new // files until journal is closed. const files = watch ? this.watchJournalFiles(this.position.file) : await this.listJournalFiles(this.position.file); const signal = this.abortController.signal; // Iterate over all journal files in chronological order. In watch mode, when the last line of the last file // has been read this loop waits until the files generator reports the current journal file again when it has // been changed or a new journal file was found. Reading is then continued at this point. for await (const file of files) { // When position is empty then initialize with first file we have seen if (this.position.file === "") { this.position.file = file; } // Create line reader or replace it when new journal file has been opened let lineReader = this.lineReader; if (lineReader == null || file !== this.position.file) { if (lineReader != null) { await lineReader.close(); } lineReader = this.lineReader = await LineReader.create(join(this.directory, file), file === this.position.file ? this.position.offset : 0, file === this.position.file ? this.position.line : 1); } // Iterate over all lines of the journal file this.lastPosition = { file, offset: lineReader.getOffset(), line: lineReader.getLine() }; for await (const line of lineReader) { if (signal.aborted) { break; } // Remember last read journal position for error messages and getLastPosition method const lastPosition = this.lastPosition = this.position; // Set position of next journal event this.position = { file, offset: lineReader.getOffset(), line: lineReader.getLine() }; try { // Parse the journal event and yield it yield this.parseJournalEvent(line); // this.lastPosition = position; } catch (error) { throw new JournalError(`Parse error in ${lastPosition.file}:${lastPosition.line}: ` + `${getErrorMessage(error)}: ${line.trim()}`); } } } } /** * Returns async iterator for the journal events. * * @returns Async iterator for the events in this journal. */ [Symbol.asyncIterator]() { return this.generator; } /** * Returns the next event from the journal. When end of journal is reached then in watch mode this method waits * until a new event arrives. When not in watch mode or when journal is closed this method returns null when no * more events are available. * * @returns The next journal event or null when end is reached. */ async next() { const result = await this.generator.next(); return result.done === true ? null : result.value; } /** * Reads the given JSON file, parses it as at a journal event and returns it. * * @param filename - The filename of the JSON file to read. Relative to journal directory. * @returns The parsed journal event. Null when file is not present. */ async readFile(filename) { const path = join(this.directory, filename); if (!(await isPathReadable(path))) { return null; } const result = (await readFile(path)).toString(); if (!result.startsWith("{") || !result.endsWith("}\r\n")) { // JSON file is not fully written yet. Wait a little bit and try again. await sleep(25); return this.readFile(filename); } return JSON.parse(result, jsonReviver); } /** * Watches the given JSON file for changes and reports any new content. It always reports the current content as * first change. * * @param filename - The filename of the JSON file to read and watch. Relative to journal directory. * @yields Async iterator watching content changes. */ async *watchFile(filename) { const signal = this.abortController.signal; yield filename; try { for await (const event of watch(this.directory, { signal })) { if (event.filename === filename) { yield filename; } } } catch { // Ignoring watch abort, generator still stops yielding values } } async *watchFileContent(filename) { for await (const file of this.watchFile(filename)) { const content = await this.readFile(file); if (content != null) { yield content; } } } /** * Returns the current backpack inventory read from the Backpack.json file. * * @returns The current backpack inventory. Null if Backpack.json file does not exist or is not readable. */ readBackpack() { return this.readFile("Backpack.json"); } /** * Watches the Backpack.json file for changes and reports any new data. It always reports the current data as * first change. * * @returns Async iterator watching backpack inventory changes. */ watchBackpack() { return this.watchFileContent("Backpack.json"); } /** * Returns the current cargo data read from the Cargo.json file. * * @returns The current cargo data. Null if Cargo.json file does not exist or is not readable. */ readCargo() { return this.readFile("Cargo.json"); } /** * Watches the Cargo.json file for changes and reports any new data. It always reports the current data as * first change. * * @returns Async iterator watching cargo changes. */ watchCargo() { return this.watchFileContent("Cargo.json"); } /** * Returns the current fleet carrier materials data read from the FCMaterials.json file. * * @returns The current fleet carrier materials data. Null if FCMaterials.json file does not exist or * is not readable. */ readFCMaterials() { return this.readFile("FCMaterials.json"); } /** * Watches the FCMaterials.json file for changes and reports any new data. It always reports the current data as * first change. * * @returns Async iterator watching fleet carrier materials data changes. */ watchFCMaterials() { return this.watchFileContent("FCMaterials.json"); } /** * Returns the current market data read from the Market.json file. * * @returns The current market data. Null if Market.json file does not exist or is not readable. */ readMarket() { return this.readFile("Market.json"); } /** * Watches the Market.json file for changes and reports any new data. It always reports the current data as * first change. * * @returns Async iterator watching market data changes. */ watchMarket() { return this.watchFileContent("Market.json"); } /** * Returns the current modules info read from the ModulesInfo.json file. * * @returns The current modules info. Null if ModulesInfo.json file does not exist or is not readable. */ readModulesInfo() { return this.readFile("ModulesInfo.json"); } /** * Watches the ModulesInfo.json file for changes and reports any new data. It always reports the current data as * first change. * * @returns Async iterator watching modules info changes. */ watchModulesInfo() { return this.watchFileContent("ModulesInfo.json"); } /** * Returns the current nav route read from the NavRoute.json file. * * @returns The current nav route data. Null if NavRoute.json file does not exist or is not readable. */ readNavRoute() { return this.readFile("NavRoute.json"); } /** * Watches the NavRoute.json file for changes and reports any new data. It always reports the current data as * first change. * * @returns Async iterator watching nav route data changes. */ watchNavRoute() { return this.watchFileContent("NavRoute.json"); } /** * Returns the current outfitting data read from the Outfitting.json file. * * @returns The current outfitting data. Null if Outfitting.json file does not exist or is not readable. */ readOutfitting() { return this.readFile("Outfitting.json"); } /** * Watches the Outfitting.json file for changes and reports any new data. It always reports the current data as * first change. * * @returns Async iterator watching outfitting data changes. */ watchOutfitting() { return this.watchFileContent("Outfitting.json"); } /** * Returns the current contents of the ship locker from the ShipLocker.json file. * * @returns The current ship locker content. Null if ShipLocker.json file does not exist or is not readable. */ readShipLocker() { return this.readFile("ShipLocker.json"); } /** * Watches the ShipLocker.json file for changes and reports any new data. It always reports the current data as * first change. * * @returns Async iterator watching ship locker content changes. */ watchShipLocker() { return this.watchFileContent("ShipLocker.json"); } /** * Returns the current shipyard data read from the Shipyard.json file. * * @returns The current shipyard data. Null if Shipyard.json file does not exist or is not readable. */ readShipyard() { return this.readFile("Shipyard.json"); } /** * Watches the Shipyard.json file for changes and reports any new data. It always reports the current data as * first change. * * @returns Async iterator watching shipyard data changes. */ watchShipyard() { return this.watchFileContent("Shipyard.json"); } /** * Returns the current status read from the Status.json file. * * @returns The current status. Null if Status.json file does not exist or is not readable. */ readStatus() { return this.readFile("Status.json"); } /** * Watches the Status.json file for changes and reports any new status. It always reports the current status as * first change. * * @returns Async iterator watching status changes. */ watchStatus() { return this.watchFileContent("Status.json"); } } //# sourceMappingURL=Journal.js.map