338 lines
9.8 KiB
JavaScript
338 lines
9.8 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const readline = require('readline');
|
|
const {createStarterConfig, getDefaultConfigPath, writeConfigFile} = require('./config');
|
|
const {getFirstServiceAccountAuthClient} = require('./auth');
|
|
const {fetchSpreadsheetTitle} = require('./sheets');
|
|
|
|
function createPrompt() {
|
|
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
|
|
|
|
const ask = question => new Promise(resolve => {
|
|
rl.question(question, answer => resolve((answer || '').trim()));
|
|
});
|
|
|
|
const close = () => new Promise(resolve => {
|
|
rl.close();
|
|
resolve();
|
|
});
|
|
|
|
return {ask, close};
|
|
}
|
|
|
|
async function askNonEmpty(ask, question) {
|
|
while (true) {
|
|
const answer = await ask(question);
|
|
if (answer) {
|
|
return answer;
|
|
}
|
|
console.log('Please enter a value.');
|
|
}
|
|
}
|
|
|
|
async function askPositiveInteger(ask, question) {
|
|
while (true) {
|
|
const answer = await ask(question);
|
|
const value = Number.parseInt(answer, 10);
|
|
if (Number.isInteger(value) && value > 0) {
|
|
return value;
|
|
}
|
|
console.log('Please enter a whole number greater than zero.');
|
|
}
|
|
}
|
|
|
|
async function askChoice(ask, question, options) {
|
|
const optionText = options
|
|
.map((option, index) => `${index + 1}. ${option.label}`)
|
|
.join('\n');
|
|
|
|
while (true) {
|
|
const answer = await ask(`${question}\n${optionText}\n> `);
|
|
const lower = answer.toLowerCase();
|
|
|
|
const byNumber = Number.parseInt(answer, 10);
|
|
if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= options.length) {
|
|
return options[byNumber - 1].value;
|
|
}
|
|
|
|
const match = options.find(option => option.value.toLowerCase() === lower || option.label.toLowerCase() === lower);
|
|
if (match) {
|
|
return match.value;
|
|
}
|
|
|
|
console.log('Please choose one of the listed options.');
|
|
}
|
|
}
|
|
|
|
async function askOptionalPositiveInteger(ask, question, defaultValue) {
|
|
while (true) {
|
|
const answer = await ask(question);
|
|
if (!answer) {
|
|
return defaultValue;
|
|
}
|
|
|
|
const value = Number.parseInt(answer, 10);
|
|
if (Number.isInteger(value) && value > 0) {
|
|
return value;
|
|
}
|
|
|
|
console.log('Please enter a whole number greater than zero, or press Enter to use the default.');
|
|
}
|
|
}
|
|
|
|
function getServiceAccountIdentity(inputPath, baseDir) {
|
|
const value = (inputPath || '').trim();
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
|
|
const fullPath = path.isAbsolute(value) ? value : path.resolve(baseDir || process.cwd(), value);
|
|
if (!fs.existsSync(fullPath)) {
|
|
return value;
|
|
}
|
|
|
|
const raw = fs.readFileSync(fullPath, 'utf8').trim();
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
const canonicalize = obj => {
|
|
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
if (Array.isArray(obj)) return '[' + obj.map(canonicalize).join(',') + ']';
|
|
const keys = Object.keys(obj).sort();
|
|
return '{' + keys.map(key => JSON.stringify(key) + ':' + canonicalize(obj[key])).join(',') + '}';
|
|
};
|
|
return canonicalize(parsed);
|
|
} catch (_) {
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
function getServiceAccountsFolderPath(configPath) {
|
|
return path.join(path.dirname(configPath), 'serviceAccounts');
|
|
}
|
|
|
|
function listServiceAccountFiles(folderPath) {
|
|
if (!fs.existsSync(folderPath)) {
|
|
return [];
|
|
}
|
|
|
|
return fs.readdirSync(folderPath)
|
|
.map(fileName => path.join(folderPath, fileName))
|
|
.filter(filePath => {
|
|
try {
|
|
return fs.statSync(filePath).isFile();
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function extractSpreadsheetId(input) {
|
|
const value = (input || '').trim();
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
|
|
const urlMatch = value.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/i);
|
|
if (urlMatch && urlMatch[1]) {
|
|
return urlMatch[1];
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
async function askSpreadsheetIdOrUrl(ask, question) {
|
|
while (true) {
|
|
const answer = await ask(question);
|
|
const documentId = extractSpreadsheetId(answer);
|
|
|
|
if (documentId) {
|
|
return documentId;
|
|
}
|
|
|
|
console.log('Please enter a Google Sheets URL or spreadsheet ID.');
|
|
}
|
|
}
|
|
|
|
async function pauseForEnter(ask) {
|
|
await ask('Press Enter to close this window...');
|
|
}
|
|
|
|
function buildTitleContext(authMode, config) {
|
|
if (authMode === 'apiKeys' && Array.isArray(config.apiKeys) && config.apiKeys.length > 0) {
|
|
return {apiKey: config.apiKeys[0].key};
|
|
}
|
|
|
|
if (authMode === 'serviceAccounts' && Array.isArray(config.serviceAccounts) && config.serviceAccounts.length > 0) {
|
|
const authClient = getFirstServiceAccountAuthClient(config);
|
|
if (authClient) {
|
|
return {authClient};
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
async function runOnboarding() {
|
|
const {ask, close} = createPrompt();
|
|
const configPath = getDefaultConfigPath();
|
|
|
|
try {
|
|
console.log('Google Sheets Rate Assistant setup');
|
|
console.log('');
|
|
|
|
const setupMode = await askChoice(
|
|
ask,
|
|
'Would you like to use the wizard or just create a config file?',
|
|
[
|
|
{label: 'Wizard', value: 'wizard'},
|
|
{label: 'Config file', value: 'config'},
|
|
]
|
|
);
|
|
|
|
if (setupMode === 'config') {
|
|
createStarterConfig(configPath);
|
|
console.log('');
|
|
console.log(`Starter config created at ${configPath}`);
|
|
console.log('Edit that file, then run the app again.');
|
|
await pauseForEnter(ask);
|
|
return {mode: 'config', configPath};
|
|
}
|
|
|
|
const authMode = await askChoice(
|
|
ask,
|
|
'Which authentication method do you want to use?',
|
|
[
|
|
{label: 'API keys', value: 'apiKeys'},
|
|
{label: 'Service accounts', value: 'serviceAccounts'},
|
|
]
|
|
);
|
|
|
|
if (authMode === 'apiKeys') {
|
|
console.log('Warning: when using API keys, the spreadsheet must be publicly accessible.');
|
|
console.log('');
|
|
}
|
|
|
|
const config = {
|
|
apiKeys: [],
|
|
serviceAccounts: [],
|
|
documents: [],
|
|
};
|
|
Object.defineProperty(config, '__configPath', {
|
|
value: configPath,
|
|
enumerable: false,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
|
|
if (authMode === 'apiKeys') {
|
|
const credentialCount = await askPositiveInteger(
|
|
ask,
|
|
'How many API keys do you want to add? '
|
|
);
|
|
|
|
const seenKeys = new Set();
|
|
|
|
for (let index = 0; index < credentialCount; index += 1) {
|
|
let key;
|
|
while (true) {
|
|
key = await askNonEmpty(ask, `Enter API key ${index + 1}: `);
|
|
if (seenKeys.has(key)) {
|
|
console.log('That API key was already entered. Please enter a unique API key.');
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
|
|
const rateLimitPerMinute = await askOptionalPositiveInteger(
|
|
ask,
|
|
`Rate limit per minute for API key ${index + 1} (press Enter for 50): `,
|
|
50
|
|
);
|
|
|
|
seenKeys.add(key);
|
|
config.apiKeys.push({key, rateLimitPerMinute});
|
|
}
|
|
} else {
|
|
const serviceAccountsFolder = getServiceAccountsFolderPath(configPath);
|
|
if (!fs.existsSync(serviceAccountsFolder)) {
|
|
fs.mkdirSync(serviceAccountsFolder, {recursive: true});
|
|
}
|
|
|
|
console.log(`A serviceAccounts folder has been created at ${serviceAccountsFolder}.`);
|
|
console.log('Paste your service account files into that folder, then press Enter to continue.');
|
|
await pauseForEnter(ask);
|
|
|
|
const serviceAccountFiles = listServiceAccountFiles(serviceAccountsFolder);
|
|
console.log(`Found ${serviceAccountFiles.length} service account${serviceAccountFiles.length === 1 ? '' : 's'}.`);
|
|
|
|
if (serviceAccountFiles.length === 0) {
|
|
throw new Error(`No service account files were found in ${serviceAccountsFolder}`);
|
|
}
|
|
|
|
const seenServiceAccounts = new Set();
|
|
|
|
for (const filePath of serviceAccountFiles) {
|
|
const relativePath = path.relative(path.dirname(configPath), filePath).split(path.sep).join('/');
|
|
const serviceAccountIdentity = getServiceAccountIdentity(relativePath, path.dirname(configPath));
|
|
|
|
if (seenServiceAccounts.has(serviceAccountIdentity)) {
|
|
console.log(`Skipping duplicate service account file: ${path.basename(filePath)}`);
|
|
continue;
|
|
}
|
|
|
|
seenServiceAccounts.add(serviceAccountIdentity);
|
|
const rateLimitPerMinute = await askOptionalPositiveInteger(
|
|
ask,
|
|
`Rate limit per minute for ${path.basename(filePath)} (press Enter for 50): `,
|
|
50
|
|
);
|
|
|
|
config.serviceAccounts.push({path: relativePath, rateLimitPerMinute});
|
|
}
|
|
}
|
|
|
|
const documentCount = await askPositiveInteger(ask, 'How many documents do you want to pull from? ');
|
|
|
|
for (let documentIndex = 0; documentIndex < documentCount; documentIndex += 1) {
|
|
const documentId = await askSpreadsheetIdOrUrl(
|
|
ask,
|
|
`Enter Google Sheets URL or document ID ${documentIndex + 1}: `
|
|
);
|
|
let documentTitle = documentId;
|
|
|
|
try {
|
|
const titleContext = buildTitleContext(authMode, config);
|
|
documentTitle = await fetchSpreadsheetTitle(titleContext, documentId);
|
|
} catch (err) {
|
|
console.log(`Could not load spreadsheet title for ${documentId}; using the document ID instead.`);
|
|
}
|
|
|
|
const sheetCount = await askPositiveInteger(ask, `How many sheets for document ${documentIndex + 1}? `);
|
|
const sheets = [];
|
|
|
|
for (let sheetIndex = 0; sheetIndex < sheetCount; sheetIndex += 1) {
|
|
const sheetName = await askNonEmpty(ask, `Enter sheet name ${sheetIndex + 1} for document ${documentIndex + 1}: `);
|
|
sheets.push({
|
|
name: sheetName,
|
|
outputFilename: `${sheetName}.csv`,
|
|
});
|
|
}
|
|
|
|
config.documents.push({
|
|
documentId,
|
|
outputDir: `output/${documentTitle}`,
|
|
sheets,
|
|
});
|
|
}
|
|
|
|
writeConfigFile(configPath, config);
|
|
console.log('');
|
|
console.log(`Config saved to ${configPath}`);
|
|
return {mode: 'wizard', configPath, config};
|
|
} finally {
|
|
await close();
|
|
}
|
|
}
|
|
|
|
module.exports = {runOnboarding};
|