@curvenote/cli
Version:
CLI Client library for Curvenote
275 lines (274 loc) ⢠10.7 kB
JavaScript
import chalk from 'chalk';
import inquirer from 'inquirer';
import { loadProjectFromDisk, selectors, writeConfigs } from 'myst-cli';
import { isORCID, isGitHubUsername, lookupAuthor } from './peopleLookup.js';
import { cleanProjectConfigForWrite } from '../utils.js';
// ============================================================================
// PROJECT MODIFICATION HANDLERS
// These functions operate on EXISTING projects (require projectConfig)
// Add new project modification operations here
// ============================================================================
/**
* Helper to validate that a project config exists
* Used by project modification operations
*/
export function validateExistingProject(session, currentPath) {
const projectConfig = selectors.selectCurrentProjectConfig(session.store.getState());
if (!projectConfig) {
throw Error(`No project config found at ${currentPath}. Run ${chalk.bold('curvenote init')} first.`);
}
return projectConfig;
}
/**
* Handle --write-toc option: Generate table of contents for existing project
*/
export async function handleWriteTOC(session, currentPath, projectConfig) {
if (projectConfig.toc) {
session.log.warn('Not writing the table of contents, it already exists!');
return;
}
await loadProjectFromDisk(session, currentPath, { writeTOC: true });
}
// ============================================================================
// AUTHOR MANAGEMENT HELPERS
// ============================================================================
/**
* Validate email format
*/
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
/**
* Parse author string: "Name; Affiliation; Email"
* Affiliation can be "null" to skip
*/
function parseAuthorString(authorStr) {
const parts = authorStr.split(';').map((p) => p.trim());
const name = parts[0];
const affiliation = parts[1] && parts[1].toLowerCase() !== 'null' ? parts[1] : undefined;
const email = parts[2] || undefined;
return {
name,
affiliations: affiliation ? [affiliation] : undefined,
email,
corresponding: !!email,
};
}
/**
* Match or add affiliations to the project
* Does case-insensitive matching against existing affiliations
*/
function matchOrAddAffiliations(projectConfig, author) {
if (!author.affiliations || author.affiliations.length === 0) {
return undefined;
}
// Get existing affiliations from project config
const existingAffiliations = projectConfig.affiliations || [];
const matchedAffiliations = [];
for (const newAffiliation of author.affiliations) {
// Try to find a case-insensitive match
const matchedExisting = existingAffiliations.find((existing) => {
const existingName = existing.name || existing.id || '';
return existingName.toLowerCase() === newAffiliation.toLowerCase();
});
if (matchedExisting) {
// Use the ID of the existing affiliation
const affiliationId = matchedExisting.id || matchedExisting.name;
matchedAffiliations.push(affiliationId);
}
else {
// No match found - add as new affiliation
// Use the affiliation string as-is (will be created by MyST)
matchedAffiliations.push(newAffiliation);
}
}
return matchedAffiliations.length > 0 ? matchedAffiliations : undefined;
}
/**
* Interactive prompt for adding an author
*/
async function promptForAuthor(session) {
const identifierPrompt = await inquirer.prompt([
{
name: 'identifier',
message: 'Enter ORCID or GitHub username (or leave blank to enter manually):',
type: 'input',
validate: (input) => {
if (!input)
return true;
const trimmed = input.trim();
// Check if it's a valid ORCID or GitHub username
if (isORCID(trimmed) || isGitHubUsername(trimmed)) {
return true;
}
return 'Invalid format. Please enter an ORCID ID (xxxx-xxxx-xxxx-xxxx) or a GitHub username';
},
},
]);
if (identifierPrompt.identifier) {
const author = await lookupAuthor(session, identifierPrompt.identifier);
if (author) {
session.log.info(`\n${chalk.bold('Found author information:')}`);
session.log.info(` Name: ${author.name}`);
if (author.orcid)
session.log.info(` ORCID: ${author.orcid}`);
if (author.github)
session.log.info(` GitHub: ${author.github}`);
if (author.email)
session.log.info(` Email: ${author.email}`);
if (author.affiliations?.length) {
session.log.info(` Affiliations: ${author.affiliations.join(', ')}`);
}
const confirm = await inquirer.prompt([
{
name: 'confirm',
message: 'Add this author?',
type: 'confirm',
default: true,
},
]);
if (confirm.confirm) {
return author;
}
}
}
// Manual entry
const manualPrompt = await inquirer.prompt([
{
name: 'name',
message: 'Author name:',
type: 'input',
validate: (input) => !!input.trim() || 'Name is required',
},
{
name: 'affiliation',
message: 'Affiliation (optional):',
type: 'input',
},
{
name: 'email',
message: 'Email (optional):',
type: 'input',
validate: (input) => {
if (!input)
return true;
return validateEmail(input) || 'Invalid email format';
},
},
]);
return {
name: manualPrompt.name,
affiliations: manualPrompt.affiliation ? [manualPrompt.affiliation] : undefined,
email: manualPrompt.email || undefined,
corresponding: !!manualPrompt.email,
};
}
/**
* Handle --add-authors option: Add authors to existing project
*/
export async function handleAddAuthors(session, currentPath, projectConfig, authorsInput) {
const newAuthors = [];
if (!authorsInput || authorsInput === 'true') {
// Pattern 1: Interactive flow
session.log.info(chalk.bold('\nš Adding authors interactively...\n'));
let addMore = true;
while (addMore) {
const author = await promptForAuthor(session);
if (author) {
newAuthors.push(author);
}
const continuePrompt = await inquirer.prompt([
{
name: 'addMore',
message: 'Add another author?',
type: 'confirm',
default: false,
},
]);
addMore = continuePrompt.addMore;
}
}
else {
// Check if this is pattern 2 (ORCID/GitHub lookup) or pattern 3 (name-based)
const entries = authorsInput
.split(',')
.map((s) => s.trim())
.filter(Boolean);
session.log.info(chalk.bold('\nš Looking up authors...\n'));
for (const entry of entries) {
// Try ORCID or GitHub lookup first
if (isORCID(entry) || isGitHubUsername(entry)) {
// Pattern 2: ORCID or GitHub lookup
const author = await lookupAuthor(session, entry);
if (author) {
newAuthors.push(author);
}
else {
session.log.error(`Skipping ${entry} due to lookup failure`);
}
}
else {
// Pattern 3: Parse name-based entry
const author = parseAuthorString(entry);
if (author.name) {
newAuthors.push(author);
}
}
}
}
if (newAuthors.length === 0) {
session.log.warn('No authors were added');
return;
}
// Process affiliations for all new authors
newAuthors.forEach((author) => {
session.log.debug(`Processing author ${author.name}: affiliations=${JSON.stringify(author.affiliations)}`);
if (author.affiliations) {
author.affiliations = matchOrAddAffiliations(projectConfig, author);
session.log.debug(`After matchOrAddAffiliations: affiliations=${JSON.stringify(author.affiliations)}`);
}
});
// Append to existing authors
const existingAuthors = projectConfig.authors || [];
// Generate unique IDs for new authors to avoid collisions
// Find the highest existing UID number
const existingIds = existingAuthors
.map((a) => a.id)
.filter((id) => !!id)
.map((id) => {
const match = id.match(/contributors-curvenote-generated-uid-(\d+)/);
return match ? parseInt(match[1], 10) : -1;
});
let nextUid = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 0;
// Assign IDs to new authors
newAuthors.forEach((author) => {
if (!author.id) {
author.id = `contributors-curvenote-generated-uid-${nextUid++}`;
}
});
const updatedAuthors = [...existingAuthors, ...newAuthors];
session.log.debug(`Final authors before write: ${JSON.stringify(updatedAuthors, null, 2)}`);
// Update project config
const updatedProjectConfig = {
...projectConfig,
authors: updatedAuthors,
};
await writeConfigs(session, currentPath, {
projectConfig: cleanProjectConfigForWrite(updatedProjectConfig),
});
session.log.info(chalk.green(`\nā
Added ${newAuthors.length} author${newAuthors.length > 1 ? 's' : ''} to project config`));
newAuthors.forEach((author, idx) => {
const identifiers = [];
if (author.orcid)
identifiers.push(`ORCID: ${author.orcid}`);
if (author.github)
identifiers.push(`GitHub: ${author.github}`);
const identifierStr = identifiers.length > 0 ? ` (${identifiers.join(', ')})` : '';
session.log.info(` ${idx + 1}. ${author.name}${identifierStr}`);
});
}
// Add more project modification handlers here, following the same pattern:
// - export async function handleSomeOption(session, currentPath, projectConfig)
// - Validate preconditions (e.g., check if already exists)
// - Perform the operation
// - Log appropriately