diff --git a/app/capture.js b/app/capture.js index e88b31b..ec52b8c 100644 --- a/app/capture.js +++ b/app/capture.js @@ -19,6 +19,7 @@ const { const { physicalToDip, physicalRectToDip, displayForDipPoint, cropGeometry } = require('./coords'); const { WINDOW_BOUNDS_CS, parseWindowBounds, readActiveWindow } = require('./platform/windows/window-bounds'); const { defaultCaptureTitle, mouseButtonKey } = require('../core/text-intel'); +const { t, tn, uiLanguage } = require('../core/ui-text'); /** True when two presses (DIP points) are close enough to be one double-click. */ function withinDoubleClickRadius(a, b) { @@ -71,10 +72,10 @@ const MIN_DOUBLE_CLICK_GAP_MS = 20; // something else that the debounce happened to drop. const DOUBLE_CLICK_RADIUS = 8; // Shown before and during a recording whose Window setting can't be honoured. -const WINDOW_FALLBACK_NOTE = 'Window capture is not available on this desktop, so StepForge records the full screen.'; +const windowFallbackNote = () => t('Window capture is not available on this desktop, so StepForge records the full screen.'); // Window mode on Windows, when a capture has no window to show (a click on // the desktop or taskbar, or the active window could not be read). -const WINDOW_NOT_FOUND_NOTE = 'StepForge couldn’t tell which window to capture, so it captured the full screen.'; +const windowNotFoundNote = () => t('StepForge couldn’t tell which window to capture, so it captured the full screen.'); // How long a Linux raw button event waits for its regular twin (the // representation that carries root coordinates) before firing without them. const LINUX_CLICK_TWIN_MS = 25; @@ -333,7 +334,7 @@ class CaptureService { state() { // Said up front, so the recording bar shows it before anything is captured. - const modeNote = this.windowModeFallsBack() ? WINDOW_FALLBACK_NOTE : ''; + const modeNote = this.windowModeFallsBack() ? windowFallbackNote() : ''; return this.session ? { active: true, @@ -487,19 +488,19 @@ class CaptureService { const img = raster.createImage(16, 16, [0, 0, 0, 0]); raster.fillOval(img, 2, 2, 12, 12, [229, 72, 77, 255]); this.tray = new Tray(nativeImage.createFromBuffer(encodePng(img))); - this.tray.setToolTip('StepForge — capture session running'); + this.tray.setToolTip(t('StepForge — capture session running')); const rebuild = () => { if (!this.tray || this.tray.isDestroyed()) return; this.tray.setContextMenu(Menu.buildFromTemplate([ - { label: `Captured ${this.session ? this.session.count : 0} steps`, enabled: false }, + { label: tn(this.session ? this.session.count : 0, 'Captured {count} step', 'Captured {count} steps'), enabled: false }, { type: 'separator' }, - { label: 'Capture now', click: () => this.sessionCapture('manual').then(rebuild).catch(() => {}) }, + { label: t('Capture now'), click: () => this.sessionCapture('manual').then(rebuild).catch(() => {}) }, { - label: this.session && this.session.paused ? 'Resume capturing' : 'Pause capturing', + label: this.session && this.session.paused ? t('Resume capturing') : t('Pause capturing'), click: () => { this.noteUiStopGesture(); this.togglePause(); rebuild(); }, }, { - label: 'Open StepForge (pauses capture)', + label: t('Open StepForge (pauses capture)'), click: () => { this.noteUiStopGesture(); this.togglePause(true); @@ -508,7 +509,7 @@ class CaptureService { }, }, { type: 'separator' }, - { label: 'Finish session', click: () => { this.noteUiStopGesture(); this.finishSession(); } }, + { label: t('Finish session'), click: () => { this.noteUiStopGesture(); this.finishSession(); } }, ])); }; rebuild(); @@ -800,7 +801,7 @@ class CaptureService { this.notify('capture:diagnostic', { kind: 'strict-click-skipped', guideId, - reason: 'No pre-click frame was ready for this click. In strict timing mode StepForge skips the shot rather than capturing the screen after the click.', + reason: t('No pre-click frame was ready for this click. In strict timing mode StepForge skips the shot rather than capturing the screen after the click.'), }); return { ok: false, reason: 'strict mode: no pre-click frame available for this click' }; } @@ -853,7 +854,7 @@ class CaptureService { return { ok: false, reason: 'waiting for the screen-share stream' }; } - if (this.shooting) return { ok: false, reason: 'capture already in progress' }; + if (this.shooting) return { ok: false, reason: t('capture already in progress') }; this.shooting = true; try { const mode = this.settings.get('capture.mode') || 'fullscreen'; @@ -1005,7 +1006,7 @@ class CaptureService { this.notify('capture:diagnostic', { kind: 'window-mode-unavailable', guideId: this.session.guideId, - reason: WINDOW_FALLBACK_NOTE, + reason: windowFallbackNote(), }); } grabMode = 'fullscreen'; @@ -2319,7 +2320,7 @@ ${WINDOW_BOUNDS_CS} this.notify('capture:diagnostic', { kind: 'window-not-found', guideId: this.session ? this.session.guideId : null, - reason: WINDOW_NOT_FOUND_NOTE, + reason: windowNotFoundNote(), }); } @@ -2563,11 +2564,11 @@ ${WINDOW_BOUNDS_CS} } const { image, display } = grabbed; const rect = await this.pickRegion(display, image); - if (!rect) return { ok: false, reason: 'selection cancelled' }; + if (!rect) return { ok: false, reason: t('selection cancelled') }; const cropped = image.crop(rect); const size = cropped.getSize(); - if (!size.width || !size.height) return { ok: false, reason: 'empty selection' }; + if (!size.width || !size.height) return { ok: false, reason: t('empty selection') }; return { ok: true, frame: { image: cropped, size, display, cursor: null } }; } @@ -2578,8 +2579,8 @@ ${WINDOW_BOUNDS_CS} * is running, since it owns the window, the hotkey and the click capture. */ async retakeFrame({ mode = 'fullscreen', delayMs = 3000 } = {}) { - if (this.session) return { ok: false, reason: 'Stop the recording session before retaking a screenshot.' }; - if (this.shooting) return { ok: false, reason: 'capture already in progress' }; + if (this.session) return { ok: false, reason: t('Stop the recording session before retaking a screenshot.') }; + if (this.shooting) return { ok: false, reason: t('capture already in progress') }; this.shooting = true; try { let frame; @@ -2684,7 +2685,9 @@ ${WINDOW_BOUNDS_CS} ipcMain.on('region:picked', onPick); overlay.on('closed', () => finish(null)); overlay.webContents.on('did-fail-load', () => finish(null)); - overlay.loadFile(path.join(__dirname, 'renderer', 'region.html')).catch(() => finish(null)); + overlay.loadFile(path.join(__dirname, 'renderer', 'region.html'), { + query: { hint: t('Drag to select a region — Esc to cancel'), title: t('Select region'), lang: uiLanguage() }, + }).catch(() => finish(null)); }); } } diff --git a/app/cloud-account.js b/app/cloud-account.js index b64f60e..6fd8336 100644 --- a/app/cloud-account.js +++ b/app/cloud-account.js @@ -7,6 +7,7 @@ const path = require('node:path'); const { atomicWriteFileSync } = require('../core/util'); const { progressBody } = require('../core/transfer-meter'); const { FolderStorage } = require('../core/folder-storage'); +const { t } = require('../core/ui-text'); /* * What OneDrive, Dropbox and Nextcloud/WebDAV sync have in common: sign-in @@ -54,7 +55,7 @@ class CloudAccount { this.credentials = stored; this.loadError = null; } else { - this.loadError = 'Please sign in again to connect this version of StepForge.'; + this.loadError = t('Please sign in again to connect this version of StepForge.'); } } catch { this.loadError = `Stored ${this.label} sign-in could not be unlocked. Disconnect and sign in again.`; @@ -199,7 +200,7 @@ class CloudAccount { if (response.body) { for await (const chunk of response.body) { length += chunk.length; - if (length > MAX_TRANSFER_BYTES) { controller.abort(); throw new Error('Cloud archive exceeds the 256 MB transfer limit.'); } + if (length > MAX_TRANSFER_BYTES) { controller.abort(); throw new Error(t('Cloud archive exceeds the 256 MB transfer limit.')); } chunks.push(Buffer.from(chunk)); onProgress?.(length); } @@ -221,7 +222,7 @@ class CloudAccount { async authorized(url, options = {}) { const generation = this.generation; - if (!this.credentials) throw new Error(this.loadError || `Sign in to ${this.label} first.`); + if (!this.credentials) throw new Error(this.loadError || t('Sign in to {service} first.', { service: this.label })); const headers = await this.authHeaders(); if (generation !== this.generation) throw new Error(`${this.label} request cancelled.`); try { @@ -259,7 +260,7 @@ class CloudAccount { this.onCallback = (req, res) => { const url = new URL(req.url, 'http://localhost'); if (req.method !== 'GET' || url.pathname !== '/' || url.searchParams.get('state') !== state) { - res.writeHead(400).end('Invalid sign-in callback.'); + res.writeHead(400).end(t('Invalid sign-in callback.')); return; } res.setHeader('Content-Type', 'text/plain; charset=utf-8'); @@ -268,7 +269,7 @@ class CloudAccount { res.end(`${this.label} sign-in was not completed. Return to StepForge.`); reject(new Error(`${this.label} sign-in was denied or cancelled.`)); } else { - res.end(`Signed in to ${this.label}. You can close this tab and go back to StepForge.`); + res.end(t('Signed in to {service}. You can close this tab and go back to StepForge.', { service: this.label })); resolve(url.searchParams.get('code')); } }; @@ -320,7 +321,7 @@ class CloudAccount { /** A current OAuth access token, refreshed when it's about to expire. */ async accessToken(force = false) { const credentials = this.credentials; - if (!credentials?.refresh_token) throw new Error(this.loadError || `Sign in to ${this.label} first.`); + if (!credentials?.refresh_token) throw new Error(this.loadError || t('Sign in to {service} first.', { service: this.label })); if (!force && credentials.access_token && credentials.expiresAt > Date.now() + 60000) return credentials.access_token; this.refreshing ||= (async () => { let response; @@ -358,37 +359,42 @@ class CloudAccount { quota() { return this.storage.quota(); } async account() { - if (!this.credentials) throw new Error(this.loadError || `Sign in to ${this.label} first.`); + if (!this.credentials) throw new Error(this.loadError || t('Sign in to {service} first.', { service: this.label })); if (!this.credentials.accountId) await this.loadProfile(); return this.credentials.accountId; } + /** + * Sign in, list, upload, download and delete a test file. Each check is + * { text, ok }: its text is in the interface language, so pass or fail is + * never read from it. + */ async test() { const checks = []; let probe; try { await this.authHeaders(true); - checks.push('Sign-in: passed'); + checks.push({ text: t('Sign-in: passed'), ok: true }); await this.storage.objects(); - checks.push(`${this.label} storage access: passed`); + checks.push({ text: t('{service} storage access: passed', { service: this.label }), ok: true }); const bytes = crypto.randomBytes(8 * 1024); probe = await this.upload({ data: bytes, name: 'StepForge connection test', properties: { stepforge: 'connection-test' } }); - checks.push('Test upload: passed'); + checks.push({ text: t('Test upload: passed'), ok: true }); const downloaded = await this.download(probe.id); - if (!bytes.equals(downloaded)) throw new Error('The test download did not match the uploaded data.'); - checks.push('Test download and integrity: passed'); + if (!bytes.equals(downloaded)) throw new Error(t('The test download did not match the uploaded data.')); + checks.push({ text: t('Test download and integrity: passed'), ok: true }); } catch (err) { - checks.push(`Failed: ${err.message}`); + checks.push({ text: t('Failed: {message}', { message: err.message }), ok: false }); return { ok: false, checks }; } finally { if (probe) { try { await this.deleteFile(probe.id); - checks.push('Test file cleanup: passed'); - } catch { checks.push('Test file cleanup failed; a small test file remains in StepForge’s folder.'); } + checks.push({ text: t('Test file cleanup: passed'), ok: true }); + } catch { checks.push({ text: t('Test file cleanup failed; a small test file remains in StepForge’s folder.'), ok: false }); } } } - return { ok: checks.every((line) => !line.includes('failed')), checks }; + return { ok: checks.every((check) => check.ok), checks }; } } diff --git a/app/confluence-session.js b/app/confluence-session.js index 32fe5bf..221af7e 100644 --- a/app/confluence-session.js +++ b/app/confluence-session.js @@ -1,6 +1,7 @@ 'use strict'; const crypto = require('node:crypto'); +const { t } = require('../core/ui-text'); /* * The Electron side of Confluence (app/confluence.js): a browser session of @@ -39,7 +40,7 @@ function createConfluenceSession({ session, BrowserWindow, getParent = () => nul // It never gets StepForge's preload or IPC. const makeWindow = (show) => { const win = new BrowserWindow({ - show, width: 1000, height: 760, title: 'Sign in to Confluence', autoHideMenuBar: true, + show, width: 1000, height: 760, title: t('Sign in to Confluence'), autoHideMenuBar: true, parent: show ? getParent() || undefined : undefined, webPreferences: { session: ses, sandbox: true, contextIsolation: true, nodeIntegration: false }, }); diff --git a/app/confluence.js b/app/confluence.js index b26e6d4..1eb33fd 100644 --- a/app/confluence.js +++ b/app/confluence.js @@ -6,6 +6,7 @@ const path = require('node:path'); const { atomicWriteFileSync, writeJsonSync, readJsonIfExists, escapeXml } = require('../core/util'); const { normalizeServer } = require('./webdav'); const { publishedImageName } = require('../exporters/common'); +const { t } = require('../core/ui-text'); /* * Publishing guides as Confluence pages, on Confluence Cloud or a Confluence @@ -37,15 +38,15 @@ const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024; function friendlyNetworkError(err, host) { const message = String(err?.message || err); if (/CLIENT_AUTH_CERT_NEEDED|BAD_SSL_CLIENT_AUTH_CERT|CLIENT_AUTH_CERT_TYPE_UNSUPPORTED/.test(message)) { - return `${host} asked for your smart card (CAC) certificate, but none was accepted. Insert your card, check that your computer’s smart card software is working (your browser can open the site), and try again.`; + return t('{host} asked for your smart card (CAC) certificate, but none was accepted. Insert your card, check that your computer’s smart card software is working (your browser can open the site), and try again.', { host }); } if (/CLIENT_AUTH_SIGNATURE_FAILED|CLIENT_AUTH_PRIVATE_KEY_ACCESS_DENIED|CLIENT_AUTH_NO_COMMON_ALGORITHMS/.test(message)) { - return `Your smart card couldn’t sign in to ${host}. Check the card is inserted, enter your PIN when asked, and try again.`; + return t('Your smart card couldn’t sign in to {host}. Check the card is inserted, enter your PIN when asked, and try again.', { host }); } if (/CERT_AUTHORITY_INVALID|CERT_COMMON_NAME_INVALID|CERT_DATE_INVALID|CERT_INVALID/.test(message)) { - return `StepForge doesn’t trust ${host}’s security certificate. If your organization uses its own certificates (for example DoD root certificates), they need to be installed on this computer, as they are for your browser.`; + return t('StepForge doesn’t trust {host}’s security certificate. If your organization uses its own certificates (for example DoD root certificates), they need to be installed on this computer, as they are for your browser.', { host }); } - return `Couldn’t reach ${host}. Check the address and your network connection.`; + return t('Couldn’t reach {host}. Check the address and your network connection.', { host }); } /** The site address as typed, and whether it's Confluence Cloud. */ @@ -117,13 +118,13 @@ class Confluence { this.requireEncryption(); this.credentials = JSON.parse(this.safeStorage.decryptString(fs.readFileSync(this.file))); } catch { - this.loadError = 'Your saved Confluence sign-in couldn’t be unlocked. Disconnect and sign in again.'; + this.loadError = t('Your saved Confluence sign-in couldn’t be unlocked. Disconnect and sign in again.'); } } requireEncryption() { if (!this.safeStorage.isEncryptionAvailable() || this.safeStorage.getSelectedStorageBackend?.() === 'basic_text') { - throw new Error('Confluence sign-in requires an operating-system credential store. Unlock your keyring and restart StepForge.'); + throw new Error(t('Confluence sign-in requires an operating-system credential store. Unlock your keyring and restart StepForge.')); } } @@ -184,7 +185,7 @@ class Confluence { async request(target, { method = 'GET', json, body, contentType, login = this.credentials, timeout = 60000, allowMissing = false } = {}, retried = false) { const base = login?.baseUrl; const url = new URL(`${base}${target}`); - if (url.origin !== new URL(base).origin) throw new Error('StepForge only talks to your Confluence site.'); + if (url.origin !== new URL(base).origin) throw new Error(t('StepForge only talks to your Confluence site.')); const controller = new AbortController(); this.controllers.add(controller); const timer = setTimeout(() => controller.abort(), timeout); @@ -196,7 +197,7 @@ class Confluence { try { response = await this.fetch(url.href, { method, headers, body: payload, redirect: 'manual', credentials: 'include', signal: controller.signal }); } catch (err) { - if (controller.signal.aborted) throw new Error('The Confluence request was cancelled or took too long.'); + if (controller.signal.aborted) throw new Error(t('The Confluence request was cancelled or took too long.')); if (CERT_NEEDED.test(err.message) && !retried) { // The site wants a smart card certificate: let a page make the handshake, then retry. await this.openHiddenPage(`${new URL(base).origin}/`); @@ -209,8 +210,8 @@ class Confluence { } if (response.status >= 300 && response.status < 400) { throw Object.assign(new Error(login?.method === 'browser' - ? 'Your Confluence sign-in has expired. Sign in again in Settings → Accounts → Confluence.' - : 'Confluence sent StepForge to a sign-in page. Check your token, or use “Sign in with your browser”.'), { status: 401 }); + ? t('Your Confluence sign-in has expired. Sign in again in Settings → Accounts → Confluence.') + : t('Confluence sent StepForge to a sign-in page. Check your token, or use “Sign in with your browser”.')), { status: 401 }); } if (response.status === 404 && allowMissing) return null; const text = await response.text(); @@ -218,15 +219,15 @@ class Confluence { try { data = text ? JSON.parse(text) : null; } catch { data = null; } if (!response.ok) { const detail = data?.message || data?.errors?.[0]?.title || data?.errorMessage || ''; - const error = new Error(response.status === 401 ? 'Confluence didn’t accept the sign-in. Check the token, or sign in again.' - : response.status === 403 ? `You don’t have permission to do that in Confluence${detail ? ` (${detail})` : ''}.` - : `Confluence request failed (${response.status}${detail ? `: ${String(detail).slice(0, 200)}` : ''}).`); + const error = new Error(response.status === 401 ? t('Confluence didn’t accept the sign-in. Check the token, or sign in again.') + : response.status === 403 ? (detail ? t('You don’t have permission to do that in Confluence ({detail}).', { detail }) : t('You don’t have permission to do that in Confluence.')) + : t('Confluence request failed ({status}).', { status: `${response.status}${detail ? `: ${String(detail).slice(0, 200)}` : ''}` })); error.status = response.status; error.detail = detail; throw error; } if (json === undefined && data === null && text && / { @@ -279,7 +280,7 @@ class Confluence { }; if (!(await signedIn())) { const finished = await this.openLoginWindow(`${site.baseUrl}/`, { signedIn }); - if (!finished || !user) throw new Error('Confluence sign-in wasn’t completed. Try again.'); + if (!finished || !user) throw new Error(t('Confluence sign-in wasn’t completed. Try again.')); } this.credentials = { ...login, user, space: null, parent: null }; this.save(); @@ -287,7 +288,7 @@ class Confluence { } requireSite() { - if (!this.credentials) throw new Error(this.loadError || 'Connect Confluence in Settings → Accounts → Confluence first.'); + if (!this.credentials) throw new Error(this.loadError || t('Connect Confluence in Settings → Accounts → Confluence first.')); return this.credentials; } @@ -401,7 +402,7 @@ class Confluence { if (existing) { return this.request(`/api/v2/pages/${encodeURIComponent(existing.id)}`, { method: 'PUT', json: { id: existing.id, status: 'current', title, spaceId: space.id, ...(parentId ? { parentId } : {}), - body: { representation: 'storage', value: body }, version: { number: existing.version + 1, message: 'Updated from StepForge' } } }); + body: { representation: 'storage', value: body }, version: { number: existing.version + 1, message: t('Updated from StepForge') } } }); } return this.request('/api/v2/pages', { method: 'POST', json: { spaceId: space.id, status: 'current', title, ...(parentId ? { parentId } : {}), body: { representation: 'storage', value: body } } }); @@ -410,13 +411,13 @@ class Confluence { body: { storage: { value: body, representation: 'storage' } } }; if (existing) { return this.request(`/rest/api/content/${encodeURIComponent(existing.id)}`, { method: 'PUT', - json: { id: existing.id, ...content, version: { number: existing.version + 1, message: 'Updated from StepForge' } } }); + json: { id: existing.id, ...content, version: { number: existing.version + 1, message: t('Updated from StepForge') } } }); } return this.request('/rest/api/content', { method: 'POST', json: content }); } async uploadAttachment(pageId, fileName, data) { - if (data.length > MAX_ATTACHMENT_BYTES) throw new Error(`The screenshot ${fileName} is too large to upload.`); + if (data.length > MAX_ATTACHMENT_BYTES) throw new Error(t('The screenshot {file} is too large to upload.', { file: fileName })); const base = `/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`; const existing = await this.request(`${base}?filename=${encodeURIComponent(fileName)}`); const found = existing?.results?.find((a) => a.title === fileName); @@ -440,7 +441,7 @@ class Confluence { */ async publish({ guideId, space, parent = null, parentPicked = false, page, replace = false, onProgress = () => {} }) { this.requireSite(); - if (!space?.key) throw new Error('Choose a Confluence space for this guide.'); + if (!space?.key) throw new Error(t('Choose a Confluence space for this guide.')); let existing = null; const known = this.publishedFor(guideId); if (known && known.spaceKey === space.key) existing = await this.getPage(known.id); diff --git a/app/coords.js b/app/coords.js index 01582f4..2646700 100644 --- a/app/coords.js +++ b/app/coords.js @@ -1,6 +1,7 @@ 'use strict'; const { pointInBounds } = require('./click-frames'); +const { t } = require('../core/ui-text'); /** * Coordinate-space conversion between physical (OS event) pixels and @@ -141,7 +142,7 @@ function cropGeometry(bounds, rect, size) { const y = Math.max(bounds.y, rect.y); const right = Math.min(bounds.x + bounds.width, rect.x + rect.width); const bottom = Math.min(bounds.y + bounds.height, rect.y + rect.height); - if (right <= x || bottom <= y) throw new Error('The selected area is outside the shared monitor.'); + if (right <= x || bottom <= y) throw new Error(t('The selected area is outside the shared monitor.')); const sx = size.width / bounds.width; const sy = size.height / bounds.height; const px = Math.round((x - bounds.x) * sx); diff --git a/app/dropbox.js b/app/dropbox.js index 68c0b5f..e230038 100644 --- a/app/dropbox.js +++ b/app/dropbox.js @@ -2,6 +2,7 @@ const { CloudAccount } = require('./cloud-account'); const { resolveCloudApps, DROPBOX_KEY_PATTERN } = require('./cloud-apps'); +const { t } = require('../core/ui-text'); /* * Dropbox with an "App folder" app: StepForge can only see Apps/StepForge in @@ -30,7 +31,7 @@ class Dropbox extends CloudAccount { refreshParams(refreshToken) { return { client_id: this.key, grant_type: 'refresh_token', refresh_token: refreshToken }; } async connect() { - if (!this.available) throw new Error('Dropbox sign-in is unavailable in this build of StepForge. Please use a release with Dropbox sign-in enabled.'); + if (!this.available) throw new Error(t('Dropbox sign-in is unavailable in this build of StepForge. Please use a release with Dropbox sign-in enabled.')); this.requireEncryption(); const { code, verifier, redirectUri } = await this.browserSignIn({ ports: REDIRECT_PORTS, @@ -41,7 +42,7 @@ class Dropbox extends CloudAccount { const response = await this.tokenRequest(this.tokenUrl, { client_id: this.key, grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: verifier }); const tokens = this.tokens(response); - if (!tokens.refresh_token) throw new Error('Dropbox did not allow StepForge to stay signed in. Sign in again.'); + if (!tokens.refresh_token) throw new Error(t('Dropbox did not allow StepForge to stay signed in. Sign in again.')); this.finishSignIn(tokens); try { await this.loadProfile(); } catch (err) { this.disconnect(); throw err; } return this.status(); @@ -50,14 +51,14 @@ class Dropbox extends CloudAccount { async loadProfile() { const credentials = this.credentials; const account = await this.rpc('users/get_current_account'); - if (this.credentials !== credentials) throw new Error('Dropbox account changed.'); - if (!account?.account_id) throw new Error('Dropbox did not return an account identity.'); + if (this.credentials !== credentials) throw new Error(t('Dropbox account changed.')); + if (!account?.account_id) throw new Error(t('Dropbox did not return an account identity.')); Object.assign(credentials, { accountId: account.account_id, email: account.email || '', name: account.name?.display_name || '' }); this.save(); } describeError(status, body) { - if (typeof body?.error_summary === 'string' && /insufficient_space/.test(body.error_summary)) return 'Your Dropbox is full. Free up space there, then try again.'; + if (typeof body?.error_summary === 'string' && /insufficient_space/.test(body.error_summary)) return t('Your Dropbox is full. Free up space there, then try again.'); return super.describeError(status, body); } diff --git a/app/export-runner.js b/app/export-runner.js index f09a94c..9bc8c73 100644 --- a/app/export-runner.js +++ b/app/export-runner.js @@ -2,6 +2,7 @@ const path = require('node:path'); const { fork } = require('node:child_process'); +const { t } = require('../core/ui-text'); const WORKER_PATH = path.join(__dirname, '..', 'core', 'export-worker.js'); @@ -9,9 +10,10 @@ const WORKER_PATH = path.join(__dirname, '..', 'core', 'export-worker.js'); * Run an export, quality check or title job in a forked helper process, so building the render AST * and rendering the output (e.g. a multi-step PDF) never blocks the main * process — and therefore never freezes the editor window — no matter how - * large the guide is. + * large the guide is. `language` is the interface language the text + * StepForge adds to the export is written in (core/export-text.js). */ -function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, maxSteps, codeStyle, reviewDays, branding = null, task = 'export' }) { +function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, maxSteps, codeStyle, reviewDays, branding = null, language = 'en', task = 'export' }) { return new Promise((resolve, reject) => { const worker = fork(WORKER_PATH, [], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, @@ -28,14 +30,14 @@ function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, worker.once('message', (msg) => { if (msg && msg.ok) finish(resolve, msg.result); - else finish(reject, new Error((msg && msg.error) || 'Export worker failed.')); + else finish(reject, new Error((msg && msg.error) || t('Export worker failed.'))); }); worker.once('error', (err) => finish(reject, err)); worker.once('exit', (code) => { - finish(reject, new Error(`Export worker exited unexpectedly (code ${code}).`)); + finish(reject, new Error(t('Export worker exited unexpectedly (code {code}).', { code }))); }); - worker.send({ dataDir, guideId, format, options, outDir, globals, maxSteps, codeStyle, reviewDays, branding, task }); + worker.send({ dataDir, guideId, format, options, outDir, globals, maxSteps, codeStyle, reviewDays, branding, language, task }); }); } diff --git a/app/github-pages.js b/app/github-pages.js index 9aa4ebe..96ea131 100644 --- a/app/github-pages.js +++ b/app/github-pages.js @@ -5,6 +5,7 @@ const path = require('node:path'); const { atomicWriteFileSync } = require('../core/util'); const { progressBody } = require('../core/transfer-meter'); const site = require('../core/pages-site'); +const { t } = require('../core/ui-text'); /* * Temporary guide sharing on GitHub Pages, in a repository the user owns. @@ -27,7 +28,7 @@ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; const CLIENT_ID_PATTERN = /^Iv[A-Za-z0-9._-]{6,}$/; const APP_SLUG_PATTERN = /^[a-z0-9-]{1,100}$/; -const SIGN_IN_EXPIRED = 'Your GitHub sign-in has expired or was revoked. Sign in again to keep sharing guides.'; +const signInExpired = () => t('Your GitHub sign-in has expired or was revoked. Sign in again to keep sharing guides.'); const SIGN_IN_CANCELLED = 'GitHub sign-in cancelled.'; // Uploads get at least this long, and more for big guides on slow links. const MIN_UPLOAD_TIMEOUT_MS = 5 * 60 * 1000; @@ -90,6 +91,8 @@ class GitHubPages { wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), onStatus = () => {}, copyText = null, + // The interface language, for the site's front page. + language = () => 'en', }) { this.file = path.join(directory, 'github.credentials'); this.clientId = clientId; @@ -102,6 +105,7 @@ class GitHubPages { this.wait = wait; this.onStatus = onStatus; this.copyText = copyText; + this.language = language; this.credentials = null; this.loadError = null; @@ -124,16 +128,16 @@ class GitHubPages { this.credentials = stored; this.loadError = null; } else { - this.loadError = 'Sign in to GitHub again to connect this version of StepForge.'; + this.loadError = t('Sign in to GitHub again to connect this version of StepForge.'); } } catch { - this.loadError = 'StepForge couldn’t unlock your saved GitHub sign-in. Sign in to GitHub again.'; + this.loadError = t('StepForge couldn’t unlock your saved GitHub sign-in. Sign in to GitHub again.'); } } requireEncryption() { if (!this.safeStorage.isEncryptionAvailable() || this.safeStorage.getSelectedStorageBackend?.() === 'basic_text') { - throw new Error('GitHub sign-in requires an operating-system credential store. Unlock your keyring and restart StepForge.'); + throw new Error(t('GitHub sign-in requires an operating-system credential store. Unlock your keyring and restart StepForge.')); } } @@ -168,7 +172,7 @@ class GitHubPages { autoExpire: Boolean(repo?.autoExpire), setupNote: repo?.setupNote || '', pending: this.pending ? { ...this.pending } : null, - error: this.loadError || (credentials?.reauth ? SIGN_IN_EXPIRED : null), + error: this.loadError || (credentials?.reauth ? signInExpired() : null), links: this.links(), expiryDays: [...site.EXPIRY_DAYS], defaultExpiryDays: site.DEFAULT_EXPIRY_DAYS, @@ -227,26 +231,26 @@ class GitHubPages { } catch (err) { // fetch() rejects with a TypeError when there is no connection at all. if (err?.name === 'TypeError' && !controller.signal.aborted) { - throw new GitHubError('StepForge couldn’t reach GitHub. Check your internet connection and try again.', 0, { transient: true }); + throw new GitHubError(t('StepForge couldn’t reach GitHub. Check your internet connection and try again.'), 0, { transient: true }); } throw err; } const declared = Number(response.headers?.get?.('content-length')); - if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) throw new GitHubError('GitHub sent an unexpectedly large response.'); + if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) throw new GitHubError(t('GitHub sent an unexpectedly large response.')); const text = response.status === 204 ? '' : await response.text(); - if (text.length > MAX_RESPONSE_BYTES) throw new GitHubError('GitHub sent an unexpectedly large response.'); + if (text.length > MAX_RESPONSE_BYTES) throw new GitHubError(t('GitHub sent an unexpectedly large response.')); let data = null; if (text && !raw) { try { data = JSON.parse(text); } catch { data = null; } } if (!response.ok) { const detail = typeof data?.message === 'string' ? data.message.slice(0, 200) : ''; - throw new GitHubError(`GitHub request failed (${response.status}${detail ? `: ${detail}` : ''}).`, response.status, + throw new GitHubError(t('GitHub request failed ({status}).', { status: `${response.status}${detail ? `: ${detail}` : ''}` }), response.status, { transient: response.status >= 500 }); } return raw ? text : data; } catch (err) { if (controller.signal.aborted && err.name === 'AbortError') { - if (timedOut) throw new GitHubError('GitHub took too long to answer. Try again.', 0, { transient: true }); - throw new GitHubError('GitHub request was cancelled. Nothing on this computer changed.'); + if (timedOut) throw new GitHubError(t('GitHub took too long to answer. Try again.'), 0, { transient: true }); + throw new GitHubError(t('GitHub request was cancelled. Nothing on this computer changed.')); } throw err; } finally { @@ -285,8 +289,8 @@ class GitHubPages { * chosen repository. */ async connect() { - if (!this.available) throw new Error('GitHub sign-in is unavailable in this build of StepForge.'); - if (this.credentials?.access_token && !this.credentials.reauth) throw new Error('Disconnect the current GitHub account before signing in with another one.'); + if (!this.available) throw new Error(t('GitHub sign-in is unavailable in this build of StepForge.')); + if (this.credentials?.access_token && !this.credentials.reauth) throw new Error(t('Disconnect the current GitHub account before signing in with another one.')); this.requireEncryption(); this.cancelSignIn(); const generation = this.generation; @@ -299,8 +303,8 @@ class GitHubPages { const stopped = () => signal.aborted || generation !== this.generation; try { const device = await this.loginRequest('device/code', { client_id: this.clientId }, signal); - if (device?.error === 'device_flow_disabled') throw new Error('GitHub sign-in isn’t turned on for this StepForge GitHub App. Its maintainer needs to enable Device Flow.'); - if (!device?.device_code || !device.user_code) throw new Error('GitHub didn’t start the sign-in. Try again.'); + if (device?.error === 'device_flow_disabled') throw new Error(t('GitHub sign-in isn’t turned on for this StepForge GitHub App. Its maintainer needs to enable Device Flow.')); + if (!device?.device_code || !device.user_code) throw new Error(t('GitHub didn’t start the sign-in. Try again.')); const verificationUri = device.verification_uri === 'https://github.com/login/device' ? device.verification_uri : 'https://github.com/login/device'; const userCode = String(device.user_code).slice(0, 20); let copied = false; @@ -317,7 +321,7 @@ class GitHubPages { for (;;) { await Promise.race([this.wait(interval), woken]); if (stopped()) throw new Error(SIGN_IN_CANCELLED); - if (this.now() > deadline) throw new Error('The GitHub sign-in code expired. Choose Sign in with GitHub to get a new one.'); + if (this.now() > deadline) throw new Error(t('The GitHub sign-in code expired. Choose Sign in with GitHub to get a new one.')); try { tokens = await this.loginRequest('oauth/access_token', { client_id: this.clientId, device_code: device.device_code, grant_type: DEVICE_GRANT }, signal); } catch (err) { @@ -330,8 +334,8 @@ class GitHubPages { if (tokens?.access_token) break; if (tokens?.error === 'authorization_pending') continue; if (tokens?.error === 'slow_down') { interval = Math.max(interval + 5000, (Number(tokens.interval) || 0) * 1000); continue; } - if (tokens?.error === 'access_denied') throw new Error('GitHub sign-in was cancelled on github.com.'); - if (tokens?.error === 'expired_token') throw new Error('The GitHub sign-in code expired. Choose Sign in with GitHub to get a new one.'); + if (tokens?.error === 'access_denied') throw new Error(t('GitHub sign-in was cancelled on github.com.')); + if (tokens?.error === 'expired_token') throw new Error(t('The GitHub sign-in code expired. Choose Sign in with GitHub to get a new one.')); throw new Error(`GitHub sign-in failed${tokens?.error ? ` (${String(tokens.error).slice(0, 60)})` : ''}.`); } const next = this.storeTokens(tokens); @@ -370,20 +374,20 @@ class GitHubPages { async accessToken(force = false) { const credentials = this.credentials; - if (!credentials?.access_token) throw new Error(this.loadError || 'Sign in to GitHub first.'); - if (credentials.reauth) throw new GitHubError(SIGN_IN_EXPIRED, 401); + if (!credentials?.access_token) throw new Error(this.loadError || t('Sign in to GitHub first.')); + if (credentials.reauth) throw new GitHubError(signInExpired(), 401); const fresh = !credentials.expiresAt || credentials.expiresAt > this.now() + 60000; if (!force && fresh) return credentials.access_token; if (!credentials.refresh_token) { if (!force) return credentials.access_token; - throw new GitHubError(SIGN_IN_EXPIRED, 401); + throw new GitHubError(signInExpired(), 401); } this.refreshing ||= (async () => { // Device-flow tokens refresh without a client secret. const tokens = await this.loginRequest('oauth/access_token', { client_id: this.clientId, grant_type: 'refresh_token', refresh_token: credentials.refresh_token, }); - if (!tokens?.access_token) throw new GitHubError(SIGN_IN_EXPIRED, 401); + if (!tokens?.access_token) throw new GitHubError(signInExpired(), 401); if (this.credentials !== credentials) throw new Error('GitHub account changed during sign-in.'); this.credentials = this.storeTokens(tokens, credentials); this.save(); @@ -421,7 +425,7 @@ class GitHubPages { } catch (retry) { if (retry.status === 401) { this.signInExpired(); - throw new GitHubError(SIGN_IN_EXPIRED, 401); + throw new GitHubError(signInExpired(), 401); } throw retry; } @@ -460,7 +464,7 @@ class GitHubPages { } repoPath(suffix = '', repo = this.credentials?.repo) { - if (!repo) throw new Error('Choose a GitHub repository for shared guides first.'); + if (!repo) throw new Error(t('Choose a GitHub repository for shared guides first.')); return `/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}${suffix}`; } @@ -479,7 +483,7 @@ class GitHubPages { */ async selectRepository({ fullName, useExisting = false, onProgress = null }) { const parsed = parseFullName(fullName); - if (!parsed) throw new Error('Choose a repository from the list.'); + if (!parsed) throw new Error(t('Choose a repository from the list.')); const allowed = await this.repositories(); const match = allowed.find((repo) => repo.fullName.toLowerCase() === fullName.toLowerCase()); if (!match) throw new Error(`StepForge isn't installed on ${fullName}. Install the StepForge GitHub App on it first.`); @@ -499,8 +503,8 @@ class GitHubPages { needsConfirmation: true, repo: match.fullName, changes: [ - `Add ${site.WORKFLOW_PATH} to the ${inspection.defaultBranch} branch. It removes shared guides when they expire.`, - `Create a ${site.PAGES_BRANCH} branch for the shared guides and turn on GitHub Pages for it.`, + t('Add {file} to the {branch} branch. It removes shared guides when they expire.', { file: site.WORKFLOW_PATH, branch: inspection.defaultBranch }), + t('Create a {branch} branch for the shared guides and turn on GitHub Pages for it.', { branch: site.PAGES_BRANCH }), ], }; } @@ -578,7 +582,7 @@ class GitHubPages { return this.exclusive(async () => { const notes = []; try { - step('Checking the repository…'); + step(t('Checking the repository…')); const info = await this.api('GET', this.repoPath()); repo.private = Boolean(info?.private); const defaultBranch = String(info?.default_branch || 'main'); @@ -587,22 +591,22 @@ class GitHubPages { if (empty) { // An empty repository has no branch to hold the workflow yet. The // Contents API works on empty repositories and makes the first commit. - step('Adding a README…'); + step(t('Adding a README…')); await this.api('PUT', this.repoPath(`/contents/${site.README_PATH}`), { message: 'Add a README for shared StepForge guides', content: Buffer.from(site.README).toString('base64'), }); } - step('Adding the clean-up workflow…'); + step(t('Adding the clean-up workflow…')); repo.autoExpire = await this.ensureWorkflow(); - if (!repo.autoExpire) notes.push('StepForge could not add the clean-up workflow, so guides are only removed after they expire while StepForge is open. Check that the App has the Workflows permission.'); - step('Creating the site…'); + if (!repo.autoExpire) notes.push(t('StepForge could not add the clean-up workflow, so guides are only removed after they expire while StepForge is open. Check that the App has the Workflows permission.')); + step(t('Creating the site…')); await this.untilNotEmpty(async () => this.writeSite(await this.readSite(), { now: this.now() })); - step('Turning on GitHub Pages…'); + step(t('Turning on GitHub Pages…')); const pages = await this.ensurePages(); repo.pagesReady = Boolean(pages); repo.siteUrl = await this.siteUrlFrom(repo, pages); - if (!pages) notes.push(`Turn on GitHub Pages for ${repo.owner}/${repo.name}: in the repository's Settings → Pages, choose “Deploy from a branch”, then the ${site.PAGES_BRANCH} branch and the / (root) folder.`); + if (!pages) notes.push(t('Turn on GitHub Pages for {owner}/{name}: in the repository\'s Settings → Pages, choose “Deploy from a branch”, then the {branch} branch and the / (root) folder.', { owner: repo.owner, name: repo.name, branch: site.PAGES_BRANCH })); } catch (err) { throw this.accessHint(err); } finally { @@ -729,7 +733,7 @@ class GitHubPages { const unchanged = current.head && !publish && !extend && !plan.removed.length && !remove && current.entries.size === tree.length + 2 && current.entries.has(site.ROOT_INDEX_PATH); if (unchanged) return { ...plan, changed: false }; - tree.push({ path: site.ROOT_INDEX_PATH, mode: '100644', type: 'blob', sha: await this.blob(Buffer.from(site.ROOT_INDEX)) }); + tree.push({ path: site.ROOT_INDEX_PATH, mode: '100644', type: 'blob', sha: await this.blob(Buffer.from(site.rootIndex(this.language()))) }); tree.push({ path: site.MANIFEST_PATH, mode: '100644', type: 'blob', sha: await this.blob(Buffer.from(site.serializeManifest(plan.manifest))) }); const createdTree = await this.api('POST', this.repoPath('/git/trees'), { tree }); const message = publish ? 'Publish a StepForge guide' : extend ? 'Extend a shared StepForge guide' : remove ? 'Remove a StepForge guide' : 'Update shared StepForge guides'; @@ -744,9 +748,9 @@ class GitHubPages { requireSite() { const repo = this.credentials?.repo; - if (!this.credentials?.access_token) throw new Error(this.loadError || 'Sign in to GitHub in Settings → Accounts first.'); - if (this.credentials.reauth) throw new Error(SIGN_IN_EXPIRED); - if (!repo) throw new Error('Choose a repository for shared guides in Settings → Accounts → GitHub first.'); + if (!this.credentials?.access_token) throw new Error(this.loadError || t('Sign in to GitHub in Settings → Accounts first.')); + if (this.credentials.reauth) throw new Error(signInExpired()); + if (!repo) throw new Error(t('Choose a repository for shared guides in Settings → Accounts → GitHub first.')); return repo; } @@ -809,7 +813,7 @@ class GitHubPages { site.expiresAtFor(days); const page = Buffer.from(site.prepareGuideHtml(html)); if (page.length > site.MAX_GUIDE_BYTES) { - throw new Error('This guide is larger than 50 MB. Publish fewer steps or smaller screenshots.'); + throw new Error(t('This guide is larger than 50 MB. Publish fewer steps or smaller screenshots.')); } return this.exclusive(async () => { try { @@ -836,7 +840,7 @@ class GitHubPages { unpublish({ slug }) { this.requireSite(); - if (!site.isSlug(slug)) throw new Error('Unknown shared guide.'); + if (!site.isSlug(slug)) throw new Error(t('Unknown shared guide.')); return this.exclusive(async () => { try { const plan = await this.writeSite(await this.readSite(), { now: this.now(), remove: slug }); @@ -852,7 +856,7 @@ class GitHubPages { */ extend({ slug, days }) { this.requireSite(); - if (!site.isSlug(slug)) throw new Error('Unknown shared guide.'); + if (!site.isSlug(slug)) throw new Error(t('Unknown shared guide.')); site.expiresAtFor(days); return this.exclusive(async () => { try { diff --git a/app/google-drive.js b/app/google-drive.js index 3546754..782b2ce 100644 --- a/app/google-drive.js +++ b/app/google-drive.js @@ -6,6 +6,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { atomicWriteFileSync } = require('../core/util'); const { progressBody } = require('../core/transfer-meter'); +const { t } = require('../core/ui-text'); // Release builds get the client stamped into google-oauth-config.json. A source // checkout leaves it empty; developers can supply the client through the @@ -97,12 +98,12 @@ class GoogleDrive { } } else { this.loadError = - 'Please sign in again to connect this version of StepForge.'; + t('Please sign in again to connect this version of StepForge.'); } } } catch { this.loadError = - 'Stored Google credentials could not be unlocked. Disconnect and sign in again.'; + t('Stored Google credentials could not be unlocked. Disconnect and sign in again.'); } } @@ -141,7 +142,7 @@ class GoogleDrive { requireEncryption() { if (!this.safeStorage.isEncryptionAvailable() || this.safeStorage.getSelectedStorageBackend?.() === 'basic_text') { - throw new Error('Google sign-in requires an operating-system credential store. Unlock your keyring and restart StepForge.'); + throw new Error(t('Google sign-in requires an operating-system credential store. Unlock your keyring and restart StepForge.')); } } @@ -202,8 +203,8 @@ class GoogleDrive { } const error = new Error( response.status === 401 || reason === 'invalid_grant' - ? 'Google authorization expired or was revoked. Disconnect and sign in again.' - : `Google Drive request failed (${response.status}${reason ? ': ' + reason : ''}${description ? ' — ' + description : ''}).` + ? t('Google authorization expired or was revoked. Disconnect and sign in again.') + : t('Google Drive request failed ({status}{reason}{description}).', { status: response.status, reason: reason ? ': ' + reason : '', description: description ? ' — ' + description : '' }) ); error.status = response.status; throw error; @@ -213,7 +214,7 @@ class GoogleDrive { if (options.uploadSession) { const location = new URL(response.headers.get('location') || ''); if (location.origin !== 'https://www.googleapis.com' || !location.pathname.startsWith('/upload/drive/v3/')) { - throw new Error('Google returned an unexpected upload destination.'); + throw new Error(t('Google returned an unexpected upload destination.')); } return location.href; } @@ -222,7 +223,7 @@ class GoogleDrive { let length = 0; for await (const chunk of response.body) { length += chunk.length; - if (length > MAX_ARCHIVE_BYTES) { controller.abort(); throw new Error('Cloud archive exceeds the 256 MB transfer limit.'); } + if (length > MAX_ARCHIVE_BYTES) { controller.abort(); throw new Error(t('Cloud archive exceeds the 256 MB transfer limit.')); } chunks.push(Buffer.from(chunk)); onProgress?.(length); } @@ -230,7 +231,7 @@ class GoogleDrive { } return await response.json(); } catch (err) { - if (controller.signal.aborted && err.name === 'AbortError') throw new Error('Google Drive request was cancelled or timed out. Local guides are unchanged.'); + if (controller.signal.aborted && err.name === 'AbortError') throw new Error(t('Google Drive request was cancelled or timed out. Local guides are unchanged.')); throw err; } finally { clearTimeout(timer); @@ -245,11 +246,11 @@ class GoogleDrive { } async connect() { - if (!this.available) throw new Error('Google sign-in is unavailable in this build of StepForge. Please use a release with Google sign-in enabled.'); + if (!this.available) throw new Error(t('Google sign-in is unavailable in this build of StepForge. Please use a release with Google sign-in enabled.')); const clientId = this.clientId; - if (this.cancelLogin) throw new Error('Google sign-in is already in progress.'); + if (this.cancelLogin) throw new Error(t('Google sign-in is already in progress.')); await this.recovering; - if (this.credentials) throw new Error('Disconnect the current Google account before signing in again.'); + if (this.credentials) throw new Error(t('Disconnect the current Google account before signing in again.')); this.requireEncryption(); const generation = this.generation; const verifier = crypto.randomBytes(32).toString('base64url'); @@ -258,25 +259,25 @@ class GoogleDrive { let timer; try { const codePromise = new Promise((resolve, reject) => { - this.cancelLogin = () => reject(new Error('Google sign-in cancelled.')); + this.cancelLogin = () => reject(new Error(t('Google sign-in cancelled.'))); server.on('error', reject); server.on('request', (req, res) => { const url = new URL(req.url, 'http://127.0.0.1'); if (req.method !== 'GET' || url.pathname !== '/' || url.searchParams.get('state') !== state) { - res.writeHead(400).end('Invalid sign-in callback.'); + res.writeHead(400).end(t('Invalid sign-in callback.')); return; } res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); if (url.searchParams.has('error') || !url.searchParams.get('code')) { - res.end('Google sign-in was not completed. Return to StepForge.'); - reject(new Error('Google sign-in was denied or cancelled.')); + res.end(t('Google sign-in was not completed. Return to StepForge.')); + reject(new Error(t('Google sign-in was denied or cancelled.'))); } else { - res.end('Signed in to Google Drive. You can close this tab and go back to StepForge.'); + res.end(t('Signed in to Google Drive. You can close this tab and go back to StepForge.')); resolve(url.searchParams.get('code')); } }); - timer = setTimeout(() => reject(new Error('Google sign-in timed out. Try again.')), SIGN_IN_TIMEOUT_MS); + timer = setTimeout(() => reject(new Error(t('Google sign-in timed out. Try again.'))), SIGN_IN_TIMEOUT_MS); }); // Attach a rejection handler before awaiting browser/server startup. codePromise.catch(() => {}); @@ -285,7 +286,7 @@ class GoogleDrive { const query = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: SCOPE, access_type: 'offline', prompt: 'consent select_account', state, code_challenge_method: 'S256', code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url') }); - if (generation !== this.generation) throw new Error('Google sign-in cancelled.'); + if (generation !== this.generation) throw new Error(t('Google sign-in cancelled.')); // Never wait for the browser: on some systems opening a URL only returns // once the browser closes. If it doesn't open, the user can open the link // from StepForge. @@ -299,8 +300,8 @@ class GoogleDrive { redirect_uri: redirectUri, grant_type: 'authorization_code' }); - if (generation !== this.generation) throw new Error('Google sign-in cancelled.'); - if (!tokens.refresh_token || (tokens.scope && !tokens.scope.split(' ').includes(SCOPE))) throw new Error('Google did not grant offline Drive access. Sign in again and allow app storage access.'); + if (generation !== this.generation) throw new Error(t('Google sign-in cancelled.')); + if (!tokens.refresh_token || (tokens.scope && !tokens.scope.split(' ').includes(SCOPE))) throw new Error(t('Google did not grant offline Drive access. Sign in again and allow app storage access.')); this.credentials = { ...tokens, clientId, expiresAt: Date.now() + Number(tokens.expires_in || 3600) * 1000 }; try { this.save(); } catch (err) { this.credentials = null; throw err; } return this.status(); @@ -314,7 +315,7 @@ class GoogleDrive { async accessToken(force = false) { const credentials = this.credentials; - if (!credentials?.refresh_token) throw new Error(this.loadError || 'Sign in to Google Drive first.'); + if (!credentials?.refresh_token) throw new Error(this.loadError || t('Sign in to Google Drive first.')); if (!force && credentials.access_token && credentials.expiresAt > Date.now() + 60000) return credentials.access_token; if (!this.refreshing) { this.refreshing = (async () => { @@ -324,7 +325,7 @@ class GoogleDrive { refresh_token: credentials.refresh_token, grant_type: 'refresh_token' }); - if (this.credentials !== credentials) throw new Error('Google account changed during authentication.'); + if (this.credentials !== credentials) throw new Error(t('Google account changed during authentication.')); Object.assign(credentials, tokens, { expiresAt: Date.now() + Number(tokens.expires_in || 3600) * 1000 }); this.save(); return credentials.access_token; @@ -336,13 +337,13 @@ class GoogleDrive { async authorized(url, options = {}) { const generation = this.generation; const token = await this.accessToken(); - if (generation !== this.generation) throw new Error('Google Drive request cancelled.'); + if (generation !== this.generation) throw new Error(t('Google Drive request cancelled.')); try { return await this.request(url, { ...options, headers: { ...options.headers, Authorization: `Bearer ${token}` } }); } catch (err) { if (err.status !== 401) throw err; const refreshed = await this.accessToken(true); - if (generation !== this.generation) throw new Error('Google Drive request cancelled.'); + if (generation !== this.generation) throw new Error(t('Google Drive request cancelled.')); return this.request(url, { ...options, headers: { ...options.headers, Authorization: `Bearer ${refreshed}` } }); } } @@ -355,8 +356,8 @@ class GoogleDrive { const promise = (async () => { try { const info = await this.authorized(`${API}/about?fields=user(permissionId,emailAddress,photoLink)`); - if (credentials !== this.credentials) throw new Error('Google account changed.'); - if (!info.user?.permissionId) throw new Error('Google did not return an account identity.'); + if (credentials !== this.credentials) throw new Error(t('Google account changed.')); + if (!info.user?.permissionId) throw new Error(t('Google did not return an account identity.')); credentials.accountId = info.user.permissionId; credentials.email = info.user.emailAddress || ''; credentials.photoLink = googlePhotoLink(info.user.photoLink); @@ -414,7 +415,7 @@ class GoogleDrive { const files = []; let pageToken = ''; do { - if (generation !== this.generation) throw new Error('Google Drive request cancelled.'); + if (generation !== this.generation) throw new Error(t('Google Drive request cancelled.')); const query = new URLSearchParams({ spaces: 'appDataFolder', q: queryText, fields: 'nextPageToken,files(id,name,createdTime,size,appProperties)', pageSize: '1000', ...(pageToken ? { pageToken } : {}) }); const page = await this.authorized(`${API}/files?${query}`); @@ -427,14 +428,14 @@ class GoogleDrive { // `onProgress(bytes)` reports how many of the archive's bytes have been sent. async upload({ data, name, properties, onProgress }) { const generation = this.generation; - if (data.length > MAX_ARCHIVE_BYTES) throw new Error('Cloud archive exceeds the 256 MB transfer limit.'); + if (data.length > MAX_ARCHIVE_BYTES) throw new Error(t('Cloud archive exceeds the 256 MB transfer limit.')); if (data.length > 5 * 1024 * 1024) { const session = await this.authorized('https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id,name,createdTime,appProperties', { method: 'POST', uploadSession: true, headers: { 'Content-Type': 'application/json; charset=UTF-8', 'X-Upload-Content-Type': 'application/octet-stream', 'X-Upload-Content-Length': String(data.length) }, body: JSON.stringify({ name, parents: ['appDataFolder'], appProperties: properties }), }); - if (generation !== this.generation) throw new Error('Google Drive upload cancelled.'); + if (generation !== this.generation) throw new Error(t('Google Drive upload cancelled.')); return this.authorized(session, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: data, onProgress }); } const boundary = `stepforge_${crypto.randomBytes(16).toString('hex')}`; @@ -454,32 +455,37 @@ class GoogleDrive { return this.authorized(`${API}/files/${encodeURIComponent(id)}`, { method: 'DELETE' }); } + /** + * Sign in, list, upload, download and delete a test file. Each check is + * { text, ok }: its text is in the interface language, so pass or fail is + * never read from it. + */ async test() { const checks = []; let probe; try { await this.accessToken(true); - checks.push('Authentication and token refresh: passed'); + checks.push({ text: t('Authentication and token refresh: passed'), ok: true }); await this.listVersions(); - checks.push('Drive app storage access: passed'); + checks.push({ text: t('Drive app storage access: passed'), ok: true }); const bytes = crypto.randomBytes(32); probe = await this.upload({ data: bytes, name: 'StepForge connection test', properties: { stepforge: 'connection-test' } }); - checks.push('Test upload: passed'); + checks.push({ text: t('Test upload: passed'), ok: true }); const downloaded = await this.download(probe.id); - if (!bytes.equals(downloaded)) throw new Error('The test download did not match the uploaded data.'); - checks.push('Test download and integrity: passed'); + if (!bytes.equals(downloaded)) throw new Error(t('The test download did not match the uploaded data.')); + checks.push({ text: t('Test download and integrity: passed'), ok: true }); } catch (err) { - checks.push(`Failed: ${err.message}`); + checks.push({ text: t('Failed: {message}', { message: err.message }), ok: false }); return { ok: false, checks }; } finally { if (probe) { try { await this.deleteFile(probe.id); - checks.push('Test file cleanup: passed'); - } catch { checks.push('Test file cleanup failed; a small test file remains in app storage.'); } + checks.push({ text: t('Test file cleanup: passed'), ok: true }); + } catch { checks.push({ text: t('Test file cleanup failed; a small test file remains in app storage.'), ok: false }); } } } - return { ok: checks.every((line) => !line.includes('failed')), checks }; + return { ok: checks.every((check) => check.ok), checks }; } } diff --git a/app/main.js b/app/main.js index 152d5a9..847a34a 100644 --- a/app/main.js +++ b/app/main.js @@ -60,6 +60,7 @@ const { syncedFolders, syncServiceFor } = require('./synced-folders'); const windowsStore = require('./windows-store'); const i18n = require('../core/i18n'); const LOCALES = require('../core/locales'); +const { t, tn, setUiLanguage } = require('../core/ui-text'); const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json')); // Linux packages launch the bundled Electron against /opt/stepforge, so // app.isPackaged is false there too. Only a source checkout is a dev build. @@ -172,7 +173,7 @@ async function useCloud(target) { await cloudSync.running; cloudSync.setDrive(target); settings.set('cloud.provider', target.id); - cloudSync.publish('off', `${target.label} sharing is off.`, { lastSync: null, transfer: null }); + cloudSync.publish('off', t('{service} sharing is off.', { service: target.label }), { lastSync: null, transfer: null }); } for (const other of Object.values(cloudProviders)) { if (other !== target && other.status().connected) await other.disconnect(); @@ -216,13 +217,14 @@ function uiLanguage() { return i18n.resolveLanguage(settings.get('language'), app.getPreferredSystemLanguages()); } -// Translates the main process's own text: file dialog titles. +// The main process's own text (file dialog titles, messages from the +// main-process modules) follows the interface language too (core/ui-text.js). let mainText = i18n.createTranslator('en'); -const t = (text, params) => mainText.t(text, params); /** Loads index.html in the interface language; the renderer reads it from ?lang=. */ function loadMainPage() { mainText = i18n.createTranslator(uiLanguage(), LOCALES); + setUiLanguage(mainText.language); return mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'), { query: { lang: mainText.language } }); } @@ -864,7 +866,7 @@ function setupIpc() { })); h('library:create', ({ title, captureDraft }) => { const guide = store.createGuide({ - title: title || 'Untitled guide', + title: title || t('Untitled guide'), flags: { focusedViewDefault: Boolean(settings.get('editor.focusedViewDefaultForNewSteps')) }, }, { captureDraft: captureDraft === true }); reindex(guide.guideId); @@ -927,7 +929,7 @@ function setupIpc() { steps: orderedSteps(guideId), }), { validate: (a) => c.id(a.guideId) }); // What to fix before exporting or publishing (core/quality.js). - h('guide:quality', ({ guideId }) => runExportInWorker({ dataDir: store.root, guideId, task: 'quality', + h('guide:quality', ({ guideId }) => runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, task: 'quality', globals: settings.getGlobalPlaceholders(), reviewDays: exportReviewDays(), }), { validate: (a) => c.id(a.guideId) }); // An AI agent may have moved the guide to the Trash meanwhile; a late save @@ -1045,7 +1047,7 @@ function setupIpc() { }, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) }); h('step:fromClipboard', async ({ guideId, position }) => { const img = await clipboardImage(); - if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' }; + if (img.isEmpty()) return { ok: false, reason: t('The clipboard has no image.') }; const { width, height } = img.getSize(); const step = store.addStep(guideId, { title: t('Pasted image'), @@ -1220,7 +1222,7 @@ function setupIpc() { cloudSync.stop(); drive.disconnect(); await cloudSync.running; - cloudSync.publish('off', `${drive.label} disconnected. Local guides and cloud copies are kept.`, { lastSync: null, transfer: null }); + cloudSync.publish('off', t('{service} disconnected. Local guides and cloud copies are kept.', { service: drive.label }), { lastSync: null, transfer: null }); return cloudStatus(); }); h('cloud:enable', async ({ enabled }) => { @@ -1229,7 +1231,7 @@ function setupIpc() { if (enabled && !drive.status().connected) throw new Error(t('Sign in to {service} first.', { service: drive.label })); settings.set('cloud.enabled', enabled); if (enabled) cloudSync.start(); - else { cloudSync.stop(); await cloudSync.running; cloudSync.publish('off', `${drive.label} sharing is off.`); } + else { cloudSync.stop(); await cloudSync.running; cloudSync.publish('off', t('{service} sharing is off.', { service: drive.label })); } return cloudStatus(); }, { validate: (a) => typeof a.enabled === 'boolean' }); h('cloud:sync', () => cloudSync.sync()); @@ -1312,7 +1314,7 @@ function setupIpc() { progress({ stage: 'check' }); await redactor.checkGuide(guideId, { onProgress: ({ done, total }) => progress({ stage: 'check', done, total }) }); progress({ stage: 'export' }); - const result = await runExportInWorker({ dataDir: store.root, guideId, format: 'confluence', + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format: 'confluence', options: { apiFiles: true, includeImages: true, toc: true }, outDir, globals: settings.getGlobalPlaceholders(), codeStyle: exportCodeStyle(), reviewDays: exportReviewDays(), branding: exportBranding() }); return await confluence.publish({ guideId, space, parent, parentPicked, replace, onProgress: progress, @@ -1334,7 +1336,7 @@ function setupIpc() { h('wikijs:defaults', async ({ guideId }) => { if (!store.guideExists(guideId)) throw new Error(t('Guide not found.')); // The title as exported, with placeholders filled in, names the page. - const title = await runExportInWorker({ dataDir: store.root, guideId, task: 'title', globals: settings.getGlobalPlaceholders() }); + const title = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, task: 'title', globals: settings.getGlobalPlaceholders() }); return wikijs.defaultsFor(guideId, title); }, { validate: (a) => c.id(a.guideId) }); h('wikijs:publish', async ({ guideId, path: pagePath, title, locale = 'en', replace = false }) => { @@ -1354,7 +1356,7 @@ function setupIpc() { await redactor.checkGuide(guideId, { onProgress: ({ done, total }) => progress({ stage: 'check', done, total }) }); progress({ stage: 'export' }); const assetFolder = `/${target}`; - const result = await runExportInWorker({ dataDir: store.root, guideId, format: 'wikijs', + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format: 'wikijs', options: { includeImages: true, toc: false, frontMatter: false, assetFolder }, outDir, globals: settings.getGlobalPlaceholders(), reviewDays: exportReviewDays(), branding: exportBranding() }); return await wikijs.publish({ guideId, path: target, title, locale: language, description: result.description, replace, onProgress: progress, @@ -1388,7 +1390,7 @@ function setupIpc() { onProgress: ({ done, total }) => progress({ stage: 'check', done, total }), }); progress({ stage: 'build' }); - const result = await runExportInWorker({ dataDir: store.root, guideId, format: 'clipboard', options: {}, + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format: 'clipboard', options: {}, outDir: path.join(store.tempDir, `clipboard-${guideId}`), globals: settings.getGlobalPlaceholders(), codeStyle: exportCodeStyle(), reviewDays: exportReviewDays(), branding: exportBranding() }); progress({ stage: 'copy' }); @@ -1413,7 +1415,7 @@ function setupIpc() { if (total) progress({ stage: 'export', detail: t('Checking screenshots for private details ({done} of {total})…', { done, total }) }); } }); progress({ stage: 'export' }); - const result = await runExportInWorker({ + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format, @@ -1785,7 +1787,7 @@ function setupIpc() { chosenOutputDirs.add(dir); } settings.set(`exports.lastOutputDirs.${format}`, dir); - const result = await runExportInWorker({ + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format, @@ -1838,7 +1840,7 @@ function setupIpc() { h('export:preview', async ({ guideId, format, options, brandProfileId }) => { const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`); fs.rmSync(previewDir, { recursive: true, force: true }); - const result = await runExportInWorker({ dataDir: store.root, guideId, format, outDir: previewDir, options: options || {}, + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format, outDir: previewDir, options: options || {}, globals: settings.getGlobalPlaceholders(), maxSteps: settings.get('exports.previewStepCount') || 3, codeStyle: exportCodeStyle(), reviewDays: exportReviewDays(), @@ -1899,7 +1901,7 @@ function setupIpc() { const profile = branding.get(id); if (!profile) throw new Error(t('That brand profile no longer exists.')); const res = await dialog.showSaveDialog(mainWindow, { - defaultPath: `${profile.name.replace(/[\\/:*?"<>|]+/g, ' ').trim() || 'Brand profile'}.sfbrand`, + defaultPath: `${profile.name.replace(/[\\/:*?"<>|]+/g, ' ').trim() || t('Brand profile')}.sfbrand`, filters: [{ name: t('StepForge brand profile'), extensions: ['sfbrand'] }], }); if (res.canceled) return { ok: false }; @@ -1923,6 +1925,7 @@ function setupIpc() { dir: path.join(previewDir, 'library'), branding: profile ? branding.resolveDraft(profile) : (id ? branding.resolve(id) : null), codeStyle: exportCodeStyle(), reviewDays: exportReviewDays(), + language: uiLanguage(), }); const result = runExport(format, ast, path.join(previewDir, 'export'), {}); const target = result.previewFile || result.file; @@ -2082,6 +2085,8 @@ if (!gotLock) { settings = new Settings(store.settingsDir, { defaultOverrides: plasmaWayland ? { capture: require('./platform/linux/desktop').PLASMA_HOTKEYS } : {}, }); + // Messages from the services below follow the interface language from the start. + setUiLanguage(uiLanguage()); // Shared lossless codec, also available in export workers and headless MCP. imageCodec = createImageCodec(); setImageCodec(imageCodec); @@ -2136,6 +2141,7 @@ if (!gotLock) { openExternal: (url) => shell.openExternal(url), // The sign-in code is copied so it can be pasted on github.com. copyText: (text) => clipboard.writeText(text), + language: uiLanguage, onStatus: (status) => sendToRenderer('github:status', status) }); cloudSync = new CloudSync({ store, drive: activeCloud(), diff --git a/app/onedrive.js b/app/onedrive.js index 777a685..0ece0d1 100644 --- a/app/onedrive.js +++ b/app/onedrive.js @@ -2,6 +2,7 @@ const { CloudAccount } = require('./cloud-account'); const { resolveCloudApps, ONEDRIVE_CLIENT_PATTERN } = require('./cloud-apps'); +const { t } = require('../core/ui-text'); /* * OneDrive through Microsoft Graph. StepForge only asks for its own app @@ -31,7 +32,7 @@ class OneDrive extends CloudAccount { refreshParams(refreshToken) { return { client_id: this.key, grant_type: 'refresh_token', refresh_token: refreshToken, scope: SCOPE }; } async connect() { - if (!this.available) throw new Error('OneDrive sign-in is unavailable in this build of StepForge. Please use a release with OneDrive sign-in enabled.'); + if (!this.available) throw new Error(t('OneDrive sign-in is unavailable in this build of StepForge. Please use a release with OneDrive sign-in enabled.')); this.requireEncryption(); const { code, verifier, redirectUri } = await this.browserSignIn({ authorizeUrl: ({ redirectUri, state, challenge }) => `${AUTHORITY}/authorize?${new URLSearchParams({ @@ -41,7 +42,7 @@ class OneDrive extends CloudAccount { const response = await this.tokenRequest(this.tokenUrl, { client_id: this.key, grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: verifier, scope: SCOPE }); const tokens = this.tokens(response); - if (!tokens.refresh_token) throw new Error('Microsoft did not allow StepForge to stay signed in. Sign in again and accept the permissions.'); + if (!tokens.refresh_token) throw new Error(t('Microsoft did not allow StepForge to stay signed in. Sign in again and accept the permissions.')); this.finishSignIn(tokens); try { await this.loadProfile(); } catch (err) { this.disconnect(); throw err; } return this.status(); @@ -53,15 +54,15 @@ class OneDrive extends CloudAccount { this.authorized(`${GRAPH}/me/drive?$select=id`), this.authorized(`${GRAPH}/me?$select=displayName,mail,userPrincipalName`).catch(() => ({})), ]); - if (this.credentials !== credentials) throw new Error('OneDrive account changed.'); - if (!drive?.id) throw new Error('Microsoft did not return a OneDrive for this account.'); + if (this.credentials !== credentials) throw new Error(t('OneDrive account changed.')); + if (!drive?.id) throw new Error(t('Microsoft did not return a OneDrive for this account.')); Object.assign(credentials, { accountId: drive.id, email: me.mail || me.userPrincipalName || '', name: me.displayName || '' }); this.save(); } describeError(status, body) { const code = body?.error?.code; - if (status === 403 && /accessDenied/i.test(code || '')) return 'OneDrive refused access. Your organization may need to approve StepForge.'; + if (status === 403 && /accessDenied/i.test(code || '')) return t('OneDrive refused access. Your organization may need to approve StepForge.'); return super.describeError(status, body); } @@ -89,7 +90,7 @@ class OneDrive extends CloudAccount { if (entry.file) files.push({ name: entry.name, size: entry.size, modified: entry.lastModifiedDateTime }); } url = page['@odata.nextLink'] || ''; - if (url && !url.startsWith(`${GRAPH}/`)) throw new Error('OneDrive returned an unexpected page link.'); + if (url && !url.startsWith(`${GRAPH}/`)) throw new Error(t('OneDrive returned an unexpected page link.')); } } catch (err) { if (err.status === 404) return []; @@ -105,7 +106,7 @@ class OneDrive extends CloudAccount { const session = await withFolder(file, () => this.authorized(`${item(file)}/createUploadSession`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item: { '@microsoft.graph.conflictBehavior': 'replace' } }) })); const target = new URL(session?.uploadUrl || ''); - if (target.protocol !== 'https:') throw new Error('OneDrive returned an unexpected upload destination.'); + if (target.protocol !== 'https:') throw new Error(t('OneDrive returned an unexpected upload destination.')); // The session URL carries its own authorization. for (let start = 0; start < data.length; start += CHUNK_BYTES) { const chunk = data.subarray(start, Math.min(data.length, start + CHUNK_BYTES)); @@ -119,7 +120,7 @@ class OneDrive extends CloudAccount { const result = await this.authorized(`${item(file)}/content`, { redirect: 'manual', location: true, binary: true, onProgress }); if (Buffer.isBuffer(result)) return result; const target = new URL(result); - if (target.protocol !== 'https:') throw new Error('OneDrive returned an unexpected download link.'); + if (target.protocol !== 'https:') throw new Error(t('OneDrive returned an unexpected download link.')); return this.request(target.href, { binary: true, onProgress, redirect: 'follow' }); }, remove: async (file) => { diff --git a/app/openai-compatible.js b/app/openai-compatible.js index 612aa59..546f3dc 100644 --- a/app/openai-compatible.js +++ b/app/openai-compatible.js @@ -10,6 +10,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { atomicWriteFileSync } = require('../core/util'); +const { t } = require('../core/ui-text'); /** * The server's base URL as StepForge uses it, without a trailing slash. A @@ -21,7 +22,7 @@ function baseUrl(value) { try { url = new URL(String(value).trim()); } catch { - throw new Error('Enter the server URL, for example http://localhost:8000/v1.'); + throw new Error(t('Enter the server URL, for example http://localhost:8000/v1.')); } if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { throw new Error('Use an http:// or https:// server URL without a user name, password, query or #fragment.'); @@ -51,7 +52,7 @@ class AiKeyStore { try { return JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch { - throw new Error('The saved API key can’t be read. Enter it again in Settings → AI.'); + throw new Error(t('The saved API key can’t be read. Enter it again in Settings → AI.')); } } @@ -67,12 +68,12 @@ class AiKeyStore { /** Save `key` for this server URL, replacing any saved key; an empty key removes it. */ set(url, key) { const endpoint = baseUrl(url); - if (typeof key !== 'string' || /[\r\n]/.test(key)) throw new Error('Enter the API key on one line.'); + if (typeof key !== 'string' || /[\r\n]/.test(key)) throw new Error(t('Enter the API key on one line.')); if (!key) { fs.rmSync(this.file, { force: true }); return; } - if (!this.available()) throw new Error('StepForge can’t encrypt the key because the system keyring is locked or missing. Unlock it and try again.'); + if (!this.available()) throw new Error(t('StepForge can’t encrypt the key because the system keyring is locked or missing. Unlock it and try again.')); atomicWriteFileSync(this.file, JSON.stringify({ endpoint, secret: this.storage.encryptString(key).toString('base64') })); } @@ -80,18 +81,18 @@ class AiKeyStore { get(url) { const saved = this.saved(); if (!saved || saved.endpoint !== baseUrl(url)) return ''; - if (!this.available()) throw new Error('Unlock the system keyring so StepForge can use the saved API key.'); + if (!this.available()) throw new Error(t('Unlock the system keyring so StepForge can use the saved API key.')); try { return this.storage.decryptString(Buffer.from(saved.secret, 'base64')); } catch { - throw new Error('The saved API key can’t be read. Enter it again in Settings → AI.'); + throw new Error(t('The saved API key can’t be read. Enter it again in Settings → AI.')); } } } /** One chat request; resolves with the reply text. `images` are base64 PNGs. */ async function chat(service, { host, model, prompt, systemPrompt, images = [], guideId = null, apiKey = '' }) { - if (apiKey && (typeof apiKey !== 'string' || /[\r\n]/.test(apiKey))) throw new Error('Enter the API key on one line.'); + if (apiKey && (typeof apiKey !== 'string' || /[\r\n]/.test(apiKey))) throw new Error(t('Enter the API key on one line.')); const content = images.length ? [{ type: 'text', text: prompt }, ...images.map((image) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } }))] : prompt; @@ -102,17 +103,17 @@ async function chat(service, { host, model, prompt, systemPrompt, images = [], g headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, body: { model, stream: false, temperature: 0.2, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content }] }, })); - if (response.status === 401 || response.status === 403) throw new Error(`The server refused the request (${response.status}). Check the API key.`); - if (response.status === 404) throw new Error('The server has no /chat/completions endpoint at this URL. Check that the URL ends in /v1 (or your server’s API path).'); - if (!response.ok) throw new Error(`The server answered with an error (${response.status}).`); + if (response.status === 401 || response.status === 403) throw new Error(t('The server refused the request ({status}). Check the API key.', { status: response.status })); + if (response.status === 404) throw new Error(t('The server has no /chat/completions endpoint at this URL. Check that the URL ends in /v1 (or your server’s API path).')); + if (!response.ok) throw new Error(t('The server answered with an error ({status}).', { status: response.status })); let payload; try { payload = await response.json(); } catch { - throw new Error('The server’s reply isn’t OpenAI-compatible JSON.'); + throw new Error(t('The server’s reply isn’t OpenAI-compatible JSON.')); } const text = payload?.choices?.[0]?.message?.content; - if (typeof text !== 'string' || !text.trim()) throw new Error('The server’s reply has no message text (choices[0].message.content).'); + if (typeof text !== 'string' || !text.trim()) throw new Error(t('The server’s reply has no message text (choices[0].message.content).')); return text; } diff --git a/app/platform/linux/gnome-capture.js b/app/platform/linux/gnome-capture.js index 419fa3c..aba4208 100644 --- a/app/platform/linux/gnome-capture.js +++ b/app/platform/linux/gnome-capture.js @@ -8,6 +8,7 @@ const CaptureService = require('../../capture'); const { PortalBackend } = require('./portal-backend'); const { mouseButtonKey } = require('../../../core/text-intel'); const { cropGeometry } = require('../../coords'); +const { t } = require('../../../core/ui-text'); const run = promisify(execFile); const UUID = 'tyler@stepforge.dev'; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -42,18 +43,18 @@ class GnomeCaptureService extends CaptureService { '--object-path', '/org/stepforge/Capture', '--method', 'org.stepforge.Capture1.GetInfo'], { timeout: 4000 }); try { await probe(); return; } catch { /* first-run setup */ } const result = await dialog.showMessageBox(this.getWindow(), { - type: 'info', title: 'Enable StepForge Capture', - message: 'GNOME recording requires the bundled StepForge Capture extension.', - detail: 'Enable it to record mouse clicks and their positions. If GNOME has not loaded the newly installed extension yet, log out and log back in first.', - buttons: ['Enable extension', 'Cancel'], defaultId: 0, cancelId: 1, + type: 'info', title: t('Enable StepForge Capture'), + message: t('GNOME recording requires the bundled StepForge Capture extension.'), + detail: t('Enable it to record mouse clicks and their positions. If GNOME has not loaded the newly installed extension yet, log out and log back in first.'), + buttons: [t('Enable extension'), t('Cancel')], defaultId: 0, cancelId: 1, }); - if (result.response !== 0) throw new Error('Enable the StepForge Capture GNOME extension before recording.'); + if (result.response !== 0) throw new Error(t('Enable the StepForge Capture GNOME extension before recording.')); try { await run('gnome-extensions', ['enable', UUID], { timeout: 5000 }); await delay(250); await probe(); } catch { - throw new Error('GNOME could not load StepForge Capture. Install the Ubuntu or Fedora package (GNOME 50), then log out and back in. For a source checkout, run bash scripts/linux/install-gnome-extension.sh first.'); + throw new Error(t('GNOME could not load StepForge Capture. Install the Ubuntu or Fedora package (GNOME 50), then log out and back in. For a source checkout, run bash scripts/linux/install-gnome-extension.sh first.')); } } @@ -178,11 +179,11 @@ class GnomeCaptureService extends CaptureService { } async frameForClick(point, at, mode = 'fullscreen', windowRect = null) { - if (!this.streamBackend?.isActive()) throw new Error('Screen sharing is not ready.'); + if (!this.streamBackend?.isActive()) throw new Error(t('Screen sharing is not ready.')); const frame = await this.streamBackend.frameForClick({ clickPos: point, clickAt: at, strict: this.strictClickFrames(), leadMs: Number(this.settings.get('capture.clickLeadMs')) || 0 }); if (mode === 'window') { - if (!windowRect) throw new Error('GNOME did not identify the active window.'); + if (!windowRect) throw new Error(t('GNOME did not identify the active window.')); return this.cropFrame(frame, windowRect, 'window'); } return frame; @@ -217,7 +218,7 @@ class GnomeCaptureService extends CaptureService { const frame = await backend.frameForClick({ clickPos: { x: rect.x, y: rect.y }, strict: false }); const b = frame.display.bounds; if (rect.x + rect.width > b.x + b.width || rect.y + rect.height > b.y + b.height) - throw new Error('Select a region within one shared monitor.'); + throw new Error(t('Select a region within one shared monitor.')); return this.storeFrameAsStep(guideId, 'region', this.cropFrame(frame, rect, 'region')); })); } catch (error) { return { ok: false, reason: error.message }; } diff --git a/app/platform/linux/kde-capture.js b/app/platform/linux/kde-capture.js index 993e098..0184468 100644 --- a/app/platform/linux/kde-capture.js +++ b/app/platform/linux/kde-capture.js @@ -6,6 +6,7 @@ const path = require('node:path'); const { execFile } = require('node:child_process'); const GnomeCaptureService = require('./gnome-capture'); const { PortalBackend } = require('./portal-backend'); +const { t } = require('../../../core/ui-text'); // Plasma's panel, its tray and the menus they open belong to plasmashell. A // click there may be the one that opens StepForge's tray menu or picks Pause @@ -18,9 +19,9 @@ function runSpectacle(file) { execFile('spectacle', ['--background', '--region', '--nonotify', '--output', file], { timeout: 125000 }, (error) => { if (error && error.code === 'ENOENT') { - reject(new Error('Install Spectacle to select a region on KDE Plasma.')); + reject(new Error(t('Install Spectacle to select a region on KDE Plasma.'))); } else if (error && !error.killed) { - reject(new Error('Spectacle could not take the region screenshot.')); + reject(new Error(t('Spectacle could not take the region screenshot.'))); } else { resolve(); } @@ -112,9 +113,9 @@ class KdeCaptureService extends GnomeCaptureService { await this.runSpectacle(file); return fs.promises.readFile(file).catch(() => null); }); - if (!png) return { ok: false, reason: 'selection cancelled' }; + if (!png) return { ok: false, reason: t('selection cancelled') }; const size = this.imageFromPng(png).getSize(); - if (!size.width || !size.height) return { ok: false, reason: 'empty selection' }; + if (!size.width || !size.height) return { ok: false, reason: t('empty selection') }; return await this.storeFrameAsStep(guideId, 'region', { mode: 'region', png, size, display: null, cursor: null }); } catch (error) { return { ok: false, reason: error.message }; diff --git a/app/platform/linux/portal-backend.js b/app/platform/linux/portal-backend.js index 6c32376..395349c 100644 --- a/app/platform/linux/portal-backend.js +++ b/app/platform/linux/portal-backend.js @@ -4,6 +4,7 @@ const path = require('node:path'); const { spawn } = require('node:child_process'); const { EventEmitter } = require('node:events'); const { failureCode, failureMessage, logFailure, plainText } = require('./portal-errors'); +const { t } = require('../../../core/ui-text'); class PortalBackend extends EventEmitter { // `desktop` picks the helper's click companion: 'gnome' (the Shell @@ -23,7 +24,7 @@ class PortalBackend extends EventEmitter { } start(options = {}) { - if (this.child) return Promise.reject(new Error('Screen sharing is already starting or active.')); + if (this.child) return Promise.reject(new Error(t('Screen sharing is already starting or active.'))); this.stopping = false; this.failureReason = null; return new Promise((resolve, reject) => { @@ -92,9 +93,9 @@ class PortalBackend extends EventEmitter { } async arm() { - if (!this.active) throw new Error('Screen sharing is not ready.'); + if (!this.active) throw new Error(t('Screen sharing is not ready.')); await new Promise((resolve, reject) => { - this.armTimer = setTimeout(() => { this.armResolve = this.armReject = null; reject(new Error(this.desktop === 'plasma' ? 'KWin did not start recording clicks.' : 'GNOME extension did not start.')); }, 5000); + this.armTimer = setTimeout(() => { this.armResolve = this.armReject = null; reject(new Error(this.desktop === 'plasma' ? t('KWin did not start recording clicks.') : t('GNOME extension did not start.'))); }, 5000); this.armResolve = () => { clearTimeout(this.armTimer); resolve(); }; this.armReject = reject; this.send({ type: 'arm' }); @@ -102,8 +103,8 @@ class PortalBackend extends EventEmitter { } request(type, args = {}) { - if (!this.active || this.stopping) return Promise.reject(new Error(`No active ${this.label} screen stream.`)); - if (this.pending.size >= 32) return Promise.reject(new Error('Capture is busy encoding earlier clicks.')); + if (!this.active || this.stopping) return Promise.reject(new Error(t('No active {label} screen stream.', { label: this.label }))); + if (this.pending.size >= 32) return Promise.reject(new Error(t('Capture is busy encoding earlier clicks.'))); const id = this.nextId++; return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -144,9 +145,9 @@ class PortalBackend extends EventEmitter { this.active = false; clearTimeout(this.startTimer); clearTimeout(this.armTimer); - this.armReject?.(new Error(this.failureReason || 'Recording cancelled.')); + this.armReject?.(new Error(this.failureReason || t('Recording cancelled.'))); this.armResolve = this.armReject = null; - this.startReject?.(new Error('Recording cancelled.')); + this.startReject?.(new Error(t('Recording cancelled.'))); this.startReject = this.startResolve = null; this.send({ type: 'disarm' }); if (immediate || !this.pending.size) this.destroy(); diff --git a/app/redaction.js b/app/redaction.js index 351d2ec..94e22ff 100644 --- a/app/redaction.js +++ b/app/redaction.js @@ -5,6 +5,7 @@ const crypto = require('node:crypto'); const { normalizeAnnotation } = require('../core/schema'); const { RevisionConflictError } = require('../core/store'); const redaction = require('../core/redaction'); +const { t } = require('../core/ui-text'); /* * Blurs private details in a guide's screenshots. Text recognition reads @@ -33,7 +34,7 @@ const MAX_READ_WIDTH = 4000; */ function bitmapToPpm(bgra, width, height) { const stride = bgra.length / height; - if (!Number.isInteger(stride) || stride < width * 4) throw new Error('A screenshot in this guide couldn’t be read.'); + if (!Number.isInteger(stride) || stride < width * 4) throw new Error(t('A screenshot in this guide couldn’t be read.')); const header = Buffer.from(`P6\n${width} ${height}\n255\n`, 'ascii'); const ppm = Buffer.allocUnsafe(header.length + width * height * 3); header.copy(ppm); diff --git a/app/renderer/cloud.js b/app/renderer/cloud.js index 6e211aa..190655b 100644 --- a/app/renderer/cloud.js +++ b/app/renderer/cloud.js @@ -302,10 +302,8 @@ function makeCloudSettings(api, { provider = 'google' } = {}) { testResults.replaceChildren(); testResults.classList.add('hidden'); const result = await api.cloud.test(); - testResults.replaceChildren(...result.checks.map((line) => { - const failed = /fail/i.test(line); - return el(`li.${failed ? 'fail' : 'pass'}`, {}, el('span.cloud-check-icon', { 'aria-hidden': 'true' }, failed ? '✕' : '✓'), line.replace(/: passed$/, '')); - })); + testResults.replaceChildren(...result.checks.map(({ text, ok }) => el(`li.${ok ? 'pass' : 'fail'}`, {}, + el('span.cloud-check-icon', { 'aria-hidden': 'true' }, ok ? '✓' : '✕'), text.replace(/: passed$/, '')))); testResults.classList.remove('hidden'); say(result.ok ? t('Connection test passed.') : t('Connection test needs attention.'), result.ok ? 'success' : 'error'); }) }, t('Test connection')); diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index 22ba71e..bf78701 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -2096,7 +2096,7 @@ const SHORTCUTS = [ ['Ctrl+C', t('Copy selected annotation (in step-select mode, paste it onto many steps)')], ['Delete', t('Delete selected annotation')], ['Esc', t('Deselect annotation')], - ['Arrows / Shift+Arrows', t('Nudge selection by 1 px / 10 px')], + [t('Arrows / Shift+Arrows'), t('Nudge selection by 1 px / 10 px')], ]], [t('View'), [ ['Ctrl+= / Ctrl+-', t('Zoom in / out')], diff --git a/app/renderer/region.html b/app/renderer/region.html index bad33ce..d374c00 100644 --- a/app/renderer/region.html +++ b/app/renderer/region.html @@ -12,9 +12,14 @@
-