Files
LZStealth 26702cec4f
build-windows-exe / build (push) Successful in 31s
Error Fix and Console Logging format.
2026-07-17 11:39:58 +01:00

464 lines
14 KiB
JavaScript

// Google Sheets Rate Assistant
// Orchestrates periodic fetches of Google Sheets and writes CSV outputs.
// Uses `./lib/config`, `./lib/rate`, and `./lib/auth` for configuration,
// rate-limiting, and authentication/fetch helpers respectively.
// === Imports & constants ===
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const stringifyModule = require('./node_modules/csv-stringify/dist/cjs/sync.cjs');
const stringify = typeof stringifyModule === 'function'
? stringifyModule
: stringifyModule.default || stringifyModule.stringify || stringifyModule;
const {loadConfig, resolveOutputPath} = require('./lib/config');
const {buildThrottle, applyRateLimitBuffer, getTotalRateLimitPerMinute} = require('./lib/rate');
const {runOnboarding} = require('./lib/onboarding');
const authHelpers = require('./lib/auth');
const {fetchSpreadsheetTitle, fetchSpreadsheetValuesBatch} = require('./lib/sheets');
const {createApiKeyBatchFetcher, createServiceAccountBatchFetcher, getFirstApiKey, getFirstServiceAccountAuthClient, loadAuth} = authHelpers;
// === Status rendering / console display ===
// Renders per-sheet status lines when running in a TTY, updating in-place.
function mapDocumentToFilename(sheetName) {
const safeName = sheetName.replace(/[\\/:*?"<>|]/g, '_');
return `${safeName}.csv`;
}
const statusManager = {
keys: [],
lineIndexes: new Map(),
statuses: new Map(),
anchorSaved: false,
enabled: process.stdout && process.stdout.isTTY,
};
function clearConsoleForRun() {
if (process.stdout && process.stdout.isTTY && typeof console.clear === 'function') {
console.clear();
return;
}
if (process.stdout && typeof process.stdout.write === 'function') {
process.stdout.write('\u001b[2J\u001b[0f');
}
}
function limitToTerminalWidth(text) {
const width = Math.max(20, (process.stdout && process.stdout.columns) || 80);
if (text.length <= width) {
return text;
}
if (width <= 1) {
return text.slice(0, width);
}
return text.slice(0, width - 1) + '…';
}
// === Document grouping & mapping ===
// Map `config.documents` entries to internal groups with resolved output paths.
function renderStatusBlock() {
if (!statusManager.enabled || statusManager.keys.length === 0) {
return;
}
process.stdout.write('\u001b[s');
statusManager.keys.forEach((key, index) => {
const statusText = statusManager.statuses.get(key) || `${key} - pending`;
readline.cursorTo(process.stdout, 0);
readline.clearLine(process.stdout, 0);
process.stdout.write(limitToTerminalWidth(statusText));
if (index < statusManager.keys.length - 1) {
process.stdout.write('\n');
}
});
statusManager.anchorSaved = true;
}
function updateStatusLine(taskId, statusText) {
if (!statusManager.enabled || !statusManager.anchorSaved) {
return;
}
const lineIndex = statusManager.lineIndexes.get(taskId);
if (lineIndex === undefined) {
return;
}
process.stdout.write('\u001b[u');
if (lineIndex > 0) {
readline.moveCursor(process.stdout, 0, lineIndex);
}
readline.cursorTo(process.stdout, 0);
readline.clearLine(process.stdout, 0);
process.stdout.write(limitToTerminalWidth(statusText));
process.stdout.write('\u001b[u');
}
function setTaskStatus(taskId, statusText) {
statusManager.statuses.set(taskId, statusText);
updateStatusLine(taskId, statusText);
}
function initializeTaskStatuses(tasks) {
statusManager.keys = tasks.map(task => `${task.documentId}:${task.sheetName}`);
statusManager.lineIndexes.clear();
statusManager.statuses.clear();
statusManager.anchorSaved = false;
for (const [index, key] of statusManager.keys.entries()) {
statusManager.lineIndexes.set(key, index);
statusManager.statuses.set(key, `${key} - pending`);
}
renderStatusBlock();
}
// Authentication helpers: see `./lib/auth` for credential loaders and
// per-credential, throttled batch fetchers (API key and service account modes).
function buildDocumentGroups(config) {
const groups = [];
if (!Array.isArray(config.documents)) {
throw new Error('`documents` must be an array in config.json');
}
for (const documentConfig of config.documents) {
if (!documentConfig.documentId || !Array.isArray(documentConfig.sheets)) {
throw new Error('Each document must include documentId and sheets array');
}
const documentOutputDir = documentConfig.outputDir || './output';
const sheets = [];
for (const sheetConfig of documentConfig.sheets) {
const name = typeof sheetConfig === 'string' ? sheetConfig : sheetConfig.name;
const outputFilename = sheetConfig.outputFilename || mapDocumentToFilename(name);
const outputPath = resolveOutputPath(documentOutputDir, outputFilename);
sheets.push({
sheetName: name,
outputPath,
});
}
groups.push({
documentId: documentConfig.documentId,
sheets,
});
}
return groups;
}
// === Batch fetching (fallback) ===
// `index.js` provides a simple `batchFetchDocumentSheets` fallback used when
// no per-credential batch fetcher is configured by `./lib/auth`.
async function batchFetchDocumentSheets(context, documentId, sheetNames) {
return fetchSpreadsheetValuesBatch(context, documentId, sheetNames);
}
function formatStatusTimestamp(date = new Date()) {
const pad = value => String(value).padStart(2, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// === Execution / saving ===
// Functions that execute per-document fetches and write CSV outputs.
async function writeCsv(outputPath, rows) {
const csv = stringify(rows, {
header: false,
quoted: true,
});
await fs.promises.writeFile(outputPath, csv, 'utf8');
}
function executeDocumentGroup(group, fetcher) {
const sheetNames = group.sheets.map(sheet => sheet.sheetName);
for (const sheet of group.sheets) {
const displayName = `${group.documentDisplayName || group.documentId}:${sheet.sheetName}`;
setTaskStatus(`${group.documentId}:${sheet.sheetName}`, `${displayName} - pulling...`);
}
// Run the actual fetch/write flow in the background so the scheduler doesn't pause.
(async () => {
try {
const rowsBySheet = await fetcher(group.documentId, sheetNames);
for (const sheet of group.sheets) {
const rows = rowsBySheet[sheet.sheetName] || [];
try {
await writeCsv(sheet.outputPath, rows);
const relativePath = path.relative(process.cwd(), sheet.outputPath) || sheet.outputPath;
const timestamp = formatStatusTimestamp();
const displayName = `${group.documentDisplayName || group.documentId}:${sheet.sheetName}`;
setTaskStatus(
`${group.documentId}:${sheet.sheetName}`,
`${displayName} - saved to ${relativePath} (updated ${timestamp})`
);
} catch (writeErr) {
setTaskStatus(
`${group.documentId}:${sheet.sheetName}`,
`${group.documentId}:${sheet.sheetName} - failed to save: ${formatGoogleError(writeErr)}`
);
}
}
} catch (err) {
for (const sheet of group.sheets) {
setTaskStatus(
`${group.documentId}:${sheet.sheetName}`,
`${group.documentId}:${sheet.sheetName} - failed: ${interpretGoogleSheetsError(err, group.documentId, sheet.sheetName)}`
);
}
}
})();
}
function formatGoogleError(err) {
if (err && err.response && err.response.data) {
return `${err.message} (${JSON.stringify(err.response.data)})`;
}
return err.message || String(err);
}
function interpretGoogleSheetsError(err, documentId, sheetName) {
const message = formatGoogleError(err);
const notFound = err && err.response && err.response.status === 404;
const requestedEntity = message.includes('Requested entity was not found');
if (notFound || requestedEntity) {
let detail = `Spreadsheet not found or inaccessible: ${documentId}`;
if (sheetName) {
detail += `, sheet: ${sheetName}`;
}
detail += '. Check that the spreadsheet ID is correct, that the sheet exists, and that the document is shared with the service account or is publicly readable when using an API key.';
return detail;
}
return message;
}
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
function waitForEnter() {
if (!process.stdin || (!process.stdin.isTTY && !(process.stdout && process.stdout.isTTY))) {
return Promise.resolve();
}
return new Promise(resolve => {
const readline = require('readline');
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
rl.question('Press Enter to close this window...', () => {
rl.close();
resolve();
});
});
}
function formatError(err) {
if (!err) {
return 'Unknown error';
}
if (err.message) {
return err.message;
}
return String(err);
}
function isUserFacingError(err) {
if (!err) {
return false;
}
const message = formatError(err);
return /config\.json|api key|service account|spreadsheet|sheet|credential|document/i.test(message);
}
async function handleFatalError(err, prefix = 'Application error:') {
const message = formatError(err);
const friendlyPrefix = prefix || 'Application error:';
console.error(friendlyPrefix);
if (isUserFacingError(err)) {
console.error(message);
} else {
console.error('Something went wrong.');
if (message && message !== 'Unknown error') {
console.error(message);
}
}
await waitForEnter();
process.exit(1);
}
// === Main scheduler ===
// Orchestrates startup, selects the appropriate fetcher (API keys,
// service accounts), and runs the periodic loop.
async function scheduleRuns(config) {
const apiBatchFetcher = createApiKeyBatchFetcher(config);
const svcBatchFetcher = createServiceAccountBatchFetcher(config);
let fetcher;
if (apiBatchFetcher) {
fetcher = apiBatchFetcher;
} else if (svcBatchFetcher) {
fetcher = svcBatchFetcher;
} else {
const auth = await loadAuth(config);
const baseRate = config.rateLimitPerMinute || 50;
const bufferedRate = applyRateLimitBuffer(baseRate);
const throttle = buildThrottle(bufferedRate);
fetcher = throttle((documentId, sheetNames) => batchFetchDocumentSheets({authClient: auth}, documentId, sheetNames));
}
const groups = buildDocumentGroups(config);
if (groups.length === 0) {
throw new Error('No sheets configured to pull in config.json');
}
console.log('Google Sheets Rate Assistant');
const firstApiKey = getFirstApiKey(config);
const titleMap = {};
// If running in single-auth mode, try to warm the token
if (!apiBatchFetcher && !svcBatchFetcher) {
try {
const singleAuth = await loadAuth(config);
if (singleAuth) {
try {
const at = await singleAuth.getAccessToken();
console.log('Service account token acquired (len=', (at && at.token) ? at.token.length : 'no', ')');
} catch (e) {
console.error('Failed to acquire initial access token from service account:', e && e.message);
}
}
} catch (_) {
// loadAuth will throw if missing credentials; continue and let title fetch handle errors
}
}
// build a context for fetching titles: prefer API key, then first service account
let titleContext = null;
if (firstApiKey) {
titleContext = {apiKey: firstApiKey};
} else if (svcBatchFetcher) {
const firstSvcAuth = getFirstServiceAccountAuthClient(config);
if (firstSvcAuth) titleContext = {authClient: firstSvcAuth};
} else {
try {
const singleAuth = await loadAuth(config);
if (singleAuth) titleContext = {authClient: singleAuth};
} catch (_) {
// ignore
}
}
for (const group of groups) {
try {
titleMap[group.documentId] = await fetchSpreadsheetTitle(titleContext || {}, group.documentId);
} catch (err) {
titleMap[group.documentId] = group.documentId;
console.error(`Failed to load title for ${group.documentId}: ${formatGoogleError(err)}`);
}
}
const tasks = groups.flatMap(group =>
group.sheets.map(sheet => ({
documentId: group.documentId,
sheetName: sheet.sheetName,
}))
);
tasks.forEach(task => {
task.documentDisplayName = titleMap[task.documentId] || task.documentId;
});
groups.forEach(group => {
group.documentDisplayName = titleMap[group.documentId] || group.documentId;
});
const totalRateLimit = getTotalRateLimitPerMinute(config);
const intervalMs = Math.max(1, Math.ceil(60000 / totalRateLimit));
console.log(limitToTerminalWidth(`Configured for ${totalRateLimit} requests per minute across ${groups.length} documents (${intervalMs}ms interval)`));
console.log('');
initializeTaskStatuses(tasks);
let currentIndex = 0;
while (true) {
const group = groups[currentIndex];
currentIndex = (currentIndex + 1) % groups.length;
try {
// Fire off the document fetch/save in the background and don't await it,
// so the scheduler can continue to the next group immediately.
executeDocumentGroup(group, fetcher);
} catch (err) {
for (const sheet of group.sheets) {
setTaskStatus(
`${group.documentId}:${sheet.sheetName}`,
`${group.documentId}:${sheet.sheetName} - failed: ${interpretGoogleSheetsError(err, group.documentId, sheet.sheetName)}`
);
}
}
await sleep(intervalMs);
}
}
if (require.main === module) {
process.on('uncaughtException', err => {
handleFatalError(err, 'Unexpected error:').catch(() => process.exit(1));
});
process.on('unhandledRejection', err => {
handleFatalError(err, 'Unexpected error:').catch(() => process.exit(1));
});
(async () => {
try {
clearConsoleForRun();
let config;
try {
config = loadConfig();
} catch (err) {
if (err && err.code === 'ERR_CONFIG_MISSING') {
const onboardingResult = await runOnboarding();
if (onboardingResult && onboardingResult.mode === 'config') {
return;
}
config = onboardingResult.config;
} else {
throw err;
}
}
await scheduleRuns(config);
} catch (err) {
handleFatalError(err, 'Configuration error:').catch(() => process.exit(1));
}
})();
}