import fs from "fs"; import path from "path"; import Datastore from "nedb-promises"; import chalk from "chalk"; import { default as YAML } from "js-yaml"; import { ClassicLevel } from "classic-level"; /* -------------------------------------------- */ /* Configuration */ /* -------------------------------------------- */ /** * @typedef { * "Actor"|"Adventure"|"Cards"|"ChatMessage"|"Combat"|"FogExploration"|"Folder"|"Item"|"JournalEntry"|"Macro"| * "Playlist"|"RollTable"|"Scene"|"Setting"|"User" * } DocumentType */ /** * @typedef { * "actors"|"adventures"|"cards"|"messages"|"combats"|"fog"|"folders"|"items"|"journal"|"macros"|"playlists"|"tables"| * "scenes"|"settings"|"users" * } DocumentCollection */ /** * @typedef {object} PackageOptions * @property {boolean} [nedb=false] Whether to operate on a NeDB database, otherwise a LevelDB database is * assumed. * @property {boolean} [yaml=false] Whether the source files are in YAML format, otherwise JSON is * assumed. * @property {boolean} [log=false] Whether to log operation progress to the console. * @property {EntryTransformer} [transformEntry] A function that is called on every entry to transform it. */ /** * @typedef {PackageOptions} CompileOptions * @property {boolean} [recursive=false] Whether to recurse into child directories to locate source files, otherwise * only source files located in the root directory will be used. */ /** * @typedef {PackageOptions} ExtractOptions * @property {object} [yamlOptions] Options to pass to yaml.dump when serializing Documents. * @property {JSONOptions} [jsonOptions] Options to pass to JSON.stringify when serializing Documents. * @property {DocumentType} [documentType] Required only for NeDB packs in order to generate a correct key. * @property {boolean} [clean] Delete the destination directory before unpacking. * @property {DocumentCollection} [collection] Required only for NeDB packs in order to generate a correct key. Can be * used instead of documentType if known. * @property {NameTransformer} [transformName] A function that is used to generate a filename for the extracted * Document. If used, the generated name must include the appropriate file * extension. The generated name will be resolved against the root path * provided to the operation, and the entry will be written to that * resolved location. */ /** * @typedef {object} JSONOptions * @property {JSONReplacer|Array} [replacer] A replacer function or an array of property names in the * object to include in the resulting string. * @property {string|number} [space] A number of spaces or a string to use as indentation. */ /** * @callback JSONReplacer * @param {string} key The key being stringified. * @param {any} value The value being stringified. * @returns {any} The value returned is substituted instead of the current property's value. */ /** * @callback EntryTransformer * @param {object} entry The entry data. * @returns {Promise} Return boolean false to indicate that this entry should be discarded. */ /** * @callback NameTransformer * @param {object} entry The entry data. * @returns {Promise} If a string is returned, it is used as the filename that the entry will be written * to. */ /** * @callback HierarchyApplyCallback * @param {object} doc The Document being operated on. * @param {string} collection The Document's collection. * @param {object} [options] Additional options supplied by the invocation on the level above this one. * @returns {Promise} Options to supply to the next level of the hierarchy. */ /** * @callback HierarchyMapCallback * @param {any} entry The element stored in the collection. * @param {string} collection The collection name. * @returns {Promise} */ /** * A flattened view of the Document hierarchy. The type of the value determines what type of collection it is. Arrays * represent embedded collections, while objects represent embedded documents. * @type {Record>} */ const HIERARCHY = { actors: { items: [], effects: [] }, cards: { cards: [] }, combats: { combatants: [] }, delta: { items: [], effects: [] }, items: { effects: [] }, journal: { pages: [] }, playlists: { sounds: [] }, regions: { behaviors: [] }, tables: { results: [] }, tokens: { delta: {} }, scenes: { drawings: [], tokens: [], lights: [], notes: [], regions: [], sounds: [], templates: [], tiles: [], walls: [] } }; /** * A mapping of primary document types to collection names. * @type {Record} */ export const TYPE_COLLECTION_MAP = { Actor: "actors", Adventure: "adventures", Cards: "cards", ChatMessage: "messages", Combat: "combats", FogExploration: "fog", Folder: "folders", Item: "items", JournalEntry: "journal", Macro: "macros", Playlist: "playlists", RollTable: "tables", Scene: "scenes", Setting: "settings", User: "users" }; /* -------------------------------------------- */ /* Compiling */ /* -------------------------------------------- */ /** * Compile source files into a compendium pack. * @param {string} src The directory containing the source files. * @param {string} dest The target compendium pack. This should be a directory for LevelDB packs, or a .db file for * NeDB packs. * @param {CompileOptions} [options] * @returns {Promise} */ export async function compilePack(src, dest, { nedb=false, yaml=false, recursive=false, log=false, transformEntry }={}) { if ( nedb && (path.extname(dest) !== ".db") ) { throw new Error("The nedb option was passed to compilePacks, but the target pack does not have a .db extension."); } const files = findSourceFiles(src, { yaml, recursive }); if ( nedb ) return compileNedb(dest, files, { log, transformEntry }); return compileClassicLevel(dest, files, { log, transformEntry }); } /* -------------------------------------------- */ /** * Compile a set of files into a NeDB compendium pack. * @param {string} pack The target compendium pack. * @param {string[]} files The source files. * @param {Partial} [options] * @returns {Promise} */ async function compileNedb(pack, files, { log, transformEntry }={}) { // Delete the existing NeDB file if it exists. try { fs.unlinkSync(pack); } catch ( err ) { if ( err.code !== "ENOENT" ) throw err; } // Create a new NeDB Datastore. const db = Datastore.create(pack); const seenKeys = new Set(); const packDoc = applyHierarchy(doc => { if ( seenKeys.has(doc._key) ) { throw new Error(`An entry with key '${key}' was already packed and would be overwritten by this entry.`); } seenKeys.add(doc._key); delete doc._key; }); // Iterate over all source files, writing them to the DB. for ( const file of files ) { try { const contents = fs.readFileSync(file, "utf8"); const ext = path.extname(file); const isYaml = ext === ".yml" || ext === ".yaml"; const doc = isYaml ? YAML.load(contents) : JSON.parse(contents); const key = doc._key; const [, collection] = key.split("!"); // If the key starts with !folders, we should skip packing it as NeDB doesn't support folders. if ( key.startsWith("!folders") ) continue; if ( await transformEntry?.(doc) === false ) continue; await packDoc(doc, collection); await db.insert(doc); if ( log ) console.log(`Packed ${chalk.blue(doc._id)}${chalk.blue(doc.name ? ` (${doc.name})` : "")}`); } catch ( err ) { if ( log ) console.error(`Failed to pack ${chalk.red(file)}. See error below.`); throw err; } } // Compact the DB. db.stopAutocompaction(); await new Promise(resolve => db.compactDatafile(resolve)); } /* -------------------------------------------- */ /** * Compile a set of files into a LevelDB compendium pack. * @param {string} pack The target compendium pack. * @param {string[]} files The source files. * @param {Partial} [options] * @returns {Promise} */ async function compileClassicLevel(pack, files, { log, transformEntry }={}) { // Create the classic level directory if it doesn't already exist. fs.mkdirSync(pack, { recursive: true }); // Load the directory as a ClassicLevel DB. const db = new ClassicLevel(pack, { keyEncoding: "utf8", valueEncoding: "json" }); const batch = db.batch(); const seenKeys = new Set(); const packDoc = applyHierarchy(async (doc, collection) => { const key = doc._key; delete doc._key; if ( seenKeys.has(key) ) { throw new Error(`An entry with key '${key}' was already packed and would be overwritten by this entry.`); } seenKeys.add(key); const value = structuredClone(doc); await mapHierarchy(value, collection, d => d._id); batch.put(key, value); }); // Iterate over all files in the input directory, writing them to the DB. for ( const file of files ) { try { const contents = fs.readFileSync(file, "utf8"); const ext = path.extname(file); const isYaml = ext === ".yml" || ext === ".yaml"; const doc = isYaml ? YAML.load(contents) : JSON.parse(contents); const [, collection] = doc._key.split("!"); if ( await transformEntry?.(doc) === false ) continue; await packDoc(doc, collection); if ( log ) console.log(`Packed ${chalk.blue(doc._id)}${chalk.blue(doc.name ? ` (${doc.name})` : "")}`); } catch ( err ) { if ( log ) console.error(`Failed to pack ${chalk.red(file)}. See error below.`); throw err; } } // Remove any entries in the DB that are not part of the source set. for ( const key of await db.keys().all() ) { if ( !seenKeys.has(key) ) { batch.del(key); if ( log ) console.log(`Removed ${chalk.blue(key)}`); } } await batch.write(); await compactClassicLevel(db); await db.close(); } /* -------------------------------------------- */ /** * Flushes the log of the given database to create compressed binary tables. * @param {ClassicLevel} db The database to compress. * @returns {Promise} */ async function compactClassicLevel(db) { const forwardIterator = db.keys({ limit: 1, fillCache: false }); const firstKey = await forwardIterator.next(); await forwardIterator.close(); const backwardIterator = db.keys({ limit: 1, reverse: true, fillCache: false }); const lastKey = await backwardIterator.next(); await backwardIterator.close(); if ( firstKey && lastKey ) return db.compactRange(firstKey, lastKey, { keyEncoding: "utf8" }); } /* -------------------------------------------- */ /* Extracting */ /* -------------------------------------------- */ /** * Extract the contents of a compendium pack into individual source files for each primary Document. * @param {string} src The source compendium pack. This should be a directory for LevelDB pack, or a .db file for * NeDB packs. * @param {string} dest The directory to write the extracted files into. * @param {ExtractOptions} [options] * @returns {Promise} */ export async function extractPack(src, dest, { nedb=false, yaml=false, yamlOptions={}, jsonOptions={}, log=false, documentType, collection, clean, transformEntry, transformName }={}) { if ( nedb && (path.extname(src) !== ".db") ) { throw new Error("The nedb option was passed to extractPacks, but the target pack does not have a .db extension."); } collection ??= TYPE_COLLECTION_MAP[documentType]; if ( nedb && !collection ) { throw new Error("For NeDB operations, a documentType or collection must be provided."); } if ( clean ) fs.rmSync(dest, { force: true, recursive: true, maxRetries: 10 }); // Create the output directory if it doesn't exist already. fs.mkdirSync(dest, { recursive: true }); if ( nedb ) { return extractNedb(src, dest, { yaml, yamlOptions, jsonOptions, log, collection, transformEntry, transformName }); } return extractClassicLevel(src, dest, { yaml, log, yamlOptions, jsonOptions, transformEntry, transformName }); } /* -------------------------------------------- */ /** * Extract a NeDB compendium pack into individual source files for each primary Document. * @param {string} pack The source compendium pack. * @param {string} dest The root output directory. * @param {Partial} [options] * @returns {Promise} */ async function extractNedb(pack, dest, { yaml, yamlOptions, jsonOptions, log, collection, transformEntry, transformName }={}) { // Load the NeDB file. const db = new Datastore({ filename: pack, autoload: true }); const unpackDoc = applyHierarchy((doc, collection, { sublevelPrefix, idPrefix }={}) => { const sublevel = keyJoin(sublevelPrefix, collection); const id = keyJoin(idPrefix, doc._id); doc._key = `!${sublevel}!${id}`; return { sublevelPrefix: sublevel, idPrefix: id }; }); // Iterate over all entries in the DB, writing them as source files. const docs = await db.find({}); for ( const doc of docs ) { await unpackDoc(doc, collection); if ( await transformEntry?.(doc) === false ) continue; let name = await transformName?.(doc); if ( !name ) { name = `${doc.name ? `${getSafeFilename(doc.name)}_${doc._id}` : doc._id}.${yaml ? "yml" : "json"}`; } const filename = path.join(dest, name); serializeDocument(doc, filename, { yaml, yamlOptions, jsonOptions }); if ( log ) console.log(`Wrote ${chalk.blue(name)}`); } } /* -------------------------------------------- */ /** * Extract a LevelDB pack into individual source files for each primary Document. * @param {string} pack The source compendium pack. * @param {string} dest The root output directory. * @param {Partial} [options] * @returns {Promise} */ async function extractClassicLevel(pack, dest, { yaml, yamlOptions, jsonOptions, log, transformEntry, transformName }) { // Load the directory as a ClassicLevel DB. const db = new ClassicLevel(pack, { keyEncoding: "utf8", valueEncoding: "json" }); const unpackDoc = applyHierarchy(async (doc, collection, { sublevelPrefix, idPrefix }={}) => { const sublevel = keyJoin(sublevelPrefix, collection); const id = keyJoin(idPrefix, doc._id); doc._key = `!${sublevel}!${id}`; await mapHierarchy(doc, collection, (embeddedId, embeddedCollectionName) => { return db.get(`!${sublevel}.${embeddedCollectionName}!${id}.${embeddedId}`); }); return { sublevelPrefix: sublevel, idPrefix: id }; }); // Iterate over all entries in the DB, writing them as source files. for await ( const [key, doc] of db.iterator() ) { const [, collection, id] = key.split("!"); if ( collection.includes(".") ) continue; // This is not a primary document, skip it. await unpackDoc(doc, collection); if ( await transformEntry?.(doc) === false ) continue; let name = await transformName?.(doc); if ( !name ) { name = `${doc.name ? `${getSafeFilename(doc.name)}_${id}` : key}.${yaml ? "yml" : "json"}`; } const filename = path.join(dest, name); serializeDocument(doc, filename, { yaml, yamlOptions, jsonOptions }); if ( log ) console.log(`Wrote ${chalk.blue(name)}`); } await db.close(); } /* -------------------------------------------- */ /* Utilities */ /* -------------------------------------------- */ /** * Wrap a function so that it can be applied recursively to a Document's hierarchy. * @param {HierarchyApplyCallback} fn The function to wrap. * @returns {HierarchyApplyCallback} The wrapped function. */ function applyHierarchy(fn) { const apply = async (doc, collection, options={}) => { const newOptions = await fn(doc, collection, options); for ( const [embeddedCollectionName, type] of Object.entries(HIERARCHY[collection] ?? {}) ) { const embeddedValue = doc[embeddedCollectionName]; if ( Array.isArray(type) && Array.isArray(embeddedValue) ) { for ( const embeddedDoc of embeddedValue ) await apply(embeddedDoc, embeddedCollectionName, newOptions); } else if ( embeddedValue ) await apply(embeddedValue, embeddedCollectionName, newOptions); } }; return apply; } /* -------------------------------------------- */ /** * Transform a Document's embedded collections by applying a function to them. * @param {object} doc The Document being operated on. * @param {string} collection The Document's collection. * @param {HierarchyMapCallback} fn The function to invoke. */ async function mapHierarchy(doc, collection, fn) { for ( const [embeddedCollectionName, type] of Object.entries(HIERARCHY[collection] ?? {}) ) { const embeddedValue = doc[embeddedCollectionName]; if ( Array.isArray(type) ) { if ( Array.isArray(embeddedValue) ) { doc[embeddedCollectionName] = await Promise.all(embeddedValue.map(entry => { return fn(entry, embeddedCollectionName); })); } else doc[embeddedCollectionName] = []; } else { if ( embeddedValue ) doc[embeddedCollectionName] = await fn(embeddedValue, embeddedCollectionName); else doc[embeddedCollectionName] = null; } } } /* -------------------------------------------- */ /** * Locate all source files in the given directory. * @param {string} root The root directory to search in. * @param {Partial} [options] * @returns {string[]} */ function findSourceFiles(root, { yaml=false, recursive=false }={}) { const files = []; for ( const entry of fs.readdirSync(root, { withFileTypes: true }) ) { const name = path.join(root, entry.name); if ( entry.isDirectory() && recursive ) { files.push(...findSourceFiles(name, { yaml, recursive })); continue; } if ( !entry.isFile() ) continue; const ext = path.extname(name); const isYaml = (ext === ".yml") || (ext === ".yaml"); if ( yaml && isYaml ) files.push(name); else if ( !yaml && (ext === ".json") ) files.push(name); } return files; } /* -------------------------------------------- */ /** * Serialize a Document and write it to the filesystem. * @param {object} doc The Document to serialize. * @param {string} filename The filename to write it to. * @param {Partial} [options] Options to configure serialization behavior. */ function serializeDocument(doc, filename, { yaml, yamlOptions, jsonOptions }={}) { fs.mkdirSync(path.dirname(filename), { recursive: true }); let serialized; if ( yaml ) serialized = YAML.dump(doc, yamlOptions); else { const { replacer=null, space=2 } = jsonOptions; serialized = JSON.stringify(doc, replacer, space); } fs.writeFileSync(filename, serialized + "\n"); } /* -------------------------------------------- */ /** * Join non-blank key parts. * @param {...string} args Key parts. * @returns {string} */ function keyJoin(...args) { return args.filter(_ => _).join("."); } /* -------------------------------------------- */ /** * Ensure a string is safe for use as a filename. * @param {string} filename The filename to sanitize * @returns {string} The sanitized filename */ function getSafeFilename(filename) { return filename.replace(/[^a-zA-Z0-9А-я]/g, '_'); }