Added onboarding and exe output.

This commit is contained in:
2026-07-17 09:57:49 +01:00
parent 12edaa054d
commit a598abe1e6
9 changed files with 2193 additions and 47 deletions
+208 -13
View File
@@ -1,14 +1,125 @@
const fs = require('fs');
const path = require('path');
const CONFIG_PATH = path.resolve(__dirname, '..', 'config.json');
const CONFIG_FILE_NAME = 'config.json';
const DEFAULT_CONFIG_TEMPLATE = JSON.stringify({
apiKeys: [
{
key: 'YOUR_API_KEY_1',
rateLimitPerMinute: 50,
},
{
key: 'YOUR_API_KEY_2',
rateLimitPerMinute: 50,
},
],
serviceAccounts: [
{
path: 'credentials.json',
rateLimitPerMinute: 50,
},
{
path: 'credentials_2.json',
rateLimitPerMinute: 50,
},
],
documents: [
{
documentId: 'YOUR_SPREADSHEET_ID',
outputDir: 'output/your-document',
sheets: [
{
name: 'Sheet1',
outputFilename: 'sheet1.csv',
},
{
name: 'Sheet2',
outputFilename: 'sheet2.csv',
},
],
},
{
documentId: 'YOUR_SPREADSHEET_ID_2',
outputDir: 'output/your-document-2',
sheets: [
{
name: 'Sheet1',
outputFilename: 'sheet1.csv',
},
{
name: 'Sheet2',
outputFilename: 'sheet2.csv',
},
],
},
],
}, null, 2) + '\n';
function getDefaultConfigPath() {
const packagedApp = Boolean(process.pkg);
return path.resolve(packagedApp ? path.dirname(process.execPath) : process.cwd(), CONFIG_FILE_NAME);
}
function getConfigPathCandidates() {
const packagedApp = Boolean(process.pkg);
const externalDefault = getDefaultConfigPath();
return [
process.env.GSA_CONFIG,
externalDefault,
!packagedApp ? path.resolve(__dirname, '..', CONFIG_FILE_NAME) : null,
].filter(Boolean);
}
function createStarterConfig(configPath) {
const directory = path.dirname(configPath);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, {recursive: true});
}
fs.writeFileSync(configPath, DEFAULT_CONFIG_TEMPLATE, 'utf8');
}
function writeConfigFile(configPath, config) {
const directory = path.dirname(configPath);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, {recursive: true});
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
}
function getApiKeyValue(item) {
if (typeof item === 'string') {
return item.trim();
}
if (item && typeof item === 'object') {
return (item.key || item.apiKey || '').toString().trim();
}
return '';
}
function isPlaceholderApiKey(key) {
return key === 'YOUR_API_KEY_HERE';
}
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
throw new Error(`Missing config file at ${CONFIG_PATH}`);
const configPath = getConfigPathCandidates().find(candidate => fs.existsSync(candidate));
if (!configPath) {
const starterPath = process.env.GSA_CONFIG || getDefaultConfigPath();
const error = new Error(`Missing config file at ${starterPath}`);
error.code = 'ERR_CONFIG_MISSING';
error.configPath = starterPath;
throw error;
}
const json = fs.readFileSync(CONFIG_PATH, 'utf8');
return JSON.parse(json);
const json = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(json);
validateConfig(config);
return config;
}
function resolveOutputPath(outputDir, filename) {
@@ -26,6 +137,18 @@ function validateConfig(config) {
throw new Error('Missing or invalid configuration object');
}
if (config.apiKeys !== undefined && !Array.isArray(config.apiKeys)) {
throw new Error('`apiKeys` must be an array when present in config.json');
}
if (config.serviceAccounts !== undefined && !Array.isArray(config.serviceAccounts)) {
throw new Error('`serviceAccounts` must be an array when present in config.json');
}
if (config.documents !== undefined && !Array.isArray(config.documents)) {
throw new Error('`documents` must be an array when present in config.json');
}
if (!Array.isArray(config.documents) || config.documents.length === 0) {
throw new Error('`documents` must be a non-empty array in config.json');
}
@@ -44,20 +167,58 @@ function validateConfig(config) {
// Ensure API key values are unique when using apiKeys
if (hasApiKeys) {
const extracted = config.apiKeys.map(item => {
if (typeof item === 'string') return item.trim();
if (item && typeof item === 'object') return (item.key || item.apiKey || '').toString().trim();
return '';
});
const extracted = config.apiKeys.map(getApiKeyValue);
const nonEmpty = extracted.filter(Boolean);
const unique = new Set(nonEmpty);
if (unique.size !== nonEmpty.length) {
throw new Error('`apiKeys` contains duplicate `key` values; ensure all API keys are unique');
}
for (const item of config.apiKeys) {
const key = getApiKeyValue(item);
if (!key) {
throw new Error('Each entry in `apiKeys` must include a non-empty `key`');
}
if (isPlaceholderApiKey(key)) {
throw new Error('Each entry in `apiKeys` must include a real API key; replace `YOUR_API_KEY_HERE`');
}
if (typeof item !== 'string' && (!item || typeof item !== 'object')) {
throw new Error('`apiKeys` entries must be strings or objects');
}
if (item.rateLimitPerMinute !== undefined && (!Number.isFinite(item.rateLimitPerMinute) || item.rateLimitPerMinute <= 0)) {
throw new Error('`rateLimitPerMinute` in `apiKeys` must be a positive number when provided');
}
}
}
// Ensure service account credential paths are unique when using serviceAccounts
if (hasSvcAccounts) {
for (const item of config.serviceAccounts) {
if (typeof item === 'string') {
if (!item.trim()) {
throw new Error('Each string entry in `serviceAccounts` must be non-empty');
}
continue;
}
if (!item || typeof item !== 'object') {
throw new Error('`serviceAccounts` entries must be strings or objects');
}
const p = (item.path || item.credentialsPath || item.file || '').toString().trim();
if (!p) {
throw new Error('Each entry in `serviceAccounts` must include `path` or `credentialsPath`');
}
if (item.rateLimitPerMinute !== undefined && (!Number.isFinite(item.rateLimitPerMinute) || item.rateLimitPerMinute <= 0)) {
throw new Error('`rateLimitPerMinute` in `serviceAccounts` must be a positive number when provided');
}
}
// Read and canonicalize the contents of each service account credential
const svcContents = config.serviceAccounts.map(item => {
let p = '';
@@ -92,9 +253,43 @@ function validateConfig(config) {
// Basic validation of documents/sheets structure
for (const doc of config.documents) {
if (!doc.documentId) throw new Error('Each document must include a `documentId`');
if (!Array.isArray(doc.sheets) || doc.sheets.length === 0) throw new Error(`Document ${doc.documentId} must include a non-empty 'sheets' array`);
if (!doc || typeof doc !== 'object') {
throw new Error('Each document entry must be an object');
}
if (typeof doc.documentId !== 'string' || !doc.documentId.trim()) {
throw new Error('Each document must include a non-empty `documentId`');
}
if (doc.outputDir !== undefined && typeof doc.outputDir !== 'string') {
throw new Error(`Document ${doc.documentId} has an invalid outputDir; it must be a string`);
}
if (!Array.isArray(doc.sheets) || doc.sheets.length === 0) {
throw new Error(`Document ${doc.documentId} must include a non-empty 'sheets' array`);
}
for (const sheet of doc.sheets) {
if (typeof sheet === 'string') {
if (!sheet.trim()) {
throw new Error(`Document ${doc.documentId} contains an empty sheet name`);
}
continue;
}
if (!sheet || typeof sheet !== 'object') {
throw new Error(`Document ${doc.documentId} contains an invalid sheet entry`);
}
if (typeof sheet.name !== 'string' || !sheet.name.trim()) {
throw new Error(`Document ${doc.documentId} contains a sheet entry missing name`);
}
if (sheet.outputFilename !== undefined && typeof sheet.outputFilename !== 'string') {
throw new Error(`Document ${doc.documentId} sheet ${sheet.name} has an invalid outputFilename; it must be a string`);
}
}
}
}
module.exports = {loadConfig, resolveOutputPath, validateConfig};
module.exports = {loadConfig, resolveOutputPath, validateConfig, createStarterConfig, getDefaultConfigPath, writeConfigFile};
+230
View File
@@ -0,0 +1,230 @@
const readline = require('readline');
const {google} = require('googleapis');
const {createStarterConfig, getDefaultConfigPath, writeConfigFile} = require('./config');
const {getFirstServiceAccountAuthClient} = require('./auth');
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.');
}
}
async function pauseForEnter(ask) {
await ask('Press Enter to close this window...');
}
async function fetchSpreadsheetTitle(context, documentId) {
const sheetOptions = {version: 'v4'};
if (context.authClient) {
sheetOptions.auth = context.authClient;
}
const sheets = google.sheets(sheetOptions);
const request = {
spreadsheetId: documentId,
fields: 'properties/title',
};
if (context.apiKey) {
request.key = context.apiKey;
}
const response = await sheets.spreadsheets.get(request);
return response.data.properties?.title || documentId;
}
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'},
]
);
const credentialCount = await askPositiveInteger(
ask,
`How many ${authMode === 'apiKeys' ? 'API keys' : 'service accounts'} do you want to add? `
);
const config = {
apiKeys: [],
serviceAccounts: [],
documents: [],
};
if (authMode === 'apiKeys') {
for (let index = 0; index < credentialCount; index += 1) {
const key = await askNonEmpty(ask, `Enter API key ${index + 1}: `);
const rateLimitPerMinute = await askOptionalPositiveInteger(
ask,
`Rate limit per minute for API key ${index + 1} (press Enter for 50): `,
50
);
config.apiKeys.push({key, rateLimitPerMinute});
}
} else {
for (let index = 0; index < credentialCount; index += 1) {
const pathValue = await askNonEmpty(ask, `Enter service account path ${index + 1}: `);
const rateLimitPerMinute = await askOptionalPositiveInteger(
ask,
`Rate limit per minute for service account ${index + 1} (press Enter for 50): `,
50
);
config.serviceAccounts.push({path: pathValue, 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 askNonEmpty(ask, `Enter 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};
+18 -3
View File
@@ -1,10 +1,25 @@
const pThrottleModule = require('p-throttle');
const pThrottle = pThrottleModule.default || pThrottleModule;
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
function buildThrottle(maxRequestsPerMinute) {
const limit = Math.max(1, Math.floor(maxRequestsPerMinute));
const interval = Math.ceil(60000 / limit);
return pThrottle({limit: 1, interval});
let nextAvailable = 0;
return function throttle(fn) {
return async function throttledFunction(...args) {
const now = Date.now();
const waitMs = Math.max(0, nextAvailable - now);
nextAvailable = Math.max(nextAvailable, now) + interval;
if (waitMs > 0) {
await sleep(waitMs);
}
return fn(...args);
};
};
}
function applyRateLimitBuffer(rateLimitPerMinute) {