Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-paths-deposit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"crossref-utils": patch
---

Accept files and folders as arguments to `crossref deposit`, deprecating `--file`
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <type> -o <output>.xml
crossref deposit --type <type> -o <output>.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.
Expand Down Expand Up @@ -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`:

Expand All @@ -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`:

Expand Down
172 changes: 131 additions & 41 deletions src/cli/deposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DepositSource | undefined> {
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<DepositSource[]> {
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<DepositSource[]> {
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<string>();
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(
Expand All @@ -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(
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 <value>', '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 <value>', 'File to deposit (deprecated, pass as an argument)'))
.addOption(
new Option('--type <value>', 'Deposit type')
.choices(['conference', 'journal', 'preprint', 'dataset'])
Expand Down
Loading