enuf
Version:
The quicker, easier way to deal with numbered files. The bibling lawyer's dream come true.
935 lines (778 loc) • 25.2 kB
JavaScript
const { lstatSync } = require('fs');
const path = require('path');
const {
isNull,
car,
cdr,
cons,
} = require('my-little-schemer');
const sh = require('shelljs');
const commandLineArgs = require('command-line-args');
const commandLineCommands = require('command-line-commands');
const getUsage = require('command-line-usage');
const pkg = require('./package.json');
/* ----- Utilities ----- */
// Determines whether the file is a numbered file
function isNumbered(name) {
return /^\d/.test(name);
}
// Extracts the number from a numbered filename
function getNum(name) {
if (isNumbered(name)) {
const num = /^\d+/.exec(name)[0];
return parseInt(num, 10);
}
return '';
}
// Extract the extension (with dot) from a filename. Doesn't use path becuase
// for other purposes, a dotfile needs to be treated as a debodied extension
function getExt(name) {
const extMatches = /\.[^.]*$/.exec(name);
if (extMatches) {
return extMatches[0];
}
return '';
}
// Extracts body of filename (i.e., what is between number and extension)
function getBody(name) {
const nameL = name.length;
const num = getNum(name);
const numStr = num.toString();
const numStrL = numStr.length;
const ext = getExt(name);
const extL = ext.length;
if (numStrL + extL === nameL) {
return '';
}
return name.slice(numStrL, nameL - extL);
}
// Parses filename into [number, body, ext] array, with option to override number
// and body (needed for naming new files)
function parseFileName(name, num = getNum(name), body = getBody(name)) {
return [num, body, getExt(name)];
}
// Get list of files in directory
function getFiles(dir) {
return sh.ls(dir)
// slice gets rid of extra stuff returned by sh.ls()
.slice()
// Filter out directories and 'index.[ext]' files.
.filter(file => lstatSync(path.resolve(dir, file)).isFile() && !/^index\.[^.]*$/.test(file));
}
// Get sorted list of numbered files
function getNumFiles(files) {
return files
.filter(name => isNumbered(name))
.sort((a, b) => getNum(a) - getNum(b));
}
// Get list of parsed filnames from list of files
function getParsedFiles(files) {
return files.map(name => parseFileName(name));
}
// Get list of parsed numbered files
function getParsedNumFiles(dir) {
return getParsedFiles(getNumFiles(getFiles(dir)));
}
// Get file numbers of all numbered files
function getFileNumbers(dir) {
return getParsedNumFiles(dir).map(parsedFile => car(parsedFile));
}
// Get parsed file by index
function getParsedFileByIndex(dir, index) {
const parsedNumFiles = getParsedNumFiles(dir);
for (let i = 0; i < parsedNumFiles.length; i += 1) {
const parsedFile = parsedNumFiles[i];
if (car(parsedFile) === index) {
return parsedFile;
}
}
throw new Error(`File ${index} not found.`);
}
// Turn parsed filename into path
function toPath(dir, parsedFileName) {
const fileName = parsedFileName.join('');
return path.resolve(dir, fileName);
}
// move files from origin array to paths given in destination array
function move(origins, destinations) {
origins.forEach((orig, i) => {
sh.mv(orig, destinations[i]);
});
}
function getIndicesFromArgs(args) {
const indices = [];
for (let i = 0; i < args.length; i += 1) {
if (!Number.isNaN(Number(args[i]))) {
indices.push(Number(args[i]));
}
}
return indices;
}
// Read file of filenames
function readFile(file) {
return sh.cat(file)
.slice()
// get rid of trailing separator chars
.replace(/\s*[\n;,]+\s*$/g, '')
// split on newline, comma or semicolon (treat multiple as one
// and ignore surrounding spaces)
.split(/\s*[\n;,]+\s*/);
}
/* ----- Operations ----- */
// Add one or more files to sequential indexes, starting from the given index.
// Then, renumber each colliding file
function addFiles(exportDir, workingDir, keepBody, index, names) {
if (Number.isNaN(index) || index === undefined) {
throw new SyntaxError('You did not indicate a file number.');
}
if (names.length === 0) {
throw new SyntaxError('You did not select any files.');
}
function reNum(fileMap, startN, prevN, col, lastN) {
if (isNull(fileMap)) {
return col([], []);
}
const curr = car(fileMap);
const currN = car(curr);
const rest = cdr(fileMap);
// StartN is the number the first inserted file will be mapped to.
// Any files numbered below that index should be skipped.
if (currN < startN) {
return reNum(rest, startN, prevN, (orig, dest) => col(orig, dest), currN);
}
// PrevN is the number the previous file will be mapped to. Initialized at
// the number of the first inserted file + number of files inserted. Once a gap is hit,
// the function should terminate.
if (currN > prevN) {
return col([], []);
}
// Preserve redundant numbering
if (currN === lastN) {
return reNum(rest, startN, prevN, (orig, dest) => col(
cons(toPath(workingDir, curr), orig),
cons(toPath(workingDir, cons(prevN, cdr(curr))), dest),
), currN);
}
const nextN = prevN + 1;
// If currN is equal to prevN, then collect
// the path to the origin file and collect a mv destination that is one number up
return reNum(rest, startN, nextN, (orig, dest) => col(
cons(toPath(workingDir, curr), orig),
cons(toPath(workingDir, cons(nextN, cdr(curr))), dest),
), currN);
}
const mappedFiles = getParsedNumFiles(workingDir);
// Files must be moved in reverse to avoid collision
const mvMap = reNum(mappedFiles, index, index + (names.length - 1), (orig, dest) => {
const relPathRegExp = /^(\.{0,2}(\/|\\))+/;
const addOrig = names.map((name) => {
// Handle a path being given rather than a filename
if (path.isAbsolute(name)) {
return name;
}
return path.resolve(exportDir, name);
});
const addDest = names.map((name, i) => {
if (keepBody) {
// Extract filename from path if path is given
const fileName = name.replace(relPathRegExp, '');
const body = getBody(fileName);
if (body) {
return toPath(workingDir, parseFileName(
fileName,
index + i,
/^\s*-/.test(body) ? body : ` - ${body}`,
));
}
}
return toPath(workingDir, parseFileName(name, index + i, ''));
});
return [orig.reverse().concat(addOrig.reverse()), dest.reverse().concat(addDest.reverse())];
});
const origins = mvMap[0];
const destinations = mvMap[1];
move(origins, destinations);
}
// Remove files from start index to end index (either delete or denumber)
function rm(dir, del, startIndex, endIndex = startIndex) {
const filesToRm = getParsedNumFiles(dir)
.filter(parsedFile => car(parsedFile) >= startIndex && car(parsedFile) <= endIndex);
const pathsToRm = filesToRm.map(parsedFile => toPath(dir, parsedFile));
// Either delete or denumber the files to be removed.
if (del) {
sh.rm(pathsToRm);
} else {
// Create denumbered paths. Given them a body if the body is blank
const denumberedPaths = filesToRm.map((parsedFile, i) => toPath(dir, cons(
'',
cons(
car(cdr(parsedFile)).replace(/^[\s-]+/, '') || `removed-file-${i + 1}-${Math.random()}`,
cdr(cdr(parsedFile)),
),
)));
move(pathsToRm, denumberedPaths);
}
}
// Remove gaps in file numbers
function degap(dir, startIndex = 0, endIndex) {
if (Number.isNaN(startIndex)) {
throw new SyntaxError('Missing start number');
}
function gapReNum(fileMap, prevN, col, lastN) {
if (isNull(fileMap)) {
return col([], []);
}
const curr = car(fileMap);
const currN = car(curr);
const rest = cdr(fileMap);
// startIndex is where degapping starts. Defaults to 0.
// Any files numbered below that index should be skipped.
if (currN < startIndex) {
return gapReNum(rest, prevN, (orig, dest) => col(orig, dest), currN);
}
// endIndex is where degapping ends. Function terminates
// when past that index, if one is given
if (currN > endIndex) {
return col([], []);
}
// Preserve redundant numbering
if (currN === lastN) {
return gapReNum(rest, prevN, (orig, dest) => col(
cons(toPath(dir, curr), orig),
cons(toPath(dir, cons(prevN - 1, cdr(curr))), dest),
), currN);
}
const nextN = prevN + 1;
// Prevent renumbering if file already at correct index
if (currN === prevN) {
return gapReNum(rest, nextN, (orig, dest) => col(orig, dest), currN);
}
// If currN is equal to prevN, then collect
// the path to the origin file and collect a mv destination that is the next sequenced number
return gapReNum(rest, nextN, (orig, dest) => col(
cons(toPath(dir, curr), orig),
cons(toPath(dir, cons(prevN, cdr(curr))), dest),
), currN);
}
const mappedFiles = getParsedNumFiles(dir);
// Files must be moved in order to avoid collision
const mvMap = gapReNum(mappedFiles, startIndex, (orig, dest) => [orig, dest]);
const origins = mvMap[0];
const destinations = mvMap[1];
move(origins, destinations);
}
function shift(dir, fromIndex, toIndex) {
if (Number.isNaN(fromIndex) || fromIndex === undefined) {
throw new SyntaxError('Missing from number');
}
if (Number.isNaN(toIndex || toIndex === undefined)) {
throw new SyntaxError('Missing to number');
}
function shiftReNum(fileMap, gap, col) {
if (isNull(fileMap)) {
return col([], []);
}
const curr = car(fileMap);
const currN = car(curr);
const rest = cdr(fileMap);
// fromIndex is the number where shifting will start.
// Any files numbered below that index should be skipped.
if (currN < fromIndex) {
return shiftReNum(rest, gap, (orig, dest) => col(orig, dest));
}
// the path to the origin file and collect a mv destination that
// is one number up
return shiftReNum(rest, gap, (orig, dest) => col(
cons(toPath(dir, curr), orig),
cons(toPath(dir, cons(currN + gap, cdr(curr))), dest),
));
}
const mappedFiles = getParsedNumFiles(dir);
const gap = toIndex - fromIndex;
const mvMap = shiftReNum(
mappedFiles,
gap,
(orig, dest) => {
// Files must be moved in reverse if gap is positive,
// and in order if negative to avoid collision
if (gap > 0) {
orig.reverse();
dest.reverse();
}
return [orig, dest];
},
);
const origins = mvMap[0];
const destinations = mvMap[1];
move(origins, destinations);
}
function mv(dir, fromIndex, toIndex) {
if (Number.isNaN(fromIndex) || fromIndex === undefined) {
throw new SyntaxError('Missing from number');
}
if (Number.isNaN(toIndex || toIndex === undefined)) {
throw new SyntaxError('Missing to number');
}
// Get moved file out of the way to avoid collision on renumbering
const origFromFileParsed = getParsedFileByIndex(dir, fromIndex);
const newFromFileParsed = cons('', cdr(origFromFileParsed));
sh.mv(toPath(dir, origFromFileParsed), toPath(dir, newFromFileParsed));
addFiles(dir, dir, true, toIndex, [newFromFileParsed.join('')]);
}
function swap(dir, firstIndex, secondIndex) {
if (Number.isNaN(firstIndex) || firstIndex === undefined) {
throw new SyntaxError('No files chosen.');
}
if (Number.isNaN(secondIndex || secondIndex === undefined)) {
throw new SyntaxError('Only one file chosen.');
}
const firstFile = getParsedFileByIndex(dir, firstIndex);
const secondFile = getParsedFileByIndex(dir, secondIndex);
const newFirstFile = secondFile.slice(1);
newFirstFile.unshift(firstFile[0]);
const newSecondFile = firstFile.slice(1);
newSecondFile.unshift(secondFile[0]);
const firstFilePath = toPath(dir, firstFile);
const secondFilePath = toPath(dir, secondFile);
const newFirstFilePath = toPath(dir, newFirstFile);
const newSecondFilePath = toPath(dir, newSecondFile);
const tmpPath = path.resolve(dir, '.swaptmp');
// Giving a tmp pointer is required to avoid collision if exts are same
sh.mv(firstFilePath, tmpPath);
sh.mv(secondFilePath, newFirstFilePath);
sh.mv(tmpPath, newSecondFilePath);
}
// Removes all bodies from numbered filenames, leaving numbers only
function clean(dir, startIndex = 0, endIndex) {
if (Number.isNaN(startIndex)) {
throw new SyntaxError('Missing start number');
}
function rmBod(fileMap, col) {
if (isNull(fileMap)) {
return col([], []);
}
const curr = car(fileMap);
const currN = car(curr);
const currB = car(cdr(curr));
const rest = cdr(fileMap);
// startIndex is where cleaning starts. Defaults to 0.
// Any files numbered below that index should be skipped.
// Filenames without bodies should also be skipped.
if (currN < startIndex || !currB) {
return rmBod(rest, (orig, dest) => col(orig, dest));
}
// endIndex is where cleaning ends. Function terminates
// when past that index, if one is given
if (endIndex && currN > endIndex) {
return col([], []);
}
// If file num is between startIndex and endIndex, collect the path to the origin file and
// collect a mv destination that is the filename without the body
return rmBod(rest, (orig, dest) => col(
cons(toPath(dir, curr), orig),
cons(toPath(dir, cons(currN, cons('', cdr(cdr(curr))))), dest),
));
}
const mappedFiles = getParsedNumFiles(dir);
// Since no renumbering, mv order doesn't matter.
const mvMap = rmBod(mappedFiles, (orig, dest) => [orig, dest]);
const origins = mvMap[0];
const destinations = mvMap[1];
move(origins, destinations);
}
// Reconciles all files with colliding numbers
function reconcile(dir, infoOnly, startN = 0, endN) {
if (Number.isNaN(startN)) {
throw new SyntaxError('Missing start number');
}
const parsedNumFiles = getParsedNumFiles(dir);
const collisions = [];
for (let i = 1; i < parsedNumFiles.length; i += 1) {
const curr = parsedNumFiles[i];
const currN = curr[0];
if (
currN >= startN &&
(endN ? currN <= endN : true) &&
currN === parsedNumFiles[i - 1][0]
) {
if (infoOnly) {
collisions.push(currN);
} else {
mv(dir, currN, currN + 1);
reconcile(dir, infoOnly, currN, endN);
break;
}
}
}
if (infoOnly) {
console.log(collisions.length ?
`The following files need reconciliation: ${collisions.join(', ')}` :
'All files are reconciled.');
}
}
// Removes all unnumbered files from directory
function purge(dir) {
getFiles(dir).forEach((file) => {
if (!isNumbered(file)) {
sh.rm(path.resolve(dir, file));
}
});
}
// List numbers of used files
function ls(dir, full) {
if (full) {
console.log(getNumFiles(getFiles(dir)));
} else{
console.log(getFileNumbers(dir).join(', '));
}
}
// Gets rid of leading zeros
function init(dir) {
getFiles(dir).forEach(name => {
if (/^0/.test(name)) {
sh.mv(path.resolve(dir, name), path.resolve(dir, name.replace(/^0+(\d+)/, '$1')));
}
})
}
// Add lead zeros
function zeros(dir, n) {
if (!n) {
throw new Error('Please specify how many lead zeros to add.')
}
getNumFiles(getFiles(dir)).forEach(name => {
const nNums = /^\d+/.exec(name)[0].length;
if (nNums < n) {
let newName = name;
for (let i = 0; i < n - nNums; i += 1) {
newName = '0' + newName;
}
sh.mv(path.resolve(dir, name), path.resolve(dir, newName));
}
});
}
/* ----- CLI ----- */
/* --- Generate Help Page --- */
// Helper function to generate help page
function printHelpPage(command, optionDefinitions) {
const commandList = {
init: {
name: 'init',
description: 'Initializes a directory for use with enuf by getting rid of any file numbers with lead zeros.',
},
list: {
name: 'list, ls',
description: 'Show all used indices.',
},
ls: {
name: 'list, ls',
description: 'Show all used indices.',
},
add: {
name: 'add',
description:'Insert one or more files into a numbered file directory, starting from the given index.',
},
remove: {
name: 'remove, rm',
description:'Remove one or more files by index.',
},
rm: {
name: 'remove, rm',
description: 'Remove one or more files by index.',
},
shift: {
name: 'shift',
description: 'Move a numbered file to a new index, move all subsequent files to subsequnet indices.',
},
degap: {
name: 'degap',
description: 'Remove gaps in numbering.',
},
move: {
name: 'move, mv',
description: 'Move file from one index to a new index.',
},
mv: {
name: 'move, mv',
description: 'Move file from one index to a new index.',
},
swap: {
name: 'swap',
description: 'Swap the indices of two files.',
},
reconcile: {
name: 'reconcile, r',
description: `Where multiple files share an index (possible if files have different bodies or extensions)
renumber files so that each file has a unique index.`,
},
r: {
name: 'reconcile, r',
description: `Where multiple files share an index (possible if files have different bodies or extensions)
renumber files so that each file has a unique index.`,
},
clean: {
name: 'clean',
description: 'Remove bodies from filenames, leaving only the index and extension.',
},
length: {
name: 'length, l',
description: 'Add lead zeros to filenames to get a minimum files number length.',
},
l: {
name: 'length, l',
description: 'Add lead zeros to filenames to get a minimum files number length.',
},
purge: {
name: 'purge',
description: `Delete all non-numbered files in the working directory, other than the file titled 'index.<ext>'.`,
},
};
const sections = [ // Universal template
{
header: `Usage: enuf ${command ? `${command}` : '<command>'} <options> ${(command !== null &&
command !== 'purge' &&
command !== 'list' &&
command !== 'ls' &&
command !== 'length' &&
command !== 'l' &&
command !== 'init') ? '<index>' : ''} ${(command === 'length' || command == 'l') ? '<length>' : ''} ${(command !== null &&
command !== 'purge' &&
command !== 'list' &&
command !== 'ls' &&
command !== 'length' &&
command !== 'l' &&
command !== 'init' &&
command !== 'add') ? ' <index2>' : ''} ${command === 'add' ? '<files>' : ''}`,
content: `${commandList[command] ? `${commandList[command].description} ` : ''} If the command takes indices, options declarations must always precede the indices. If the command takes files, these should always come last, after the options and index.`,
},
{
header: 'Options',
optionList: optionDefinitions,
},
{
content: `See 'enuf <command> -h' for help with ${command ? 'another command or \'enuf -h\' for a full list of commands.' : 'a specific command.'}`,
},
];
if (!command) { // Show title and command list only for 'enuf -h'
sections.unshift({
header: pkg.name,
content: pkg.description,
});
const commandDescriptions = Object.values(commandList).filter((a, i, arr) => {
if (i > 0) {
return a.name !== arr[i - 1].name;
}
return true;
});
sections.splice(sections.length - 1, 0, {
header: 'Command List',
content: commandDescriptions,
});
}
console.log(getUsage(sections));
}
/* ----- Commands ----- */
const validCommands = [
null,
'init',
'list',
'ls',
'add',
'remove',
'rm',
'shift',
'degap',
'move',
'mv',
'swap',
'reconcile',
'r',
'clean',
'length',
'l',
'purge',
];
const { command, argv } = commandLineCommands(validCommands);
const globalOptionDefinitions = [
{
name: 'directory',
alias: 'd',
type: String,
defaultValue: process.cwd(),
description: 'Choose working directory. Default is current working directory.',
},
{
name: 'help',
alias: 'h',
type: Boolean,
description: 'Prints this help page.',
},
];
if (command === null) {
const specificOptionDefitions = [
{
name: 'version',
alias: 'v',
type: Boolean,
description: 'Prints the installed version of enuf.',
},
];
const optionDefinitions = globalOptionDefinitions
.slice(1)
.concat(specificOptionDefitions);
const options = commandLineArgs(optionDefinitions, { argv });
if (options.help) {
printHelpPage(command, optionDefinitions);
} else if (options.version) {
console.log(pkg.version);
}
}
if (command === 'add') {
const specificOptionDefitions = [
{
name: 'export-directory',
alias: 'e',
type: String,
description: 'Directory where files are being exported from. Default is the chosen working directory.',
},
{
name: 'keep-body',
alias: 'k',
type: Boolean,
description: 'Append numbers to current filenames instead of overriting filenames with numbers.',
},
{
name: 'file',
alias: 'f',
type: Boolean,
description: 'Import file names from file.',
},
{
name: 'all',
alias: 'a',
type: Boolean,
description: `Works in conjunction with the --export-directory|-e option. Import all files in given export directory. No effect if an export directory is not provided. Non-recursive (won't look in sub-directories).`,
},
];
const optionDefinitions = globalOptionDefinitions.concat(specificOptionDefitions);
const options = commandLineArgs(optionDefinitions, { argv });
if (options.help) {
printHelpPage(command, optionDefinitions);
} else {
let number;
let index;
for (let i = 0; i < argv.length; i += 1) {
if (!Number.isNaN(Number(argv[i]))) {
number = Number(argv[i]);
index = i;
break;
}
}
if (number === undefined) {
throw new SyntaxError('You did not indicate a file number.');
}
let files = argv.slice(index + 1);
if (options.file) {
files = readFile(files);
}
if (options['export-directory'] && options.all) {
files = getFiles(options['export-directory']);
}
addFiles(
options['export-directory'] || options.directory,
options.directory,
options['keep-body'],
number,
files,
);
}
}
if (command === 'remove' || command === 'rm') {
const specificOptionDefitions = [
{
name: 'delete',
alias: 'D',
type: Boolean,
description: 'Delete removed files instead of just de-numbering them.',
},
];
const optionDefinitions = globalOptionDefinitions.concat(specificOptionDefitions);
const options = commandLineArgs(optionDefinitions, { argv });
if (options.help) {
printHelpPage(command, optionDefinitions);
} else {
rm(options.directory, options.delete, ...getIndicesFromArgs(argv));
}
}
if (command === 'reconcile' || command === 'r') {
const specificOptionDefitions = [
{
name: 'info-only',
alias: 'i',
type: Boolean,
description: 'Displays file numbers which need reconciliation, but does not re-number files.',
},
];
const optionDefinitions = globalOptionDefinitions.concat(specificOptionDefitions);
const options = commandLineArgs(optionDefinitions, { argv });
if (options.help) {
printHelpPage(command, optionDefinitions);
} else {
reconcile(options.directory, options['info-only'], ...getIndicesFromArgs(argv));
}
}
if (command === 'list' || command === 'ls') {
const specificOptionDefitions = [
{
name: 'full',
alias: 'f',
type: Boolean,
description: 'Displays names of numbered files, instead of just numbers.',
},
];
const optionDefinitions = globalOptionDefinitions.concat(specificOptionDefitions);
const options = commandLineArgs(optionDefinitions, { argv });
if (options.help) {
printHelpPage(command, optionDefinitions);
} else {
ls(options.directory, options.full);
}
}
if (
command === 'degap' ||
command === 'shift' ||
command === 'move' ||
command === 'mv' ||
command === 'swap' ||
command === 'clean' ||
command === 'purge' ||
command === 'length' ||
command === 'l' ||
command === 'init'
) {
const options = commandLineArgs(globalOptionDefinitions, { argv });
if (options.help) {
printHelpPage(command, globalOptionDefinitions);
} else if (
command === 'purge' ||
command === 'init'
) {
const functions = {
purge,
init,
};
functions[command](options.directory);
} else {
const functions = {
degap,
shift,
mv,
move: mv,
swap,
clean,
length: zeros,
l: zeros,
};
functions[command](options.directory, ...getIndicesFromArgs(argv));
}
}