UNPKG

@curvenote/cli

Version:
486 lines (485 loc) 22 kB
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')}`); } }