553 lines
20 KiB
JavaScript
553 lines
20 KiB
JavaScript
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, '_');
|
||
}
|