553 lines
20 KiB
JavaScript
Raw Normal View History

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<string|number>} [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<false|void>} Return boolean false to indicate that this entry should be discarded.
*/
/**
* @callback NameTransformer
* @param {object} entry The entry data.
* @returns {Promise<string|void>} 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<object|void>} 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<any>}
*/
/**
* 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<string, Record<string, object|Array>>}
*/
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<DocumentType, DocumentCollection>}
*/
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<void>}
*/
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<PackageOptions>} [options]
* @returns {Promise<void>}
*/
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<PackageOptions>} [options]
* @returns {Promise<void>}
*/
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<void>}
*/
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<void>}
*/
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<ExtractOptions>} [options]
* @returns {Promise<void>}
*/
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<ExtractOptions>} [options]
* @returns {Promise<void>}
*/
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<CompileOptions>} [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<ExtractOptions>} [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, '_');
}