'use strict' const _ = require('lodash'), ApiClient = require('classeur-api-client'), async = require('async'), fs = require('fs-extra'), pathMod = require('path'), pathJoin = _.spread(pathMod.join), TreeManipulator = require('tree-manipulator') // const eyes = require('eyes'), p = eyes.inspect.bind(eyes) /** * Module for downloading and listing files and folders stored in [Classeur](http://classeur.io/). * * @example <caption>Installation</caption> * npm install classeur-downloader * @example <caption>Saving a single file's markdown content</caption> * const downloader = require('classeur-downloader') * downloader.saveSingleFile({ * file: 'some file ID', * userId: 'user ID', * apiKey: 'api key', * path: '/some/path.md', * markdown: true * }, (error) => { * if (error) throw error * }) * @example <caption>Saving directories</caption> * // Saves all files contained in 'folder1' and 'folder2' in subdirectories of mydir/ with those same names: * downloader.saveTree({ * folders: ['folder1', 'folder2' ] * userId: 'user ID', * apiKey: 'api key', * path: 'mydir/', * markdown: true * }, (error) => { * if (error) throw error * }) * @see The [README](index.html) for an overview and more usage examples. * @see The [source code]{@link https://github.com/zbentley/classeur-downloader} on GitHub. * @see The [classeur-api-client](http://zbentley.github.io/classeur-api-client/versions/latest) module (which `classeur-downloader` is built around) for a lower-level way to interact with the Classeur API. * @module classeur-downloader */ function getTree(byId, print, items) { let props = { identifierProperty: byId ? 'id' : 'name', nestedNodesProperty: 'files', } if ( print ) { props.valueGetter = function(obj, property) { // If we're getting the value of a node, and not its children, // stringify it for pretty printing. if (property === this.identifierProperty) { return APIobjectToString(obj, byId) } else { return obj[property] } } } let tm = new TreeManipulator(props) // If contents are supplied, bind the instance methods used by this script // to the contents to prevent having to pass around tree manipulators *and* // contents everywhere. if ( items !== undefined ) { _.mixin(tm, { print: _.partial(tm.print, items), findNode: _.partialRight(tm.findNode, items) }) } return tm } function errorIfExists(path, cb) { fs.stat(path, (error, result) => { if (error && error.errno === -2) { //ENOENT cb(null, result) } else { cb(error || new Error(`File ${path} exists, and --overwrite is not set.`), null) } }) } function getWriter(path, options, addExtension, content) { const writefunc = _.isString(content) ? fs.outputFile : fs.outputJson path = pathJoin(path) if ( addExtension ) { path += _.isString(content) ? '.md' : '.json' } return options.overwrite ? _.partial(writefunc, path, content) : _.partial(async.series, [ _.partial(errorIfExists, path), _.partial(writefunc, path, content), ]) } // For each item in the tree, either download it, or make the folder and recurse. function makeFolderOrSaveFile(conn, tree, options, id, cb) { const found = tree.findNode(id), kids = tree.nestedNodesProperty, node = found.node, parallel = [], markdown = options.markdown let path = found.path if ( _.has(node, kids) ) { // Handle creation of folder metadata file only applies in JSON mode, // and only applies to non-root nodes. if ( options.folderMetadata && path.length > 1 ) { path[path.length - 1] += '.folder_metadata.json' parallel.push(getWriter(path, options, false, node)) } parallel.push(_.partial(async.each, node[kids], (child, cb) => { makeFolderOrSaveFile(conn, tree, options, child[tree.identifierProperty], cb) })) } else { parallel.push(_.partial(async.waterfall,[ _.bind(conn.getFile, conn, node.id), function(result, cb) { getWriter(path, options, options.addExtension, markdown ? result.content.text : result)(cb) } ])) } async.parallel(parallel, cb) } // Print a tree rooted at each folder and file. Printing the top-level tree // results in leading \____ parent relationships for no reason. function showTree(items, conn, options, cb) { const tm = getTree(options.byId, true) // TODO group by files vs. folders. _.forEach(_.sortBy(items, tm.identifierProperty), _.bind(tm.print, tm)) cb(null, null) } function saveTree(items, conn, options, cb) { const path = options.path makeFolderOrSaveFile(conn, getTree(options.byId, false, { id: path, name: path, files: items // top-level folders and manually-specified files }), options, path, cb) } function APIobjectToString(object, byId) { if ( object.id || object.name ) { let info = [object.id, object.name] if ( byId ) info.reverse() return `${info.pop()} (${info.pop()})` } else { return '' } } function getFilesAndFolders(options, func, cb) { const conn = new ApiClient(options.userId, options.apiKey, options.host) async.parallel( [ (cb) => { conn.getFolders(options.folders, cb) }, (cb) => { conn.getFiles(options.files, cb) }, ], (error, result) => { if (error) { if ( ! _.isError(error) ) { error = new Error(error) } cb(error, null) } else { // _.flatten de-'segments' the array into a single list of files and folders. func(_.flatten(result), conn, options, cb) } } ) } function assertOptions(options, maxFiles, maxFolders) { options.folders = options.folders || [] options.files = options.files || [] if ( ! _.isUndefined(maxFiles) && options.files.length > maxFiles ) { throw new Error(`Got ${options.files.length} files, but expected no more than ${maxFiles}:\n${options.files}`) } if ( ! _.isUndefined(maxFolders) && options.folders.length > maxFolders ) { throw new Error(`Got ${options.folders.length} folders, but expected no more than ${maxFolders}:\n${options.folders}`) } if ( _.isUndefined(options.addExtension) ) { options.addExtension = true } return options } function scrubCallback(cb) { return (error, result) => { result = _.compact(_.flattenDeep(result)) cb(error || null, _.isEmpty(result) ? null : result) } } /** * Options for configuring `classeur-downloader`. * @typedef {Object} Options:Global * @property {String} userId - User ID with which to connect to the Classeur API. * @property {String} apiKey - API key to use when connecting to the Classeur API. This can be obtained by re-generating your key in the Classeur 'User' preferences pane. * @property {String[]} [files] - Array of file IDs to operate on. * - At least one value must be supplied in `options.files` or `options.folders`, otherwise an error will be raised. * @property {String[]} [folders] - Array of folder IDs to operate on. * - At least one value must be supplied in `options.files` or `options.folders`, otherwise an error will be raised. * @property {boolean} [byId=false] - If true, files and folders will be handled (saved or printed) by ID. If false, they will be handled by Classeur human-readable name. */ /** * Options for configuring the bulk download behavior of `classeur-downloader`. * All options used by {@link module:classeur-downloader~Options:Global} are also accepted. * @typedef {Object} Options:DownloadFilesAndFolders * @property {String} path - Destination path to save files and folders from Classeur. `path` must be an existent, writable folder. * - All files in `options.files` will be saved inside of `path`. All folders in the `options.folders` will be created (with names according to the `byId` property) in `path`, and the files they contain will be created within those folders. * - If `options.overwrite` is not set and name collisions occur with files being saved into `path`, an error will be raised and save operations will halt. Partial results may exist on the filesystem. * @property {boolean} [overwrite=false] - If true, items in `path` that already exist will be overwritten. * @property {boolean} [folderMetadata=false] - If true, generate JSON folder metadata for all folders in `folders`. * - If `true`, a single JSON file will be created next to every Classeur folder downloaded. That JSON file will be named after the folder, and will end in `.folder_metadata.json`. It will contain the full Classeur API metadata information for the folder. This is usually not useful, unless you are using `classeur-downlaoder` to back up a locally-hosted Classeur instance with the intent of using the generated files for some future restoration process. * @property {boolean} [markdown=false] - Whether or not to write markdown content for files. * - If `true`, saved files' content will be the markdown content of Classeur documents. * - If `false`, files' content will be their full JSON data from Classeur. Full JSON data objects include markdown content and other fields, and will likely not be able to be opened directly in a Markdown editor. * @property {boolean} [addExtension=true] - Whether or not appropriate extensions should be added to files written. * - If `true`, files saved with `options.markdown` set to `true` will have the `.md` extension, and files saved outside of `markdown` mode will have the `.json` extension. * - If false, extensions will not be added to files. */ /** * Options for configuring the single-file download behavior of `classeur-downloader`. * All options used by {@link module:classeur-downloader~Options:Global} are also accepted. * @typedef {Object} Options:DownloadSingleFile * @property {String} path - Destination path to save files and folders from Classeur. Must be either a nonexistent path in a writable directory, or an existent, writable file (if `options.overwrite` is set). * @property {String} [file=options.files[0]] - Single file ID to download and save. If not provided, `options.files[0]` will be used. * - This option is mutually exclusive with `options.files`. * @property {boolean} [overwrite=false] - If true, `path` will be overwritten with the new Classeur file content retrieved. * @property {boolean} [markdown=false] - Whether or not to write markdown content for `options.file`. * - If `true`, `options.file`'s content will be that file's markdown content, visible in the Classeur UI. * - If `false`, `options.file`'s content will be its full JSON data from Classeur. Full JSON data objects include markdown content and other fields, and will likely not be able to be opened directly in a Markdown editor. */ /** * @callback CompletionCallback * @param {Error?} error - An throwable Error (or subclass thereof) if an error occurrend. * - For errors in writing files, `error` may be any of the errors raised by the [fs](https://nodejs.org/api/fs.html) module. * - For errors retrieving data from the Classeur API, `error` may be one of the Error subclasses used by [classeur-api-client](http://zbentley.github.io/classeur-api-client/versions/latest). Errors will be supplied to `CompletionCallback`s in the same way they will be supplied to [ClasseurClient~ScrubbedCallback](http://zbentley.github.io/classeur-api-client/versions/latest/module-classeur-api-client.html#.ScrubbedCallback)s. * - `error` will always be `null` (not `undefined` or another falsy value) if no error occurred. * @param {*?} result - Behavior of `result` is not defined it should not be used. Will usually be `null`. May sometimes contain an array of partial result objects. */ /** * @summary Prints out (to the console) a tree structure of the Classeur hierarchy of supplied files and folders. * @param {module:classeur-downloader~Options:Global} options - Options for which Classeur files and folders to retrieve, and how to display them. * - Folders in the `options.folders` will be printed as root directories, and all of the files they contain will be printed. * - If the `options.byId` is `true`, files and folders will be printed out as 'id (name)'. Otherwise, they will be printed as 'name (id)', where 'name' is the human-readable name of an object in the Classeur UI. * @param {module:classeur-downloader~CompletionCallback} callback - Called with an error, if one occurred, or `null` if all operations were successful. */ module.exports.showTree = (options, cb) => { getFilesAndFolders(assertOptions(options), showTree, scrubCallback(cb)) } /** * Folders in the `options.folders` array will be saved as root directories (with names determined by the presence or absence of `options.byId`), and all of the files they contain will be saved within them. Files in `options.files` will be saved at the top level. * If the `options.byId` is `true`, files and folders' root names will be their Classeur object IDs. Extensions will be added regardless of `options.byId`, depending on the value of `options.addExtension`. * @summary Saves Classeur files and folders to a specified path on the local filesystem. * @param {module:classeur-downloader~Options:DownloadFilesAndFolders} options - Options for which Classeur files and folders to retrieve, and how to save them. * @param {module:classeur-downloader~CompletionCallback} callback - Called with an error, if one occurred, or `null` if all operations were successful. * * @example <caption>Saving files by ID</caption> * // Assume the folder with ID 'abcd' contains files with the IDs 'foo', 'bar', and 'baz'. * const downloader = require('classeur-downloader') * downloader.saveTree({ * files: [ 'quux' ], * folders: [ 'abcde' ], * userId: 'user ID', * apiKey: 'api key', * path: 'mydir/', * folderMetadata: true * }, (error) => { if (error) throw error }) * // 'mydir' will now contain: * // quux.json * // abcde.folder_metadata.json * // abcde * // \_ foo.json * // \_ bar.json * // \_ baz.json * * @example <caption>Saving markdown content</caption> * // Assume the folder with ID 'abcd' has the UI-visible name 'My Folder'. * // Assume it contains two files with IDs 'foo' and 'bar', and the names 'My Stuff' and 'My Other Stuff'. * downloader.saveTree({ * folders: [ 'abcde' ], * userId: 'user ID', * apiKey: 'api key', * path: 'mydir/' * markdown: true * }, (error) => { if (error) throw error }) * // 'mydir' will now contain: * // My Folder * // \_ My Stuff.md * // \_ My Other Stuff.md */ module.exports.saveTree = (options, cb) => { getFilesAndFolders(assertOptions(options), saveTree, scrubCallback(cb)) } /** * This function can be used when you don't need/want to create container folders for your retrieved Classeur content. * @summary Saves a single Classeur file to a specified path on the local filesystem. * @param {module:classeur-downloader~Options:DownloadSingleFile} options * - `options.folders` may not be supplied to this function. * - `options.byId` is ignored by this function. * - File content will be saved directly to `options.path` no folders or other metadata files will be created. * - File extensions (e.g. `.md`) will _not_ be added by SaveSingleFile write the extension you want into `options.path` directly. * @param {module:classeur-downloader~CompletionCallback} callback - Called with an error, if one occurred, or `null` if all operations were successful. * * @example <caption>Saving a single file's markdown content</caption> * const downloader = require('classeur-downloader') * downloader.saveSingleFile({ * files: 'some file id', * userId: 'user ID', * apiKey: 'api key', * path: 'myfile.markdown', * markdown: true * }, (error) => { if (error) throw error }) */ module.exports.saveSingleFile = (options, cb) => { options = assertOptions(options, 1, 0) const conn = new ApiClient(options.userId, options.apiKey, options.host), ext = pathMod.parse(options.path).ext async.waterfall([ _.bind(conn.getFile, conn, options.file || options.files[0]), (result, cb) => { getWriter([options.path], options, false, options.markdown ? result.content.text : result)(cb) } ], scrubCallback(cb)) }