diff --git a/.changeset/tidy-paths-deposit.md b/.changeset/tidy-paths-deposit.md new file mode 100644 index 0000000..7bd21fc --- /dev/null +++ b/.changeset/tidy-paths-deposit.md @@ -0,0 +1,5 @@ +--- +"crossref-utils": patch +--- + +Accept files and folders as arguments to `crossref deposit`, deprecating `--file` diff --git a/README.md b/README.md index baf8023..0de8ac9 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,24 @@ npm install -g crossref-utils To create a deposit, from within a [MyST](https://github.com/jupyter-book/mystmd) project, run: ``` -crossref deposit --type -o .xml +crossref deposit --type -o .xml [paths...] ``` +The optional `paths` arguments say what to deposit; each one may be: + +- a MyST content file, e.g. `papers/smith/article.md` - deposited as a single article, using the metadata of the page and its containing project +- a `myst.yml`/`curvenote.yml` file or the folder containing one, e.g. `papers/smith` - deposited as a single article, using the project metadata, with the abstract and citations collected from the project pages +- a folder of MyST projects, e.g. `papers` - deposited as one article per project found in it (its subfolders, and their subfolders) + +Since folders of projects are expanded, `crossref deposit papers essays` and `crossref deposit papers/* essays/*` are equivalent; patterns are expanded by your shell, so do not quote them. Paths may be mixed and are deduplicated, so `crossref deposit myst.yml papers/*` deposits the project in the current folder alongside each project in `papers`. + +If no paths are given, the CLI falls back to discovery: if there is a project in the current folder, the CLI prompts for a file from that project; otherwise, every project in the current folder's subfolders (up to two levels deep) is deposited. Note that discovery has no concept of which projects are articles - if the repository contains MyST projects that are not articles, list the article paths explicitly instead. + This will prompt the user to select new DOIs, if they are not present in MyST metadata. Available options are: - `--type`: Currently available types are `journal`, `preprint`, `conference`, and `dataset`. Each type is discussed in more detail below. - `-o, --output`: Output xml file. If this is not provided, the xml will be printed to stdout. -- `--file`: Specific file to use for the deposit; this may be a single article or a `myst.yml` file. If not specified, the CLI will prompt the user. +- `--file`: **Deprecated** - pass the file as an argument instead. May not be combined with path arguments. - `--prefix`: DOI prefix to use for new, generated DOIs. Default is Curvenote's prefix. - `--name`, `--email`: Depositor name and email. Default Depositor is Curvenote. - `--registrant`: Registrant organization. Default is `Crossref` - likely this should not be changed. @@ -52,7 +62,7 @@ Different deposit types have different required fields. If DOIs are not provided #### Journal -This type is used to register a new journal and/or new journal articles. If `--file` is set to `myst.yml`, this deposit type will attempt to discover multiple articles in the MyST project. +This type is used to register a new journal and/or new journal articles. Multiple articles may be deposited at once, for example `crossref deposit --type journal papers/*`. In addition to the above article metadata for each article, this deposit type requires journal title and DOI, set under `venue` frontmatter in `myst.yml`: @@ -68,7 +78,7 @@ You may also specify in the frontmatter: #### Conference -This type is used to register a conference proceedings. Similar to "journal" deposits, this will attempt to discover multiple articles. +This type is used to register a conference proceedings. Similar to "journal" deposits, multiple articles may be deposited at once. In addition to the above article metadata for each article, this deposit type requires conference title, proceedings title, and proceedings publisher, set in `myst.yml`: diff --git a/src/cli/deposit.ts b/src/cli/deposit.ts index 8b19657..1e9ef2c 100644 --- a/src/cli/deposit.ts +++ b/src/cli/deposit.ts @@ -159,25 +159,136 @@ export async function depositArticleFromSource(session: ISession, depositSource: return { frontmatter: frontmatter ?? {}, dois, abstract, configFile }; } +const CONFIG_FILES = ['myst.yml', 'curvenote.yml']; +const VALID_DEPOSIT_EXTENSIONS = ['.md', '.ipynb', '.tex', '.myst.json']; + +function isDirectory(item: string) { + return fs.existsSync(item) && fs.lstatSync(item).isDirectory(); +} + +/** Return true if the file is a MyST config or a MyST content file */ +function isDepositFile(file: string) { + const lower = file.toLowerCase(); + if (CONFIG_FILES.includes(path.basename(lower))) return true; + return VALID_DEPOSIT_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} + +/** + * Return a deposit source for the MyST project defined in the given folder + * + * Returns undefined if the folder does not have a loadable MyST config. + */ +async function depositSourceFromFolder( + session: ISession, + folder: string, +): Promise { + const config = await loadConfig(session, folder); + if (!config) return; + const depositFile = selectors.selectLocalConfigFile(session.store.getState(), folder); + if (!depositFile) return; + return { projectPath: folder, depositFile }; +} + +/** + * Return deposit sources for all MyST projects in the given folder, up to two levels deep + * + * If the folder itself is a MyST project, that project is the only source; otherwise, each + * child folder is a source if it is a project, or is itself descended into. + */ +async function depositSourcesFromFolder( + session: ISession, + folder: string, +): Promise { + const source = await depositSourceFromFolder(session, folder); + if (source) return [source]; + const subdirs = fs + .readdirSync(folder) + .map((item) => path.join(folder, item)) + .filter(isDirectory) + .map((dir) => { + const files = fs.readdirSync(dir); + if (files.some((file) => CONFIG_FILES.includes(file))) return dir; + return files.map((item) => path.join(dir, item)).filter(isDirectory); + }) + .flat(); + const sources: DepositSource[] = []; + for (const dir of subdirs) { + const subdirSource = await depositSourceFromFolder(session, dir); + if (subdirSource) sources.push(subdirSource); + } + return sources; +} + +/** + * Return deposit sources for an explicit file or folder path + * + * A file is deposited from its containing project; a folder is deposited as a project, + * or, if it is not a project, as all the projects it contains. + */ +async function depositSourcesFromPath(session: ISession, depositPath: string) { + const resolved = path.resolve(depositPath); + if (!fs.existsSync(resolved)) { + if (depositPath.match(/[*?[\]]/)) { + throw new Error( + `Deposit path not found: ${depositPath} - patterns must be expanded by your shell, do not quote them`, + ); + } + throw new Error(`Deposit path not found: ${depositPath}`); + } + if (isDirectory(resolved)) { + const sources = await depositSourcesFromFolder(session, resolved); + if (sources.length === 0) { + throw new Error(`Unable to find MyST project in folder: ${depositPath}`); + } + return sources; + } + if (!isDepositFile(resolved)) { + throw new Error( + `Unable to deposit file: ${depositPath} - must be a MyST config or content file (${VALID_DEPOSIT_EXTENSIONS.join(', ')})`, + ); + } + const projectPath = await findCurrentProjectAndLoad(session, resolved); + if (!projectPath) { + throw new Error(`Unable to determine project path from file: ${depositPath}`); + } + return [{ depositFile: resolved, projectPath }]; +} + async function getDepositSources( session: ISession, + paths: string[], opts: DepositOptions, ): Promise { - let depositFile: string; - let projectPath: string | undefined; - // If file is specified, find the containing project and use it as the only source + if (opts.file && paths.length > 0) { + throw new Error( + 'Unable to use both --file and deposit path arguments; --file is deprecated, pass files and folders as arguments', + ); + } if (opts.file) { - depositFile = path.resolve(opts.file); - projectPath = await findCurrentProjectAndLoad(session, depositFile); - if (!projectPath) { - throw new Error(`Unable to determine project path from file: ${opts.file}`); + session.log.warn( + `--file is deprecated; pass files and folders as arguments, for example: crossref deposit ${opts.file}`, + ); + paths = [opts.file]; + } + // If paths are specified, each one is resolved to the project(s) it describes + if (paths.length > 0) { + const sources: DepositSource[] = []; + const depositFiles = new Set(); + for (const depositPath of paths) { + const pathSources = await depositSourcesFromPath(session, depositPath); + pathSources.forEach((source) => { + // The same project may be described by more than one path, e.g. `myst.yml .` + if (depositFiles.has(source.depositFile)) return; + depositFiles.add(source.depositFile); + sources.push(source); + }); } - return [{ depositFile, projectPath }]; + return sources; } - // If file is not specified and there is a project on the current path, select a single source from there + // If no paths are specified and there is a project on the current path, select a single source from there await session.reload(); const state = session.store.getState(); - projectPath = selectors.selectCurrentProjectPath(state); + const projectPath = selectors.selectCurrentProjectPath(state); const configFile = selectors.selectCurrentProjectFile(state); if (projectPath && configFile) { const project = await processProject( @@ -200,35 +311,10 @@ async function getDepositSources( }), }, ]); - depositFile = resp.depositFile; - return [{ projectPath, depositFile }]; + return [{ projectPath, depositFile: resp.depositFile }]; } - // If there is no project on the current path, load all projects in child folders (up to two levels deep) - const subdirs = fs - .readdirSync('.') - .map((item) => path.resolve(item)) - .filter((item) => fs.lstatSync(item).isDirectory()) - .map((dir) => { - const files = fs.readdirSync(dir); - if (files.includes('myst.yml') || files.includes('curvenote.yml')) return dir; - return files - .map((item) => path.join(dir, item)) - .filter((item) => fs.lstatSync(item).isDirectory()); - }) - .flat(); - const depositSources = ( - await Promise.all( - subdirs.map(async (dir) => { - const config = await loadConfig(session, dir); - if (!config) return; - return { - projectPath: dir, - depositFile: selectors.selectLocalConfigFile(session.store.getState(), dir), - }; - }), - ) - ).filter((source): source is DepositSource => !!source); - return depositSources; + // If there is no project on the current path, load all projects in child folders + return depositSourcesFromFolder(session, path.resolve('.')); } function issueDataFromArticles( @@ -408,7 +494,7 @@ function issueDataFromArticles( }; } -export async function deposit(session: ISession, opts: DepositOptions) { +export async function deposit(session: ISession, paths: string[], opts: DepositOptions) { let { type: depositType, name, email, registrant, prefix } = opts; if (!depositType) { const resp = await inquirer.prompt([ @@ -466,7 +552,7 @@ export async function deposit(session: ISession, opts: DepositOptions) { registrant = resp.registrant; } if (!prefix) prefix = 'curvenote'; - const depositSources = await getDepositSources(session, opts); + const depositSources = await getDepositSources(session, paths, opts); const depositArticles = ( await Promise.all(depositSources.map((source) => depositArticleFromSource(session, source))) ).sort((a, b) => Number(a.frontmatter.first_page) - Number(b.frontmatter.first_page)); @@ -664,7 +750,11 @@ export async function deposit(session: ISession, opts: DepositOptions) { function makeDepositCLI(program: Command) { const command = new Command('deposit') .description('Create Crossref deposit XML from local MyST content') - .addOption(new Option('--file ', 'File to deposit')) + .argument( + '[paths...]', + 'Files and/or folders to deposit; folders may contain the MyST project or its subfolders may. If not specified, projects are discovered from the current folder.', + ) + .addOption(new Option('--file ', 'File to deposit (deprecated, pass as an argument)')) .addOption( new Option('--type ', 'Deposit type') .choices(['conference', 'journal', 'preprint', 'dataset'])