@curvenote/cli
Version:
CLI Client library for Curvenote
486 lines (485 loc) • 22 kB
JavaScript
import { selectors } from 'myst-cli';
import chalk from 'chalk';
import { format } from 'date-fns';
import inquirer from 'inquirer';
import { plural } from 'myst-common';
import { confirmOrExit } from '../utils/utils.js';
import { getMyWorkFromKey } from '../works/utils.js';
import { getFromJournals, getFromUrl } from '../utils/api.js';
import { postNewWork, postNewWorkVersion } from '../works/push.js';
import { postNewSubmission, postUpdateSubmissionWorkVersion } from './utils.js';
export function kindQuestions(kinds) {
return {
name: 'kinds',
type: 'list',
message: 'What kind of submission are you making?',
choices: kinds.map(({ name, id }) => ({ name, value: id })),
};
}
export function collectionMoniker(collection) {
if (collection.name === collection.content.title || !collection.content.title) {
return collection.name;
}
return `${collection.content.title} (${collection.name})`;
}
export function collectionQuestions(venue, collections, opts) {
return {
name: 'collections',
type: 'list',
message: `Venue ${venue} has multiple collections${opts?.allowClosedCollection ? '' : ' open for submission'}. Which do you want to select?`,
choices: collections.map((item) => ({
name: collectionMoniker(item),
value: item,
})),
};
}
/**
* Choose and return a collection and kind based on venue
*
* By default, this function only looks at open collections; however, if
* `opts.allowClosedCollection` is `true`, the "open" requirement is removed.
*
* First `collection` must be determined; successful cases include:
* - `collection` is provided, valid, and "open"
* - `venue` has a single, "open" `collection`
* - `opts.yes` is `true` and the `venue` has a default, "open" `collection`
* - user interactively selects one of the available `collections` on the `venue`
*
* On failure, throws. Failure cases include:
* - No "open" collections
* - Provided `collection` is invalid or not "open"
* - `opts.yes` is `true` but there is no default, "open" `collection`
*
* After determining `collection`, `kind` is chosen using `determineKindFromCollection`.
*/
export async function determineCollectionAndKind(session, venue, collections, opts) {
const openCollections = [
...collections.items.filter((c) => c.open && c.default),
...collections.items.filter((c) => c.open && !c.default),
];
if (!opts?.allowClosedCollection && openCollections.length === 0) {
const message = `No collections are open for submissions at venue "${venue}"`;
session.log.info(`${chalk.red(`⛔️ ${message}`)}`);
throw new Error(message);
}
if (opts?.collection)
session.log.debug(`Explicit collection provided: ${opts?.collection}`);
let selectedCollection = opts?.collection
? collections.items.find((c) => c.name === opts?.collection)
: undefined;
if (opts?.collection) {
if (!selectedCollection) {
const message = `Collection "${opts?.collection}" does not exist at venue "${venue}"`;
session.log.info(`${chalk.bold.red(`⛔️ ${message}`)}`);
session.log.info(`${chalk.bold(`🗂 open collections are: ${openCollections.map((c) => collectionMoniker(c)).join(', ')}`)}`);
throw new Error(message);
}
if (selectedCollection && !selectedCollection?.open) {
const message = `Collection "${opts?.collection}" is not open for submissions at venue "${venue}"`;
session.log.info(`${chalk.bold.red(`⛔️ ${message}`)}`);
if (!opts?.allowClosedCollection) {
session.log.info(`${chalk.bold(`🗂 open collections are: ${openCollections
.map((c) => collectionMoniker(c))
.join(', ')}`)}`);
throw new Error(message);
}
}
}
let prompted = false;
if (!selectedCollection) {
const defaultCollection = collections.items.find((c) => c.default);
if (defaultCollection && !defaultCollection.open) {
session.log.info(`${chalk.red(`🗂 default collection "${defaultCollection.name}" is not open for submissions at venue "${venue}"`)}`);
}
if (!opts?.allowClosedCollection && openCollections.length === 1) {
selectedCollection = openCollections[0];
}
else if (opts?.allowClosedCollection && collections.items.length === 1) {
selectedCollection = collections.items[0];
}
else if (opts?.yes &&
defaultCollection &&
(defaultCollection.open || opts?.allowClosedCollection)) {
selectedCollection = defaultCollection;
}
else if (opts?.yes) {
const message = 'Collection must be specified to continue submission';
session.log.info(`${chalk.red(`⛔️ ${message}`)}`);
throw new Error(message);
}
else {
const response = await inquirer.prompt([
collectionQuestions(venue, opts?.allowClosedCollection ? collections.items : openCollections, opts),
]);
selectedCollection = response.collections;
prompted = true;
}
}
session.log.info(`🗂 Collection ${collectionMoniker(selectedCollection)} selected`);
const results = await determineKindFromCollection(session, venue, selectedCollection, opts);
return {
collection: selectedCollection,
kind: results.kind,
prompted: results.prompted || prompted,
};
}
/**
* Fetch a list of kinds from `venue` API
*/
export async function listSubmissionKinds(session, venue) {
return getFromJournals(session, `/sites/${venue}/kinds`);
}
/**
* Fetch a single `venue` kind from API
*/
export async function getSubmissionKind(session, venue, kindIdOrName) {
return getFromJournals(session, `/sites/${venue}/kinds/${kindIdOrName}`);
}
/**
* Choose and return one kind based on venue and collection
*
* Successful cases include:
* - `kind` is provided and available on the `collection`
* - `collection` with a single `kind`, which is returned
* - `opts.yes` is `true` and the `collection` has a default `kind`, which is returned
* - user interactively selects one of the available `kinds` on the `collection`
*
* On failure, throws. Failure cases include:
* - Invalid `kind` is provided
* - `opts.yes` is `true` but there is no default `kind`
* - API fetch for selected kind fails (user is not authorized, venue does not exist, etc)
*/
export async function determineKindFromCollection(session, venue, collection, opts) {
const kinds = collection.kinds;
let kindId;
let prompted = false;
if (opts?.kind) {
const match = kinds.find(({ name }) => name.toLowerCase() === opts.kind?.toLowerCase());
if (!match) {
const message = `Submission kind "${opts.kind}" is not accepted in the collection "${collectionMoniker(collection)}"`;
session.log.info(`${chalk.bold.red(`⛔️ ${message}`)}`);
session.log.info(`${chalk.bold(`📚 accepted kinds are: ${kinds.map((k) => k.name).join(', ')}`)}`);
throw new Error(message);
}
// Return the actual kind (including case)
kindId = match.id;
session.log.debug(`kindId from options`);
}
else if (kinds.length === 1) {
kindId = kinds[0].id;
session.log.debug(`kindId from only kind`);
}
else if (opts?.yes) {
const defaultKind = kinds.find((k) => k.default);
if (defaultKind) {
session.log.debug(`kindId from default kind`);
kindId = defaultKind.id;
}
else {
const message = 'Kind must be specified to continue submission';
session.log.info(`${chalk.red(`⛔️ ${message}`)}`);
throw new Error(message);
}
}
else {
const response = await inquirer.prompt([kindQuestions(kinds)]);
kindId = response.kinds;
prompted = true;
session.log.debug(`kindId from prompt`);
}
session.log.debug(`kindId: ${kindId}`);
let kind;
// retrieve the full kind DTO from the API
try {
kind = await getSubmissionKind(session, venue, kindId);
}
catch (err) {
const message = `Could not get submission kind details "${kindId}" from venue ${venue}`;
session.log.info(`${chalk.red(`🚨 ${message}`)}`);
throw new Error(message, { cause: err });
}
return { kind, prompted };
}
export function getSiteConfig(session) {
const siteConfig = selectors.selectCurrentSiteConfig(session.store.getState());
if (!siteConfig) {
throw new Error('🧐 No site config found.');
}
return siteConfig;
}
export async function checkVenueSubmitAccess(session, venue) {
try {
session.log.debug('checking submit access');
const { submit } = (await getFromJournals(session, `/sites/${venue}/access`));
session.log.debug('checking submit access - GET successful', submit);
if (submit)
return true;
session.log.debug('You do not have permission to submit to this venue.');
throw new Error('You do not have permission to submit to this venue.');
}
catch (err) {
const message = `Venue "${venue}" is not accepting submissions.`;
session.log.info(`${chalk.red(`🚦 ${message}`)}`);
session.log.info(`${chalk.gray(`🤖 API Response: ${err.message}`)}`);
session.log.debug(JSON.stringify(err, null, 2));
throw new Error(message, { cause: err });
}
}
/**
* Fetch `venue` collections from API
*/
export async function listCollections(session, venue) {
return getFromJournals(session, `/sites/${venue}/collections`);
}
/**
* Get collections from `venue` and log information about open collections
*
* Throws if the fetch for venue collections fails.
* By default, it also throws if there are no open collections.
*
* If `requireOpenCollections` is false, this function will not fail if there are
* only closed collections or no collections at all.
*/
export async function getVenueCollections(session, venue, requireOpenCollections = true) {
let collections;
try {
collections = await listCollections(session, venue);
}
catch (err) {
const message = `Venue "${venue}" is unavailable; make sure the name is correct and you have permission to access it`;
session.log.info(`${chalk.red(`🚦 ${message}`)}`);
throw new Error(message, { cause: err });
}
const openCollections = collections.items.filter((c) => c.open);
if (openCollections.length === 0) {
const message = `Venue "${venue}" has no open collections.`;
session.log.info(`${chalk.red(`🚦 ${message}`)}`);
if (requireOpenCollections)
throw new Error(message);
}
else if (openCollections.length > 1) {
session.log.info(`${chalk.green(`💚 venue "${venue}" is accepting submissions in the following collections: ${openCollections
.map((c) => collectionMoniker(c))
.join(', ')}`)}`);
}
else {
session.log.info(`${chalk.green(`💚 venue "${venue}" is accepting submissions (${collectionMoniker(openCollections[0])}).`)}`);
}
return collections;
}
export async function checkForSubmissionKeyInUse(session, venue, key) {
session.log.debug(`checking to see if submission key is in use: "${key}"`);
try {
const { exists } = (await getFromJournals(session, `/sites/${venue}/submissions/key/${key}`));
return exists;
}
catch (err) {
session.log.debug(err);
return null;
}
}
export async function chooseSubmission(session, submissions, opts) {
if (opts?.yes) {
session.log.debug(`Updating latest submission`);
return submissions[0];
}
const { useLatest } = await inquirer.prompt([
{
name: 'useLatest',
message: `Update latest submission?`,
type: 'confirm',
default: true,
},
]);
if (useLatest)
return submissions[0];
throw new Error('Using non-latest submission not yet supported...');
}
export async function getAllSubmissionsThatICanSeeUsingKey(session, venue, key, opts) {
session.log.debug(`checking for existing submission using key "${key}"`);
const submissions = [];
try {
// will only contain submissions is the user has scopes on the site
const siteSubmissions = await getFromJournals(session, `/sites/${venue}/submissions?${new URLSearchParams({ key }).toString()}`);
submissions.push(...siteSubmissions.items);
}
catch (err) {
session.log.debug(err);
}
try {
const mySubmissions = await getFromJournals(session, `/my/submissions?${new URLSearchParams({ key }).toString()}`);
submissions.push(...mySubmissions.items.filter((submission) => {
const correctVenue = submission.site_name === venue;
const submissionIsDuplicate = submissions.map(({ id }) => id).includes(submission.id);
return correctVenue && !submissionIsDuplicate;
}));
}
catch (err) {
session.log.debug(err);
}
if (opts?.includeDrafts) {
return submissions;
}
// TODO we can remove this additional filtering once the `/my/submissions?key=` API endpoint filters out drafts by default
const draftSubmissions = submissions.filter((submission) => submission.status === 'DRAFT');
if (draftSubmissions.length > 0) {
session.log.debug(`Ignoring ${plural('%s draft submission(s)', draftSubmissions)}`);
}
return submissions.filter((submission) => submission.status !== 'DRAFT');
}
export async function getSubmissionToUpdate(session, submissions) {
const nonDraftSubmissions = submissions.filter((submission) => submission.status !== 'DRAFT');
if (nonDraftSubmissions.length === 0) {
session.log.debug('existing submission not found');
return;
}
if (nonDraftSubmissions.length === 1) {
session.log.debug(`${chalk.bold(`🔍 Found one existing submission`)}`);
return nonDraftSubmissions[0];
}
session.log.info(`🔍 Found ${plural('%s existing submission(s)', nonDraftSubmissions)} for this work`);
const submission = await chooseSubmission(session, nonDraftSubmissions);
return submission;
}
export async function confirmUpdateToExistingSubmission(session, venue, collections, submission, key, opts) {
session.log.debug('found existing submission with work');
const lastSubDate = submission.active_version.date_created;
session.log.info(chalk.bold(`🗓 Work was last submitted to "${venue}" on ${lastSubDate ? format(new Date(lastSubDate), 'dd MMM, yyyy HH:mm:ss') : 'unknown'}.`));
try {
session.log.debug('existing submission collection id', submission.collection?.id);
const collection = collections.items.find((c) => c.id === submission.collection?.id);
const openCollections = collections.items.filter((c) => c.open);
if (opts?.collection && opts.collection !== submission.collection?.name) {
session.log.info(`🪧 NOTE: the --collection option was provided, but will be ignored as you are updating an existing submission`);
}
session.log.info(`✅ Submission found, collection: ${collection ? collectionMoniker(collection) : 'unknown'}, ${plural('%s version(s)', submission.num_versions)} present, active status: ${submission.active_version.status}.`);
if (!collection?.open) {
const message = 'The collection for this submission is not accepting submissions';
session.log.error(chalk.bold.red(`⛔️ ${message}`));
session.log.info(`${chalk.bold(`📚 Open collections are: ${openCollections.map((c) => collectionMoniker(c)).join(', ')}`)}`);
throw new Error(message);
}
const work = await getMyWorkFromKey(session, key);
if (!work) {
session.log.info(`${chalk.yellow('👉 You do not own the work associated with this submission, but you may still be able to update it')}`);
}
const kindId = (submission?.kind).id;
const kindName = (submission?.kind).name ?? submission?.kind;
if (opts?.kind && opts.kind !== kindName) {
session.log.info(`🪧 NOTE: the --kind option was provided, but will be ignored as you are updating an existing submission`);
}
session.log.debug(`resolved kind to ${kindName}`);
const kind = await getSubmissionKind(session, venue, kindId);
if (!collection.kinds.find((k) => k.id === kindId)) {
const message = `The kind "${kind.name}" is not accepted in the collection "${collectionMoniker(collection)}". This indicates a problem with your previous submission, please contact support@curvenote.com.`;
session.log.error(`${chalk.red(`⛔️ ${message}`)}`);
throw new Error(message);
}
await confirmOrExit(`Update your submission to "${venue}" based on the contents of your local folder?`, opts);
return { kind, collection };
}
catch (err) {
session.log.debug(err);
const message = 'Submission not found, or you do not have permission to update it';
session.log.info(`${chalk.red(`🚨 ${message}`)}`);
throw new Error(message, { cause: err });
}
}
export async function createNewSubmission(session, submitLog, venue, collection, kind, cdn, cdnKey, jobId, key, opts, existingWork) {
const tags = opts?.tags && opts.tags.length > 0 ? opts.tags : undefined;
const workResp = existingWork ?? (await getMyWorkFromKey(session, key));
let work;
if (workResp) {
session.log.debug(`posting new work version...`);
work = await postNewWorkVersion(session, workResp.links.versions, cdnKey, cdn);
session.log.debug(`new work posted with version id ${work.version_id}`);
}
else {
session.log.debug(`posting new work...`);
try {
work = await postNewWork(session, cdnKey, cdn, key);
}
catch (err) {
if (opts?.draft) {
session.log.debug(`unable to create a work with key ${key} - attempting to create un-keyed work for draft submission`);
work = await postNewWork(session, cdnKey, cdn, undefined);
}
else {
throw err;
}
}
session.log.debug(`new work posted with id ${work.id}`);
}
if (!work.version_id) {
throw new Error('Failed to create a work version');
}
session.log.debug(`posting new submission...`);
const submission = await postNewSubmission(session, venue, collection.id, kind.id, work.version_id, opts?.draft ?? false, jobId, undefined, tags);
session.log.debug(`new submission posted with id ${submission.id}`);
if (opts?.draft) {
session.log.info(`🚀 ${chalk.green(`Your draft was successfully submitted to "${venue}"`)}.`);
}
else {
session.log.info(`🚀 ${chalk.green(`Your work was successfully submitted to "${venue}"`)}.`);
}
submitLog.work = {
id: work.id,
date_created: workResp?.date_created ?? work.date_created,
};
submitLog.workVersion = {
id: work.version_id,
date_created: work.date_created,
};
submitLog.submission = {
id: submission.id,
date_created: submission.date_created,
};
submitLog.submissionVersion = {
id: submission.active_version_id,
date_created: submission.date_created,
};
}
export async function updateExistingSubmission(session, submitLog, venue, cdnKey, existingSubmission, jobId, opts) {
const tags = opts?.tags && opts.tags.length > 0 ? opts.tags : undefined;
session.log.debug(`existing submission - upload & post`);
try {
if (!existingSubmission.links.work) {
throw new Error('No work associated with existing submission');
}
session.log.debug(`getting existing work...`);
const workResp = await getFromUrl(session, `${existingSubmission.links.work}?submission=${existingSubmission.id}`);
if (!workResp) {
throw new Error('Unable to fetch work associated with existing submission');
}
session.log.debug(`posting new work version...`);
const work = await postNewWorkVersion(session, workResp.links.versions, cdnKey, session.config.privateCdnUrl);
if (!work.version_id) {
throw new Error('Failed to create a work version');
}
session.log.debug(`work version posted with id ${work.version_id}`);
session.log.debug(`posting new version to existing submission...`);
const submissionVersion = await postUpdateSubmissionWorkVersion(session, venue, existingSubmission.links.versions, work.version_id, jobId, undefined, tags);
session.log.debug(`submission version posted with id ${submissionVersion.id}`);
session.log.info(`🚀 ${chalk.bold.green(`Your submission was successfully updated at "${venue}"`)}.`);
submitLog.work = {
id: work.id,
date_created: workResp?.date_created ?? work.date_created,
};
submitLog.workVersion = {
id: work.version_id,
date_created: work.date_created,
};
submitLog.submission = {
id: existingSubmission.id,
date_created: existingSubmission.date_created,
};
submitLog.submissionVersion = {
id: submissionVersion.id,
date_created: submissionVersion.date_created,
};
}
catch (err) {
session.log.error(err.message);
throw new Error(`🚨 ${chalk.bold.red('Could not update your submission')}`);
}
}