From ed742d694b3a9c64a4136fdb2030d3929787e943 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 10:21:08 -0500 Subject: [PATCH 1/7] Add a Language setting and translatable interface text, and list the languages in the Store package --- app/main.js | 61 +- app/preload.js | 1 + app/renderer/app.js | 311 ++++++----- app/renderer/cloud.js | 2 +- app/renderer/dialogs.js | 649 +++++++++++----------- app/renderer/editor.js | 518 ++++++++--------- app/renderer/github.js | 2 +- app/renderer/index.html | 12 +- app/renderer/util.js | 35 +- core/i18n.js | 97 ++++ core/locales/es.js | 8 + core/locales/fr.js | 8 + core/locales/index.js | 11 + core/locales/ja.js | 8 + core/locales/ru.js | 8 + core/locales/zh-CN.js | 8 + core/settings.js | 4 +- package.json | 1 + scripts/check-translations.js | 89 +++ scripts/package-windows-store.js | 5 +- tests/unit/ai-visibility.test.js | 6 +- tests/unit/branding-ui.test.js | 5 +- tests/unit/capture-drafts.test.js | 4 +- tests/unit/capture-mode-notice.test.js | 3 +- tests/unit/choose-screenshot.test.js | 6 +- tests/unit/cloud-indicator.test.js | 3 +- tests/unit/cloud-settings-ui.test.js | 3 +- tests/unit/code-highlight.test.js | 2 +- tests/unit/export-publish-instead.test.js | 5 +- tests/unit/find-replace-ui.test.js | 5 +- tests/unit/github-ui.test.js | 11 +- tests/unit/google-signin-ui.test.js | 8 +- tests/unit/guide-quality.test.js | 4 +- tests/unit/helpers.js | 12 +- tests/unit/library-selection.test.js | 3 +- tests/unit/linked-save-editor.test.js | 3 +- tests/unit/package-windows-store.test.js | 2 + tests/unit/quick-actions.test.js | 3 +- tests/unit/retake-screenshot.test.js | 3 +- tests/unit/step-reorder.test.js | 3 +- tests/unit/step-selection.test.js | 3 +- tests/unit/undo-coverage.test.js | 3 +- tests/unit/update-now-ui.test.js | 3 +- 43 files changed, 1145 insertions(+), 796 deletions(-) create mode 100644 core/i18n.js create mode 100644 core/locales/es.js create mode 100644 core/locales/fr.js create mode 100644 core/locales/index.js create mode 100644 core/locales/ja.js create mode 100644 core/locales/ru.js create mode 100644 core/locales/zh-CN.js create mode 100644 scripts/check-translations.js diff --git a/app/main.js b/app/main.js index 1f68c463..c3ee4e42 100644 --- a/app/main.js +++ b/app/main.js @@ -52,6 +52,8 @@ const { zoomShortcutFromInputEvent } = require('./shortcut-utils'); const security = require('./security'); const { linuxPasswordStore } = require('./platform/linux/password-store'); const windowsStore = require('./windows-store'); +const i18n = require('../core/i18n'); +const LOCALES = require('../core/locales'); 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. @@ -190,6 +192,21 @@ function applyTheme() { nativeTheme.themeSource = settings.get('appearance') || 'system'; } +/** The interface language from Settings → General → Language ('system' follows the OS). */ +function uiLanguage() { + return i18n.resolveLanguage(settings.get('language'), app.getPreferredSystemLanguages()); +} + +// Translates the main process's own text: file dialog titles. +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); + return mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'), { query: { lang: mainText.language } }); +} + /** Code block colors for an export, from Settings → Exports. */ function exportCodeStyle() { return resolveCodeStyle(settings.get('exports.codeTheme')); @@ -290,7 +307,7 @@ function createWindow() { event.preventDefault(); handleZoomShortcut(kind); }); - mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); + loadMainPage(); mainWindow.once('ready-to-show', () => { mainWindow.show(); // Dev-only verification hook: optionally navigate, then write a @@ -957,7 +974,7 @@ function setupIpc() { }, { validate: (a) => c.id(a.guideId) && c.optionalNumber(a.position, 0, 100000) }); h('step:importImage', async ({ guideId }) => { const res = await dialog.showOpenDialog(mainWindow, { - title: 'Import images as steps', + title: t('Import images as steps'), filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }], properties: ['openFile', 'multiSelections'], }); @@ -991,7 +1008,7 @@ function setupIpc() { h('step:chooseImage', async ({ guideId, stepId }) => { store.getStep(guideId, stepId); // throws for a step that does not exist, before any dialog const res = await dialog.showOpenDialog(mainWindow, { - title: 'Choose a screenshot for this step', + title: t('Choose a screenshot for this step'), filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] }], properties: ['openFile'], }); @@ -1331,7 +1348,7 @@ function setupIpc() { h('storage:choose', async () => { if (libraryLocation.status().locked) throw new Error('STEPFORGE_DATA_DIR controls this session. Remove that override before changing storage.'); const result = await dialog.showOpenDialog(mainWindow, { - title: 'Choose guide storage folder', + title: t('Choose guide storage folder'), properties: ['openDirectory', 'createDirectory'], }); if (result.canceled || !result.filePaths[0]) return { cancelled: true, ...libraryLocation.status() }; @@ -1341,6 +1358,7 @@ function setupIpc() { h('storage:cancelMove', () => libraryLocation.cancel()); h('settings:set', ({ keyPath, value }) => { if (keyPath === 'cloud' || keyPath.startsWith('cloud.')) throw new Error('Use the cloud sync controls in Settings → Accounts.'); + if (keyPath === 'language') throw new Error('Use Settings → General → Language.'); const trashWasKept = !(settings.get('library.trashDays') > 0); settings.set(keyPath, value); // Turning emptying on starts the clock now for guides already in the trash. @@ -1474,7 +1492,7 @@ function setupIpc() { h('archive:export', async ({ guideId }) => { const guide = store.getGuide(guideId); const res = await dialog.showSaveDialog(mainWindow, { - title: 'Share guide as file', + title: t('Share guide as file'), defaultPath: `${guide.title.replace(/[/\\:]+/g, '-')}.sfgz`, filters: [{ name: 'StepForge guide archive', extensions: ['sfgz'] }], }); @@ -1484,7 +1502,7 @@ function setupIpc() { }, { validate: (a) => c.id(a.guideId) }); h('archive:open', async ({ mode }) => { const res = await dialog.showOpenDialog(mainWindow, { - title: 'Open guide archive', + title: t('Open guide archive'), filters: [{ name: 'StepForge guide archive', extensions: ['sfgz'] }], properties: ['openFile'], }); @@ -1619,7 +1637,7 @@ function setupIpc() { if (!dir) dir = settings.get(`exports.lastOutputDirs.${format}`); if (!dir) { const res = await dialog.showOpenDialog(mainWindow, { - title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'], + title: t('Choose output folder'), properties: ['openDirectory', 'createDirectory'], }); if (res.canceled) return { ok: false }; dir = res.filePaths[0]; @@ -1646,14 +1664,14 @@ function setupIpc() { }); h('export:chooseImage', async () => { const res = await dialog.showOpenDialog(mainWindow, { - title: 'Choose an image', properties: ['openFile'], + title: t('Choose an image'), properties: ['openFile'], filters: [{ name: 'PNG image', extensions: ['png'] }], }); return res.canceled ? null : res.filePaths[0]; }); h('export:chooseDir', async ({ format }) => { const res = await dialog.showOpenDialog(mainWindow, { - title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'], + title: t('Choose output folder'), properties: ['openDirectory', 'createDirectory'], }); if (res.canceled) return null; chosenOutputDirs.add(res.filePaths[0]); @@ -1693,9 +1711,9 @@ function setupIpc() { }; const assetId = (v) => typeof v === 'string' && /^[a-f0-9]{32}$/.test(v); const ASSET_FILTERS = { - image: { title: 'Choose an image', filters: [{ name: 'Images (PNG, JPG, SVG)', extensions: ['png', 'jpg', 'jpeg', 'svg'] }] }, - font: { title: 'Choose a font file', filters: [{ name: 'Fonts (WOFF2, WOFF, TTF, OTF)', extensions: ['woff2', 'woff', 'ttf', 'otf'] }] }, - css: { title: 'Choose a CSS file', filters: [{ name: 'CSS', extensions: ['css'] }] }, + image: { title: () => t('Choose an image'), filters: [{ name: 'Images (PNG, JPG, SVG)', extensions: ['png', 'jpg', 'jpeg', 'svg'] }] }, + font: { title: () => t('Choose a font file'), filters: [{ name: 'Fonts (WOFF2, WOFF, TTF, OTF)', extensions: ['woff2', 'woff', 'ttf', 'otf'] }] }, + css: { title: () => t('Choose a CSS file'), filters: [{ name: 'CSS', extensions: ['css'] }] }, }; h('branding:list', () => branding.list()); h('branding:get', ({ id }) => brandProfile(id), { validate: (a) => validBrandId(a.id) }); @@ -1708,7 +1726,7 @@ function setupIpc() { }, { validate: (a) => validBrandId(a.id) }); h('branding:duplicate', ({ id }) => brandProfile(branding.duplicate(id).id), { validate: (a) => validBrandId(a.id) }); h('branding:chooseAsset', async ({ kind }) => { - const res = await dialog.showOpenDialog(mainWindow, { ...ASSET_FILTERS[kind], properties: ['openFile'] }); + const res = await dialog.showOpenDialog(mainWindow, { ...ASSET_FILTERS[kind], title: ASSET_FILTERS[kind].title(), properties: ['openFile'] }); if (res.canceled) return null; const file = res.filePaths[0]; // Too large is refused as soon as it's picked, before the file is read. @@ -1817,6 +1835,15 @@ function setupIpc() { }); return lastUpdateCheck; }); + // Settings → General → Language. Saves the choice and, when the shown + // language changes, reloads the window in it. The renderer saves open + // edits first. + h('app:setLanguage', ({ language }) => { + settings.set('language', language); + if (uiLanguage() === mainText.language) return { ok: true, reloaded: false }; + void loadMainPage(); + return { ok: true, reloaded: true }; + }, { validate: (a) => c.oneOf(a.language, ['system', ...i18n.LANGUAGES.map((l) => l.code)]) }); // Settings → About "Open Microsoft Store" (Store installs): StepForge's Store // page, where Update installs the new version. The URL is built here. h('app:openStore', () => { @@ -1920,10 +1947,10 @@ if (!gotLock) { certificates: { ...(settings.get('confluence')?.certificates || {}), [host]: fingerprint } }), ask: async (host, certificates) => { const { response } = await dialog.showMessageBox(mainWindow, { - type: 'question', title: 'Choose a certificate', - message: `${host} asks for a certificate to sign you in.`, - detail: 'Choose the one from your smart card that you use to sign in to websites. StepForge remembers your choice for this site.', - buttons: [...certificates.map((cert) => `${cert.subjectName} (issued by ${cert.issuerName})`.slice(0, 120)), 'Cancel'], + type: 'question', title: t('Choose a certificate'), + message: t('{host} asks for a certificate to sign you in.', { host }), + detail: t('Choose the one from your smart card that you use to sign in to websites. StepForge remembers your choice for this site.'), + buttons: [...certificates.map((cert) => t('{subject} (issued by {issuer})', { subject: cert.subjectName, issuer: cert.issuerName }).slice(0, 120)), t('Cancel')], cancelId: certificates.length, noLink: true, }); return response >= certificates.length ? -1 : response; diff --git a/app/preload.js b/app/preload.js index 7d0f5647..7ffa5e6e 100644 --- a/app/preload.js +++ b/app/preload.js @@ -244,6 +244,7 @@ const api = { checkForUpdates: invoke('app:checkForUpdates'), updateNow: invoke('app:updateNow'), openStore: invoke('app:openStore'), + setLanguage: invoke('app:setLanguage'), platformCapabilities: invoke('platform:capabilities'), }, }; diff --git a/app/renderer/app.js b/app/renderer/app.js index 3e5af005..1f8453ae 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -11,23 +11,24 @@ function timeAgo(iso) { const then = Date.parse(iso); if (!Number.isFinite(then)) return ''; const minutes = Math.round((Date.now() - then) / 60000); - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes} min ago`; + if (minutes < 1) return t('just now'); + if (minutes < 60) return t('{count} min ago', { count: minutes }); const hours = Math.round(minutes / 60); - if (hours < 24) return `${hours} hr ago`; + if (hours < 24) return t('{count} hr ago', { count: hours }); const days = Math.round(hours / 24); - if (days === 1) return 'yesterday'; - if (days < 7) return `${days} days ago`; - return new Date(then).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: new Date(then).getFullYear() === new Date().getFullYear() ? undefined : 'numeric' }); + if (days === 1) return t('yesterday'); + if (days < 7) return tn(days, '{count} day ago', '{count} days ago'); + return new Date(then).toLocaleDateString(dateLocale, { month: 'short', day: 'numeric', year: new Date(then).getFullYear() === new Date().getFullYear() ? undefined : 'numeric' }); } const DAY_MS = 24 * 60 * 60 * 1000; /** The "May need review" pill's tooltip: why the guide is flagged. */ function reviewBadgeTitle(status) { - const since = new Date(status.since).toLocaleDateString(undefined, { dateStyle: 'medium' }); - const what = status.sinceKind === 'verified' ? `Last verified ${since}` : `Last saved ${since}`; - return `${what}, over ${review.intervalLabel(status.days)} ago. Open the guide to check it still matches the software, or right-click → Mark as verified.`; + const since = new Date(status.since).toLocaleDateString(dateLocale, { dateStyle: 'medium' }); + const what = status.sinceKind === 'verified' ? t('Last verified {date}', { date: since }) : t('Last saved {date}', { date: since }); + return t('{since}, over {interval} ago. Open the guide to check it still matches the software, or right-click → Mark as verified.', + { since: what, interval: intervalText(status.days) }); } /** "Deleted for good in 12 days", from the trash settings; null when kept. */ @@ -36,8 +37,8 @@ function trashCountdown(item, library = {}) { const deleted = Date.parse(item.deletedAt); if (!(days > 0) || !Number.isFinite(deleted)) return null; const left = Math.ceil((Math.max(deleted, library.trashEmptyingSince || 0) + days * DAY_MS - Date.now()) / DAY_MS); - if (left <= 1) return 'Deleted for good within a day'; - return `Deleted for good in ${left} days`; + if (left <= 1) return t('Deleted for good within a day'); + return tn(left, 'Deleted for good in {count} day', 'Deleted for good in {count} days'); } class StepForgeApp { @@ -140,7 +141,7 @@ class StepForgeApp { this.state.settings = await api.settings.all(); this.editor.setSettings(this.state.settings); } catch { /* the next refresh picks it up */ } - toast('An AI agent updated your brand profiles.'); + toast(t('An AI agent updated your brand profiles.')); await ensurePendingBrandRasters(api); } @@ -149,7 +150,7 @@ class StepForgeApp { if (!payload || !payload.guideId) return; if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) { await this.editor.reloadAfterOutsideChange(); - toast('An AI agent updated this guide.'); + toast(t('An AI agent updated this guide.')); } else if (this.state.view === 'library') { await this.refreshLibrary(); } @@ -162,8 +163,8 @@ class StepForgeApp { if (currentStep && currentStep.stepId === payload.step.stepId) { await this.editor.reload(payload.step.stepId); toast(payload.reason === 'redaction' - ? `Blurred ${payload.added} possible private detail${payload.added === 1 ? '' : 's'} in this step.` - : 'Documentation generated.'); + ? tn(payload.added, 'Blurred {count} possible private detail in this step.', 'Blurred {count} possible private details in this step.') + : t('Documentation generated.')); } } } @@ -268,7 +269,7 @@ class StepForgeApp { el('div.welcome', {}, el('div.welcome-title', {}, el('h1', {}, 'StepForge'), - el('p.muted', {}, 'Capture, annotate, and export step-by-step guides.'), + el('p.muted', {}, t('Capture, annotate, and export step-by-step guides.')), ), this.renderRecentGuides(), el('div.welcome-actions', {}, @@ -276,22 +277,22 @@ class StepForgeApp { type: 'button', onClick: () => this.startNewCapture(), }, - el('span.welcome-btn-label', {}, 'New Capture'), - el('span.welcome-btn-hint', {}, 'Start a guide and capture your screen'), + el('span.welcome-btn-label', {}, t('New Capture')), + el('span.welcome-btn-hint', {}, t('Start a guide and capture your screen')), ), el('button.welcome-btn', { type: 'button', onClick: () => this.openExistingWorkspace(), }, - el('span.welcome-btn-label', {}, 'Existing Workspace'), - el('span.welcome-btn-hint', {}, 'Browse your guide library'), + el('span.welcome-btn-label', {}, t('Existing Workspace')), + el('span.welcome-btn-hint', {}, t('Browse your guide library')), ), el('button.welcome-btn', { type: 'button', onClick: () => this.openSettings(), }, - el('span.welcome-btn-label', {}, 'Settings'), - el('span.welcome-btn-hint', {}, 'Theme, capture, and export options'), + el('span.welcome-btn-label', {}, t('Settings')), + el('span.welcome-btn-hint', {}, t('Theme, capture, and export options')), ), ), ), @@ -303,7 +304,7 @@ class StepForgeApp { const guides = this.state.library.guides.slice(0, 3); if (!guides.length) return null; return el('section.welcome-recent', { 'aria-labelledby': 'recent-guides-heading' }, - el('h2#recent-guides-heading', {}, 'Recent guides'), + el('h2#recent-guides-heading', {}, t('Recent guides')), el('ul', {}, guides.map((guide) => el('li', {}, el('button.welcome-recent-guide', { type: 'button', @@ -321,7 +322,7 @@ class StepForgeApp { } async startNewCapture() { - const guide = await api.library.create({ title: 'Untitled capture', captureDraft: true }); + const guide = await api.library.create({ title: t('Untitled capture'), captureDraft: true }); await this.refreshData(); await this.openGuide(guide.guideId); await this.armCaptureSession(guide.guideId); @@ -382,10 +383,10 @@ class StepForgeApp { this.cloudProvider = status.provider || 'google'; const short = status.providerShort || 'Drive'; const name = status.providerLabel || 'Google Drive'; - const labels = { synced: 'synced', syncing: 'syncing…', pending: 'pending', error: 'needs attention' }; + const labels = { synced: t('synced'), syncing: t('syncing…'), pending: t('pending'), error: t('needs attention') }; const transfer = status.transfer ? describeTransfer(status.transfer) : null; this.cloudStatus.textContent = transfer ? `${short}: ${transfer.arrow} ${transfer.compact}` : labels[status.phase] ? `${short}: ${labels[status.phase]}` : name; - this.cloudStatus.title = transfer?.detail || status.error || status.message || `${name} settings`; + this.cloudStatus.title = transfer?.detail || status.error || status.message || t('{service} settings', { service: name }); this.cloudStatus.classList.toggle('transferring', Boolean(transfer)); this.cloudStatus.style.setProperty('--transfer-progress', `${transfer?.percent || 0}%`); this.cloudStatus.setAttribute('aria-label', this.cloudStatus.title); @@ -419,11 +420,11 @@ class StepForgeApp { // No active session: show a button to start a new one for the open guide. if (!s.active) { this.captureStatus.append( - el('span', {}, 'Recording - stopped'), + el('span', {}, t('Recording - stopped')), el('button', { type: 'button', onClick: () => this.armCaptureSession(this.editor.guideId), - }, 'New recording'), + }, t('New recording')), ); return; } @@ -432,16 +433,16 @@ class StepForgeApp { // What is currently triggering captures, so the user knows what to do. const notStarted = s.paused && !s.count; - const trigger = s.gnomeRequired && s.warmingUp ? 'waiting for screen sharing' - : notStarted ? 'ready' - : s.paused ? 'paused' - : s.clickCapture ? 'on click' - : s.intervalSec > 0 ? `every ${s.intervalSec}s` - : 'hotkey only'; + const trigger = s.gnomeRequired && s.warmingUp ? t('waiting for screen sharing') + : notStarted ? t('ready') + : s.paused ? t('paused') + : s.clickCapture ? t('on click') + : s.intervalSec > 0 ? t('every {seconds}s', { seconds: s.intervalSec }) + : t('hotkey only'); const pauseBtn = el('button', { type: 'button', - title: notStarted ? 'StepForge tucks away and starts capturing' : '', + title: notStarted ? t('StepForge tucks away and starts capturing') : '', onClick: async () => { if (notStarted) { const acknowledged = await dialogs.showRecordingReminder(); @@ -449,14 +450,14 @@ class StepForgeApp { } send({ action: s.paused ? 'resume' : 'pause' }); }, - }, s.paused ? 'Start recording' : 'Stop recording'); + }, s.paused ? t('Start recording') : t('Stop recording')); this.captureStatus.append( - el('span', { title: `Capture session — ${trigger}` }, `Recording - ${trigger}`), + el('span', { title: t('Capture session — {trigger}', { trigger }) }, t('Recording - {trigger}', { trigger })), // Settings says Window but this desktop can only record the full screen: // say so before the first capture, since other windows will be in it. // Native append() prints null as "null", so leave the note out instead. - ...(s.modeNote ? [el('span.capture-mode-note', { title: s.modeNote }, 'Full screen (window capture unavailable)')] : []), + ...(s.modeNote ? [el('span.capture-mode-note', { title: s.modeNote }, t('Full screen (window capture unavailable)'))] : []), pauseBtn, ); } @@ -466,60 +467,60 @@ class StepForgeApp { if (this.state.view === 'welcome') return; if (this.state.view === 'library') { // Library actions live in the library header; only app-wide ones here. - this.topbarContext.append(el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings')); + this.topbarContext.append(el('button', { type: 'button', onClick: () => this.openSettings() }, t('Settings'))); return; } const guide = this.editorMeta?.guide; this.topbarContext.append( - el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Library'), + el('button', { type: 'button', onClick: () => this.showLibrary() }, t('Library')), el('button.primary', { type: 'button', - title: 'Capture a screenshot step', + title: t('Capture a screenshot step'), onClick: (e) => this.editor.openCaptureMenu(e), - }, 'Capture ▾'), - el('button', { type: 'button', onClick: () => this.editor.saveAll() }, 'Save'), - el('button', { type: 'button', onClick: () => this.editor.openExportDialog() }, 'Export'), + }, t('Capture ▾')), + el('button', { type: 'button', onClick: () => this.editor.saveAll() }, t('Save')), + el('button', { type: 'button', onClick: () => this.editor.openExportDialog() }, t('Export')), el('button', { type: 'button', - title: 'Share this guide as a file or on the web', + title: t('Share this guide as a file or on the web'), onClick: (e) => { const rect = e.currentTarget.getBoundingClientRect(); contextMenu(rect.left, rect.bottom + 4, [ - { label: 'Save as a .sfgz file…', action: () => this.editor.shareAsFile() }, - { label: 'Publish to the web…', action: () => this.publishToWeb(guide?.guideId || this.editor.guideId, guide?.title) }, - { label: 'Publish to Confluence…', badge: 'Beta', action: () => this.publishToConfluence(guide?.guideId || this.editor.guideId, guide?.title) }, - { label: 'Publish to Wiki.js…', badge: 'Beta', action: () => this.publishToWikiJs(guide?.guideId || this.editor.guideId, guide?.title) }, + { label: t('Save as a .sfgz file…'), action: () => this.editor.shareAsFile() }, + { label: t('Publish to the web…'), action: () => this.publishToWeb(guide?.guideId || this.editor.guideId, guide?.title) }, + { label: t('Publish to Confluence…'), badge: 'Beta', action: () => this.publishToConfluence(guide?.guideId || this.editor.guideId, guide?.title) }, + { label: t('Publish to Wiki.js…'), badge: 'Beta', action: () => this.publishToWikiJs(guide?.guideId || this.editor.guideId, guide?.title) }, ]); }, - }, 'Share ▾'), + }, t('Share ▾')), el('button', { type: 'button', className: this.editorMeta?.aiProgress ? 'ai-busy' : '', - title: this.editorMeta?.aiProgress ? 'AI is writing. Click to stop.' : 'Let AI write this guide', + title: this.editorMeta?.aiProgress ? t('AI is writing. Click to stop.') : t('Let AI write this guide'), onClick: (e) => { const rect = e.currentTarget.getBoundingClientRect(); contextMenu(rect.left, rect.bottom + 4, this.editor.aiMenuItems()); }, - }, this.editorMeta?.aiProgress ? `AI · ${this.editorMeta.aiProgress}` : 'AI ▾'), + }, this.editorMeta?.aiProgress ? `AI · ${this.editorMeta.aiProgress}` : t('AI ▾')), el('button', { type: 'button', onClick: (e) => { const rect = e.target.getBoundingClientRect(); contextMenu(rect.left, rect.bottom + 4, [ - { label: 'Rename guide…', action: () => this.renameGuide() }, - { label: 'Guide information…', action: () => this.editor.openGuideInfo() }, - { label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() }, - { label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() }, - { label: 'Find private details…', action: () => this.editor.findPrivateDetails() }, - { label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() }, + { label: t('Rename guide…'), action: () => this.renameGuide() }, + { label: t('Guide information…'), action: () => this.editor.openGuideInfo() }, + { label: t('Guide placeholders…'), action: () => this.editor.openGuidePlaceholders() }, + { label: t('Backups & snapshots…'), action: () => this.editor.openBackupsDialog() }, + { label: t('Find private details…'), action: () => this.editor.findPrivateDetails() }, + { label: guide && guide.linkedSource ? t('Linked guide…') : t('Linked guide (not linked)'), action: () => this.editor.openLinkedGuide() }, 'sep', - { label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() }, - { label: 'Quick actions (Ctrl+/)', action: () => this.editor.openQuickActions() }, - { label: 'Settings…', action: () => this.openSettings() }, + { label: t('Keyboard shortcuts…'), action: () => this.editor.openShortcutsHelp() }, + { label: t('Quick actions (Ctrl+/)'), action: () => this.editor.openQuickActions() }, + { label: t('Settings…'), action: () => this.openSettings() }, ]); }, - }, 'More ▾'), + }, t('More ▾')), el('span.muted', { style: { marginLeft: '8px' } }, guide ? `${guide.title} · ${this.editorMeta?.stepCount || 0} steps` : ''), ); } @@ -539,20 +540,21 @@ class StepForgeApp { this.state.selectedTrash = new Set(); } const count = trash ? this.state.trash.length : this.state.library.guides.filter((guide) => this.scopeGuide(guide)).length; - const subtitle = q ? `Results for “${q}”` : trash ? `${count} deleted guide${count === 1 ? '' : 's'}` : `${count} guide${count === 1 ? '' : 's'}`; + const subtitle = q ? t('Results for “{query}”', { query: q }) + : trash ? tn(count, '{count} deleted guide', '{count} deleted guides') : tn(count, '{count} guide', '{count} guides'); const folders = this.renderFolderItems(this.state.library.folders || [], null, 0); const body = el('div.library', {}, el('aside.lib-side', {}, el('div.lib-side-section', {}, - this.libraryNavItem('all', 'All guides', this.state.library.guides.length), - this.libraryNavItem('favorites', 'Favorites', this.state.library.guides.filter((g) => g.favorite).length), + this.libraryNavItem('all', t('All guides'), this.state.library.guides.length), + this.libraryNavItem('favorites', t('Favorites'), this.state.library.guides.filter((g) => g.favorite).length), ), el('div.lib-side-head', {}, - el('h3', {}, 'Folders'), - el('button.icon.lib-add-folder', { type: 'button', title: 'New folder', 'aria-label': 'New folder', onClick: () => this.createFolder() }, '+'), + el('h3', {}, t('Folders')), + el('button.icon.lib-add-folder', { type: 'button', title: t('New folder'), 'aria-label': t('New folder'), onClick: () => this.createFolder() }, '+'), ), - el('div.lib-side-section', {}, ...(folders.length ? folders : [el('div.lib-side-empty', {}, 'No folders yet')])), - el('div.lib-side-footer', {}, this.libraryNavItem('trash', 'Trash', this.state.trash.length)), + el('div.lib-side-section', {}, ...(folders.length ? folders : [el('div.lib-side-empty', {}, t('No folders yet'))])), + el('div.lib-side-footer', {}, this.libraryNavItem('trash', t('Trash'), this.state.trash.length)), ), el('main.lib-main', {}, el('header.lib-header', {}, @@ -566,10 +568,10 @@ class StepForgeApp { className: this.state.selectMode ? 'primary' : '', 'aria-pressed': String(this.state.selectMode), onClick: () => this.toggleSelectMode(), - }, 'Select') : null, - trash && count && !this.state.selectMode ? el('button.danger', { type: 'button', onClick: () => this.purgeTrashItem() }, 'Empty trash') : null, - trash ? null : el('button', { type: 'button', onClick: (e) => this.openImportMenu(e) }, 'Import ▾'), - trash ? null : el('button.primary', { type: 'button', onClick: () => this.createGuide() }, 'New guide'), + }, t('Select')) : null, + trash && count && !this.state.selectMode ? el('button.danger', { type: 'button', onClick: () => this.purgeTrashItem() }, t('Empty trash')) : null, + trash ? null : el('button', { type: 'button', onClick: (e) => this.openImportMenu(e) }, t('Import ▾')), + trash ? null : el('button.primary', { type: 'button', onClick: () => this.createGuide() }, t('New guide')), ), ), this.domBulkBar = el('div', {}), @@ -592,8 +594,8 @@ class StepForgeApp { openImportMenu(event) { const rect = event.currentTarget.getBoundingClientRect(); contextMenu(rect.left, rect.bottom + 4, [ - { label: 'Import a copy of a guide (.sfgz)…', action: () => this.importArchive('copy') }, - { label: 'Open a linked guide (.sfgz)…', action: () => this.importArchive('linked') }, + { label: t('Import a copy of a guide (.sfgz)…'), action: () => this.importArchive('copy') }, + { label: t('Open a linked guide (.sfgz)…'), action: () => this.importArchive('linked') }, ]); } @@ -644,17 +646,17 @@ class StepForgeApp { const folder = (this.state.library.folders || []).find((f) => f.id === folderId); if (!folder) return; contextMenu(event.clientX, event.clientY, [ - { label: 'Rename folder', action: () => this.renameFolder(folderId) }, - { label: 'Delete folder', danger: true, action: () => this.deleteFolder(folderId) }, + { label: t('Rename folder'), action: () => this.renameFolder(folderId) }, + { label: t('Delete folder'), danger: true, action: () => this.deleteFolder(folderId) }, ]); } filterLabel() { - if (this.state.folderFilter === 'all') return 'All guides'; - if (this.state.folderFilter === 'favorites') return 'Favorites'; - if (this.state.folderFilter === 'trash') return 'Trash'; + if (this.state.folderFilter === 'all') return t('All guides'); + if (this.state.folderFilter === 'favorites') return t('Favorites'); + if (this.state.folderFilter === 'trash') return t('Trash'); const folder = (this.state.library.folders || []).find((f) => f.id === this.state.folderFilter); - return folder ? folder.name : 'All guides'; + return folder ? folder.name : t('All guides'); } scopeGuide(guide) { @@ -676,7 +678,7 @@ class StepForgeApp { }); clearNode(this.domLibraryResults); if (!filtered.length) { - this.domLibraryResults.append(el('div.empty-state', {}, el('div.big', {}, 'Search'), 'No results for this query.')); + this.domLibraryResults.append(el('div.empty-state', {}, el('div.big', {}, t('Search')), t('No results for this query.'))); return; } this.domLibraryResults.append( @@ -704,11 +706,11 @@ class StepForgeApp { if (!guides.length) { const filter = this.state.folderFilter; this.domLibraryResults.append(filter === 'all' - ? this.emptyState('No guides yet', 'Create a guide and start capturing steps, or import one someone shared with you.', - el('button.primary', { type: 'button', onClick: () => this.createGuide() }, 'New guide')) + ? this.emptyState(t('No guides yet'), t('Create a guide and start capturing steps, or import one someone shared with you.'), + el('button.primary', { type: 'button', onClick: () => this.createGuide() }, t('New guide'))) : filter === 'favorites' - ? this.emptyState('No favorites', 'Star a guide to keep it here.') - : this.emptyState('This folder is empty', 'Right-click a guide, or select guides, to move them here.')); + ? this.emptyState(t('No favorites'), t('Star a guide to keep it here.')) + : this.emptyState(t('This folder is empty'), t('Right-click a guide, or select guides, to move them here.'))); return; } this.domLibraryResults.append(el('div.guide-grid', {}, ...guides.map((guide) => this.guideCard(guide)))); @@ -718,9 +720,10 @@ class StepForgeApp { clearNode(this.domLibraryResults); if (!this.state.trash.length) { const days = this.state.settings?.library?.trashDays ?? 30; - this.domLibraryResults.append(this.emptyState('Trash is empty', days > 0 - ? `Deleted guides stay here for ${days} days, then are deleted for good. Change this in Settings → General.` - : 'Deleted guides stay here until you empty the trash.')); + this.domLibraryResults.append(this.emptyState(t('Trash is empty'), days > 0 + ? tn(days, 'Deleted guides stay here for {count} day, then are deleted for good. Change this in Settings → General.', + 'Deleted guides stay here for {count} days, then are deleted for good. Change this in Settings → General.') + : t('Deleted guides stay here until you empty the trash.'))); return; } const selectMode = this.state.selectMode; @@ -736,9 +739,9 @@ class StepForgeApp { e.preventDefault(); if (selectMode) return; contextMenu(e.clientX, e.clientY, [ - { label: 'Restore', action: () => this.restoreTrashItem(item.name) }, + { label: t('Restore'), action: () => this.restoreTrashItem(item.name) }, 'sep', - { label: 'Empty trash', danger: true, action: () => this.purgeTrashItem() }, + { label: t('Empty trash'), danger: true, action: () => this.purgeTrashItem() }, ]); }, }, @@ -750,8 +753,8 @@ class StepForgeApp { countdown ? el('div.trash-countdown', {}, countdown) : null, el('div.card-footer', {}, el('span', {}, `${item.stepCount} step${item.stepCount === 1 ? '' : 's'}`), - item.deletedAt ? el('span', {}, `Deleted ${timeAgo(item.deletedAt)}`) : null, - el('button.card-restore', { type: 'button', onClick: (e) => { e.stopPropagation(); this.restoreTrashItem(item.name); } }, 'Restore'))); + item.deletedAt ? el('span', {}, t('Deleted {when}', { when: timeAgo(item.deletedAt) })) : null, + el('button.card-restore', { type: 'button', onClick: (e) => { e.stopPropagation(); this.restoreTrashItem(item.name); } }, t('Restore')))); }); this.domLibraryResults.append(el('div.guide-grid', {}, ...items)); } @@ -762,8 +765,8 @@ class StepForgeApp { className: `select-check${selected ? ' on' : ''}`, role: 'checkbox', 'aria-checked': String(selected), - 'aria-label': selected ? 'Deselect' : 'Select', - title: selected ? 'Deselect' : 'Select', + 'aria-label': selected ? t('Deselect') : t('Select'), + title: selected ? t('Deselect') : t('Select'), onClick, }, selected ? '✓' : ''); } @@ -780,7 +783,7 @@ class StepForgeApp { const reviewState = review.reviewStatus(guide, this.state.settings?.library?.reviewAfterDays); return el('div.guide-card', { className: `guide-card${selected ? ' selected' : ''}${selectMode ? ' selecting' : ''}`, - title: selectMode ? '' : 'Open guide · Ctrl-click to select', + title: selectMode ? '' : t('Open guide · Ctrl-click to select'), onClick: (e) => { // Ctrl/Cmd-click starts or extends a selection; Shift-click selects a range. if (selectMode || e.ctrlKey || e.metaKey || e.shiftKey) this.selectGuideFromClick(guide.guideId, e); @@ -799,8 +802,8 @@ class StepForgeApp { el('button.fav', { type: 'button', className: `fav${guide.favorite ? ' on' : ''}`, - title: guide.favorite ? 'Remove from favorites' : 'Add to favorites', - 'aria-label': guide.favorite ? 'Remove from favorites' : 'Add to favorites', + title: guide.favorite ? t('Remove from favorites') : t('Add to favorites'), + 'aria-label': guide.favorite ? t('Remove from favorites') : t('Add to favorites'), 'aria-pressed': String(Boolean(guide.favorite)), onClick: async (e) => { e.stopPropagation(); @@ -809,14 +812,14 @@ class StepForgeApp { await this.refreshLibrary(); }, }, '★'), - el('h4', {}, guide.title || 'Untitled guide'), + el('h4', {}, guide.title || t('Untitled guide')), description ? el('div.snippet', {}, description) : null, el('div.card-footer', {}, el('span', {}, `${steps} step${steps === 1 ? '' : 's'}`), showFolder ? el('span.card-folder', {}, folder.name) : null, - guide.linkedSource ? el('span.badge', { title: 'Saved to a linked .sfgz file' }, 'Linked') : null, - guide.locked ? el('span.badge', { title: 'Open on another device' }, 'Locked') : null, - reviewState?.overdue ? el('span.badge.review-badge', { title: reviewBadgeTitle(reviewState) }, 'May need review') : null, + guide.linkedSource ? el('span.badge', { title: t('Saved to a linked .sfgz file') }, t('Linked')) : null, + guide.locked ? el('span.badge', { title: t('Open on another device') }, t('Locked')) : null, + reviewState?.overdue ? el('span.badge.review-badge', { title: reviewBadgeTitle(reviewState) }, t('May need review')) : null, el('span.card-updated', { title: fmtDate(guide.updatedAt) }, timeAgo(guide.updatedAt)))); } @@ -827,7 +830,7 @@ class StepForgeApp { isStep ? el('div.card-eyebrow', {}, guide.title) : null, el('h4', {}, result.title || guide.title), result.snippet ? el('div.snippet', {}, result.snippet) : null, - el('div.card-footer', {}, el('span.badge', {}, isStep ? 'Step' : 'Guide'))); + el('div.card-footer', {}, el('span.badge', {}, isStep ? t('Step') : t('Guide')))); } guideContextMenu(event, guide) { @@ -835,24 +838,24 @@ class StepForgeApp { const folderItems = (this.state.library.folders || []) .filter((folder) => folder.id !== currentFolderId) .map((folder) => ({ - label: `Move to ${folder.name}`, + label: t('Move to {folder}', { folder: folder.name }), action: () => this.moveGuideToFolder(guide.guideId, folder.id), })); - if (currentFolderId) folderItems.push({ label: 'Move to no folder', action: () => this.moveGuideToFolder(guide.guideId, null) }); + if (currentFolderId) folderItems.push({ label: t('Move to no folder'), action: () => this.moveGuideToFolder(guide.guideId, null) }); const moveItems = folderItems.length ? ['sep', ...folderItems] : []; contextMenu(event.clientX, event.clientY, [ - { label: 'Open guide', action: () => this.openGuideAndArmCapture(guide.guideId) }, - { label: 'Rename guide…', action: () => this.renameGuide(guide) }, - { label: guide.favorite ? 'Unfavorite' : 'Favorite', action: () => this.toggleFavorite(guide) }, - { label: 'Mark as verified', action: () => this.markVerified([guide.guideId]) }, - { label: 'Duplicate guide', action: () => this.duplicateGuide(guide.guideId) }, - { label: 'Export', action: () => this.openGuideExport(guide.guideId) }, - { label: 'Publish to the web…', action: () => this.publishToWeb(guide.guideId, guide.title) }, - { label: 'Publish to Confluence…', badge: 'Beta', action: () => this.publishToConfluence(guide.guideId, guide.title) }, - { label: 'Publish to Wiki.js…', badge: 'Beta', action: () => this.publishToWikiJs(guide.guideId, guide.title) }, + { label: t('Open guide'), action: () => this.openGuideAndArmCapture(guide.guideId) }, + { label: t('Rename guide…'), action: () => this.renameGuide(guide) }, + { label: guide.favorite ? t('Unfavorite') : t('Favorite'), action: () => this.toggleFavorite(guide) }, + { label: t('Mark as verified'), action: () => this.markVerified([guide.guideId]) }, + { label: t('Duplicate guide'), action: () => this.duplicateGuide(guide.guideId) }, + { label: t('Export'), action: () => this.openGuideExport(guide.guideId) }, + { label: t('Publish to the web…'), action: () => this.publishToWeb(guide.guideId, guide.title) }, + { label: t('Publish to Confluence…'), badge: 'Beta', action: () => this.publishToConfluence(guide.guideId, guide.title) }, + { label: t('Publish to Wiki.js…'), badge: 'Beta', action: () => this.publishToWikiJs(guide.guideId, guide.title) }, ...moveItems, 'sep', - { label: 'Delete guide', danger: true, action: () => this.deleteGuide(guide.guideId) }, + { label: t('Delete guide'), danger: true, action: () => this.deleteGuide(guide.guideId) }, ]); } @@ -929,7 +932,7 @@ class StepForgeApp { async bulkPurgeTrash() { const names = [...this.state.selectedTrash]; if (!names.length) return; - const ok = await confirmDialog(`Permanently delete ${names.length} item${names.length === 1 ? '' : 's'}? This cannot be undone.`, { danger: true, okLabel: 'Delete forever' }); + const ok = await confirmDialog(tn(names.length, 'Permanently delete {count} item? This cannot be undone.', 'Permanently delete {count} items? This cannot be undone.'), { danger: true, okLabel: t('Delete forever') }); if (!ok) return; await api.library.trashPurge({ names }); this.state.selectedTrash = new Set(); @@ -940,9 +943,9 @@ class StepForgeApp { if (!this.domBulkBar) return; clearNode(this.domBulkBar); if (!this.state.selectMode) return; - const bar = (n, total, allSelected, onToggleAll, actions) => el('div.bulk-bar', { role: 'toolbar', 'aria-label': 'Selection actions' }, - el('span.bulk-count', {}, n ? `${n} of ${total} selected` : 'Click guides to select them'), - el('button.link', { type: 'button', onClick: onToggleAll }, allSelected ? 'Clear' : 'Select all'), + const bar = (n, total, allSelected, onToggleAll, actions) => el('div.bulk-bar', { role: 'toolbar', 'aria-label': t('Selection actions') }, + el('span.bulk-count', {}, n ? t('{count} of {total} selected', { count: n, total }) : t('Click guides to select them')), + el('button.link', { type: 'button', onClick: onToggleAll }, allSelected ? t('Clear') : t('Select all')), el('span.spacer', {}), ...actions); if (this.state.folderFilter === 'trash') { @@ -950,8 +953,8 @@ class StepForgeApp { const total = this.state.trash.length; const allSelected = total > 0 && n === total; this.domBulkBar.append(bar(n, total, allSelected, () => (allSelected ? this.clearTrashSelection() : this.selectAllTrash()), [ - el('button', { type: 'button', disabled: !n, onClick: () => this.bulkRestoreTrash() }, 'Restore'), - el('button.danger', { type: 'button', disabled: !n, onClick: () => this.bulkPurgeTrash() }, 'Delete forever'), + el('button', { type: 'button', disabled: !n, onClick: () => this.bulkRestoreTrash() }, t('Restore')), + el('button.danger', { type: 'button', disabled: !n, onClick: () => this.bulkPurgeTrash() }, t('Delete forever')), ])); return; } @@ -961,10 +964,10 @@ class StepForgeApp { const selected = guides.filter((g) => this.state.selectedGuides.has(g.guideId)); const allFavorite = selected.length > 0 && selected.every((g) => g.favorite); this.domBulkBar.append(bar(n, guides.length, allSelected, () => (allSelected ? this.clearSelection() : this.selectAllGuides()), [ - el('button', { type: 'button', disabled: !n, onClick: () => this.bulkSetFavorite(!allFavorite) }, allFavorite ? 'Unfavorite' : 'Favorite'), - el('button', { type: 'button', disabled: !n, onClick: (e) => this.openBulkMoveMenu(e) }, 'Move to ▾'), - el('button', { type: 'button', disabled: !n, title: 'Record that these guides still match the software today', onClick: () => this.markVerified([...this.state.selectedGuides]) }, 'Mark as verified'), - el('button.danger', { type: 'button', disabled: !n, onClick: () => this.bulkDelete() }, 'Delete'), + el('button', { type: 'button', disabled: !n, onClick: () => this.bulkSetFavorite(!allFavorite) }, allFavorite ? t('Unfavorite') : t('Favorite')), + el('button', { type: 'button', disabled: !n, onClick: (e) => this.openBulkMoveMenu(e) }, t('Move to ▾')), + el('button', { type: 'button', disabled: !n, title: t('Record that these guides still match the software today'), onClick: () => this.markVerified([...this.state.selectedGuides]) }, t('Mark as verified')), + el('button.danger', { type: 'button', disabled: !n, onClick: () => this.bulkDelete() }, t('Delete')), ])); } @@ -975,7 +978,7 @@ class StepForgeApp { action: () => this.bulkMoveToFolder(folder.id), })); contextMenu(rect.left, rect.bottom + 4, [ - { label: 'No folder', action: () => this.bulkMoveToFolder(null) }, + { label: t('No folder'), action: () => this.bulkMoveToFolder(null) }, ...(folderItems.length ? ['sep', ...folderItems] : []), ]); } @@ -997,7 +1000,9 @@ class StepForgeApp { async bulkDelete() { const ids = [...this.state.selectedGuides]; if (!ids.length) return; - const ok = await confirmDialog(`Delete ${ids.length} guide${ids.length === 1 ? '' : 's'}? They'll move to Trash. Guides that sync to the cloud are also removed from your other devices, with one cloud recovery snapshot retained.`, { danger: true, okLabel: 'Delete' }); + const ok = await confirmDialog(tn(ids.length, + 'Delete {count} guide? It moves to Trash. If it syncs to the cloud, it is also removed from your other devices, with one cloud recovery snapshot retained.', + 'Delete {count} guides? They’ll move to Trash. Guides that sync to the cloud are also removed from your other devices, with one cloud recovery snapshot retained.'), { danger: true, okLabel: t('Delete') }); if (!ok) return; await Promise.all(ids.map((guideId) => api.library.delete({ guideId }))); this.state.selectedGuides = new Set(); @@ -1006,13 +1011,13 @@ class StepForgeApp { async createGuide() { const title = await dialogs.promptText({ - title: 'New Guide', - label: 'Title', - value: 'Untitled guide', - placeholder: 'Untitled guide', + title: t('New Guide'), + label: t('Title'), + value: t('Untitled guide'), + placeholder: t('Untitled guide'), }); if (title == null) return; - const guide = await api.library.create({ title: title.trim() || 'Untitled guide', captureDraft: !title.trim() || title.trim() === 'Untitled guide' }); + const guide = await api.library.create({ title: title.trim() || t('Untitled guide'), captureDraft: !title.trim() || title.trim() === t('Untitled guide') }); await this.refreshLibrary(); // Arm a (paused) capture session like every other open path, so the // "Start recording" bar appears and actually controls this new guide. @@ -1022,7 +1027,7 @@ class StepForgeApp { } async createFolder() { - const name = await dialogs.promptText({ title: 'New folder', label: 'Folder name', value: '' }); + const name = await dialogs.promptText({ title: t('New folder'), label: t('Folder name'), value: '' }); if (name == null || !name.trim()) return; await api.folders.create({ name: name.trim(), parentId: null }); await this.refreshLibrary(); @@ -1031,7 +1036,7 @@ class StepForgeApp { async renameFolder(folderId) { const folder = (this.state.library.folders || []).find((f) => f.id === folderId); if (!folder) return; - const name = await dialogs.promptText({ title: 'Rename folder', label: 'Folder name', value: folder.name }); + const name = await dialogs.promptText({ title: t('Rename folder'), label: t('Folder name'), value: folder.name }); if (name == null || !name.trim()) return; await api.folders.rename({ folderId, name: name.trim() }); await this.refreshLibrary(); @@ -1040,7 +1045,7 @@ class StepForgeApp { async deleteFolder(folderId) { const folder = (this.state.library.folders || []).find((f) => f.id === folderId); if (!folder) return; - const ok = await confirmDialog(`Delete the folder “${folder.name}”? Guides stay in the library.`); + const ok = await confirmDialog(t('Delete the folder “{folder}”? Guides stay in the library.', { folder: folder.name })); if (!ok) return; await api.folders.delete({ folderId }); await this.refreshLibrary(); @@ -1055,7 +1060,7 @@ class StepForgeApp { if (!guideIds.length) return; await api.library.markVerified({ guideIds }); await this.refreshLibrary(); - toast(guideIds.length === 1 ? 'Marked as verified today.' : `${guideIds.length} guides marked as verified today.`); + toast(guideIds.length === 1 ? t('Marked as verified today.') : t('{count} guides marked as verified today.', { count: guideIds.length })); } async toggleFavorite(guide) { @@ -1071,7 +1076,7 @@ class StepForgeApp { async deleteGuide(guideId) { const guide = this.state.library.guides.find((g) => g.guideId === guideId); if (!guide) return; - const ok = await confirmDialog(`Delete “${guide.title}”? It moves to Trash. If it syncs to the cloud, it is also removed from your other devices and one cloud recovery snapshot is retained.`, { danger: true, okLabel: 'Delete' }); + const ok = await confirmDialog(t('Delete “{title}”? It moves to Trash. If it syncs to the cloud, it is also removed from your other devices and one cloud recovery snapshot is retained.', { title: guide.title }), { danger: true, okLabel: t('Delete') }); if (!ok) return; await api.library.delete({ guideId }); await this.refreshLibrary(); @@ -1083,7 +1088,7 @@ class StepForgeApp { } async purgeTrashItem() { - const ok = await confirmDialog('Permanently empty the trash?', { danger: true, okLabel: 'Empty trash' }); + const ok = await confirmDialog(t('Permanently empty the trash?'), { danger: true, okLabel: t('Empty trash') }); if (!ok) return; await api.library.trashPurge(); await this.refreshLibrary(); @@ -1098,7 +1103,7 @@ class StepForgeApp { await showPublishToWebDialog({ api, guideId, - guideTitle: title || 'Untitled guide', + guideTitle: title || t('Untitled guide'), onOpenAccounts: () => this.openSettings('github'), onShowFinding: (picked) => { finding = picked; }, }); @@ -1116,7 +1121,7 @@ class StepForgeApp { const changed = await showPublishToConfluenceDialog({ api, guideId, - guideTitle: title || 'Untitled guide', + guideTitle: title || t('Untitled guide'), onOpenAccounts: () => this.openSettings('confluence'), onShowFinding: (picked) => { finding = picked; }, }); @@ -1134,7 +1139,7 @@ class StepForgeApp { const changed = await showPublishToWikiJsDialog({ api, guideId, - guideTitle: title || 'Untitled guide', + guideTitle: title || t('Untitled guide'), onOpenAccounts: () => this.openSettings('wikijs'), onShowFinding: (picked) => { finding = picked; }, }); @@ -1160,7 +1165,7 @@ class StepForgeApp { async renameGuide(guide = this.editorMeta?.guide) { if (!guide) return; - const title = await dialogs.promptText({ title: 'Rename guide', label: 'Title', value: guide.title }); + const title = await dialogs.promptText({ title: t('Rename guide'), label: t('Title'), value: guide.title }); if (title == null || !title.trim()) return; const fullGuide = (await api.guide.get({ guideId: guide.guideId })).guide; fullGuide.title = title.trim(); @@ -1182,6 +1187,7 @@ class StepForgeApp { async openSettings(section) { const settings = await api.settings.all(); const placeholders = await api.settings.globalPlaceholders(); + let newLanguage = null; await dialogs.showSettingsDialog({ api, settings, @@ -1204,8 +1210,14 @@ class StepForgeApp { await api.settings.set({ keyPath: 'library', value: next.library }); await api.settings.setGlobalPlaceholders(next.placeholders || {}); this.state.settings = await api.settings.all(); + if (next.language !== settings.language) newLanguage = next.language; }, }); + // A new language reloads the window in it, so open edits are saved first. + if (newLanguage && await this.flushEditorBeforeNavigation()) { + const result = await api.app.setLanguage({ language: newLanguage }); + if (result.reloaded) return; + } await this.refreshData(); this.renderTopbar(); if (this.state.view === 'library') this.renderLibrary(); @@ -1217,11 +1229,11 @@ class StepForgeApp { return; } const commands = [ - { kind: 'cmd', label: 'New guide', description: 'Create a blank guide', action: () => this.createGuide() }, - { kind: 'cmd', label: 'Import archive', description: 'Open a .sfgz guide archive', action: () => this.importArchive('copy') }, - { kind: 'cmd', label: 'Open linked archive', description: 'Import a linked guide from .sfgz', action: () => this.importArchive('linked') }, - { kind: 'cmd', label: 'Settings', description: 'Open application settings', action: () => this.openSettings() }, - { kind: 'cmd', label: 'Refresh library', description: 'Reload guides and folders', action: () => this.refreshLibrary() }, + { kind: 'cmd', label: t('New guide'), description: t('Create a blank guide'), action: () => this.createGuide() }, + { kind: 'cmd', label: t('Import archive'), description: t('Open a .sfgz guide archive'), action: () => this.importArchive('copy') }, + { kind: 'cmd', label: t('Open linked archive'), description: t('Import a linked guide from .sfgz'), action: () => this.importArchive('linked') }, + { kind: 'cmd', label: t('Settings'), description: t('Open application settings'), action: () => this.openSettings() }, + { kind: 'cmd', label: t('Refresh library'), description: t('Reload guides and folders'), action: () => this.refreshLibrary() }, ]; await dialogs.showQuickActions({ commands, @@ -1259,6 +1271,11 @@ document.addEventListener('click', (e) => { }, true); function boot() { + // index.html's own text, in the interface language. + document.documentElement.lang = uiLanguage; + for (const node of document.querySelectorAll('[data-i18n-title]')) node.title = t(node.title); + for (const node of document.querySelectorAll('[data-i18n-placeholder]')) node.placeholder = t(node.placeholder); + for (const node of document.querySelectorAll('[data-i18n-label]')) node.setAttribute('aria-label', t(node.getAttribute('aria-label'))); const app = new StepForgeApp(); app.init(); window.stepforgeApp = app; diff --git a/app/renderer/cloud.js b/app/renderer/cloud.js index 58852b10..92b68afa 100644 --- a/app/renderer/cloud.js +++ b/app/renderer/cloud.js @@ -73,7 +73,7 @@ function makeCloudSettings(api, { provider = 'google' } = {}) { const formatWhen = (iso) => { const date = iso ? new Date(iso) : null; return date && !Number.isNaN(date.getTime()) - ? date.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) + ? date.toLocaleString(dateLocale, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'Date unavailable'; }; const timeAgo = (iso) => { diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index ab546572..cc61cf58 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -40,7 +40,7 @@ const CODE_THEME_CHOICES = [ { value: 'monokai', label: 'Monokai' }, { value: 'solarized-light', label: 'Solarized Light' }, { value: 'nord', label: 'Nord' }, - { value: 'plain', label: 'Plain (no highlighting)' }, + { value: 'plain', label: t('Plain (no highlighting)') }, ]; const HOTKEY_LABELS = { @@ -108,7 +108,7 @@ function makeHotkeyInput(value = '') { const keys = el('div.hotkey-keys'); const clearBtn = el('button.hotkey-clear', { type: 'button', - title: 'Clear shortcut', + title: t('Clear shortcut'), onClick: (e) => { e.stopPropagation(); current = ''; @@ -116,7 +116,7 @@ function makeHotkeyInput(value = '') { }, }, '×'); - const wrap = el('div.hotkey-input', { tabindex: '0', role: 'button', title: 'Click, then press a key combination' }, + const wrap = el('div.hotkey-input', { tabindex: '0', role: 'button', title: t('Click, then press a key combination') }, keys, clearBtn); function renderKeys(parts) { @@ -129,12 +129,12 @@ function makeHotkeyInput(value = '') { function render() { if (wrap.classList.contains('recording')) { - keys.replaceChildren(el('span.hotkey-placeholder', {}, 'Press a key combination…')); + keys.replaceChildren(el('span.hotkey-placeholder', {}, t('Press a key combination…'))); clearBtn.hidden = true; return; } if (!current) { - keys.replaceChildren(el('span.hotkey-placeholder', {}, 'Click to set shortcut')); + keys.replaceChildren(el('span.hotkey-placeholder', {}, t('Click to set shortcut'))); clearBtn.hidden = true; return; } @@ -193,9 +193,9 @@ function makeHotkeyInput(value = '') { * there. `positions` is [{ value, label, current?, levels: [{ value, label }] }]; * resolves { gap, depth } (the chosen values) or null. */ -function showMoveStepDialog({ title = 'Move step', hint = '', positions = [], currentDepth = 0 } = {}) { +function showMoveStepDialog({ title = t('Move step'), hint = '', positions = [], currentDepth = 0 } = {}) { return new Promise((resolve) => { - const where = el('select', {}, positions.map((p) => el('option', { value: p.value }, p.current ? `${p.label} (current place)` : p.label))); + const where = el('select', {}, positions.map((p) => el('option', { value: p.value }, p.current ? t('{place} (current place)', { place: p.label }) : p.label))); const level = el('select', {}); const fillLevels = () => { const position = positions.find((p) => p.value === where.value) || positions[0]; @@ -215,12 +215,12 @@ function showMoveStepDialog({ title = 'Move step', hint = '', positions = [], cu title, body: el('div', {}, hint ? el('div.muted', { style: { marginBottom: '8px' } }, hint) : null, - labeledRow('Place', where), - labeledRow('Level', level), + labeledRow(t('Place'), where), + labeledRow(t('Level'), level), ), footer: [ - el('button', { onClick: () => done(null) }, 'Cancel'), - el('button.primary', { onClick: () => done({ gap: where.value, depth: level.value }) }, 'Move'), + el('button', { onClick: () => done(null) }, t('Cancel')), + el('button.primary', { onClick: () => done({ gap: where.value, depth: level.value }) }, t('Move')), ], onClose: () => resolve(null), }); @@ -245,8 +245,8 @@ async function promptText({ title, body: labeledRow(label, field, { stacked: multiline }), footer: [ - el('button', { onClick: () => { close(); resolve(null); } }, 'Cancel'), - el('button.primary', { onClick: () => { close(); resolve(field.value); } }, 'OK'), + el('button', { onClick: () => { close(); resolve(null); } }, t('Cancel')), + el('button.primary', { onClick: () => { close(); resolve(field.value); } }, t('OK')), ], onClose: () => resolve(null), }); @@ -276,12 +276,12 @@ function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onC const input = el('input', { type: 'search', value: query, - placeholder: 'Search guides, steps, and commands', + placeholder: t('Search guides, steps, and commands'), autocomplete: 'off', spellcheck: false, }); const results = el('div.qa-results'); - const hint = el('div.muted', {}, 'Type to search, arrows to move, Enter to open.'); + const hint = el('div.muted', {}, t('Type to search, arrows to move, Enter to open.')); let items = []; let active = 0; @@ -292,7 +292,7 @@ function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onC function renderItems() { clearNode(results); if (!items.length) { - results.append(el('div.muted', { style: { padding: '8px 2px' } }, 'No matches.')); + results.append(el('div.muted', { style: { padding: '8px 2px' } }, t('No matches.'))); return; } items.forEach((item, idx) => { @@ -332,7 +332,7 @@ function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onC } const { close } = openModal({ - title: 'Quick Actions', + title: t('Quick Actions'), body: el('div.quick-actions', {}, input, hint, @@ -340,7 +340,7 @@ function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onC ), wide: true, footer: [ - el('button', { onClick: () => { close(); resolve(null); } }, 'Close'), + el('button', { onClick: () => { close(); resolve(null); } }, t('Close')), ], onClose: () => { if (onClose) onClose(); @@ -370,13 +370,13 @@ function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onC */ function showFindReplace({ stepNumbers = {}, find, replace } = {}) { return new Promise((resolve) => { - const query = el('input', { type: 'search', placeholder: 'Find', autocomplete: 'off', spellcheck: false }); - const replacement = el('input', { type: 'text', placeholder: 'Replace with', autocomplete: 'off', spellcheck: false }); + const query = el('input', { type: 'search', placeholder: t('Find'), autocomplete: 'off', spellcheck: false }); + const replacement = el('input', { type: 'text', placeholder: t('Replace with'), autocomplete: 'off', spellcheck: false }); const matchCase = el('input', { type: 'checkbox' }); const wholeWord = el('input', { type: 'checkbox' }); const summary = el('span.muted'); const list = el('div.qa-results.fr-results'); - const replaceButton = el('button.primary', { disabled: true }, 'Replace'); + const replaceButton = el('button.primary', { disabled: true }, t('Replace')); let matches = []; let unticked = new Set(); // ids the user unticked; everything else is ticked let lastSearch = ''; @@ -392,12 +392,14 @@ function showFindReplace({ stepNumbers = {}, find, replace } = {}) { function updateSummary() { const n = chosen().length; const blocked = matches.filter((m) => m.blocked).length; - summary.textContent = !query.value ? 'Type something to find.' - : stale ? 'Searching…' - : !matches.length ? 'No matches.' - : `${matches.length} match${matches.length === 1 ? '' : 'es'}, ${n} selected${blocked ? `, ${blocked} cannot take this replacement` : ''}.`; + summary.textContent = !query.value ? t('Type something to find.') + : stale ? t('Searching…') + : !matches.length ? t('No matches.') + : blocked ? tn(matches.length, '{count} match, {selected} selected, {blocked} cannot take this replacement.', + '{count} matches, {selected} selected, {blocked} cannot take this replacement.', { selected: n, blocked }) + : tn(matches.length, '{count} match, {selected} selected.', '{count} matches, {selected} selected.', { selected: n }); replaceButton.disabled = busy || stale || n === 0; - replaceButton.textContent = n ? `Replace ${n}` : 'Replace'; + replaceButton.textContent = n ? t('Replace {count}', { count: n }) : t('Replace'); } function renderList(truncated) { @@ -411,12 +413,12 @@ function showFindReplace({ stepNumbers = {}, find, replace } = {}) { list.append(el('label.qa-item.fr-item', { title: m.blocked || '' }, box, el('div', {}, - el('div.snippet', {}, el('b', {}, `Step ${stepNumbers[m.stepId] || m.stepIndex + 1}`), ` · ${m.where}`), + el('div.snippet', {}, el('b', {}, t('Step {number}', { number: stepNumbers[m.stepId] || m.stepIndex + 1 })), ` · ${m.where}`), el('div', {}, m.before, el('mark', {}, m.matched), m.after), m.blocked ? el('div.muted', {}, m.blocked) : null, ))); } - if (truncated) list.append(el('div.muted', { style: { padding: '8px 2px' } }, `Showing the first ${matches.length} matches. Narrow the search to see the rest.`)); + if (truncated) list.append(el('div.muted', { style: { padding: '8px 2px' } }, t('Showing the first {count} matches. Narrow the search to see the rest.', { count: matches.length }))); updateSummary(); } @@ -460,12 +462,12 @@ function showFindReplace({ stepNumbers = {}, find, replace } = {}) { resolve(true); } catch (err) { busy = false; - summary.textContent = `Could not replace: ${(err && err.message) || err}`; + summary.textContent = t('Could not replace: {error}', { error: (err && err.message) || err }); } } const { close } = openModal({ - title: 'Find and replace', + title: t('Find and replace'), wide: true, body: el('div.quick-actions', {}, query, @@ -477,10 +479,10 @@ function showFindReplace({ stepNumbers = {}, find, replace } = {}) { summary), list, el('div.muted', { style: { marginTop: '8px' } }, - 'Searches step titles and descriptions, text, code and table blocks, and annotation labels. Screenshots are not changed. Ctrl+Z undoes the whole replacement.'), + t('Searches step titles and descriptions, text, code and table blocks, and annotation labels. Screenshots are not changed. Ctrl+Z undoes the whole replacement.')), ), footer: [ - el('button', { onClick: () => { close(); resolve(false); } }, 'Close'), + el('button', { onClick: () => { close(); resolve(false); } }, t('Close')), replaceButton, ], onClose: () => resolve(false), @@ -508,35 +510,35 @@ function showFindReplace({ stepNumbers = {}, find, replace } = {}) { */ function makeUpdateCheck(api, { openLink, flushEdits = null }) { const updateStatus = el('div.update-status', { role: 'status', 'aria-live': 'polite' }); - const checkUpdatesBtn = el('button', { type: 'button' }, 'Check for updates'); + const checkUpdatesBtn = el('button', { type: 'button' }, t('Check for updates')); const updateNowButton = (result) => { - const button = el('button.primary', { type: 'button' }, 'Update now'); + const button = el('button.primary', { type: 'button' }, t('Update now')); const note = el('div.setting-desc.update-now-note', {}); button.addEventListener('click', async () => { - const ok = await confirmDialog(`StepForge will close to install version ${result.latestVersion}. Continue?`, { okLabel: 'Update now' }); + const ok = await confirmDialog(`StepForge will close to install version ${result.latestVersion}. Continue?`, { okLabel: t('Update now') }); if (!ok) return; note.textContent = ''; note.classList.remove('error'); - setButtonLoading(button, true, 'Starting…'); + setButtonLoading(button, true, t('Starting…')); let outcome; try { if (flushEdits && !await flushEdits()) { - outcome = { ok: false, message: 'Your latest changes couldn’t be saved, so the update didn’t start.' }; + outcome = { ok: false, message: t('Your latest changes couldn’t be saved, so the update didn’t start.') }; } else { outcome = await api.app.updateNow(); } } catch { - outcome = { ok: false, message: 'Couldn’t start the update. Try again, or download the new installer.' }; + outcome = { ok: false, message: t('Couldn’t start the update. Try again, or download the new installer.') }; } if (outcome && outcome.ok) { // StepForge quits on its own; keep the button off until it does. - setButtonLoading(button, true, 'Closing StepForge…'); - note.textContent = 'An administrator window will install the update and reopen StepForge.'; + setButtonLoading(button, true, t('Closing StepForge…')); + note.textContent = t('An administrator window will install the update and reopen StepForge.'); return; } setButtonLoading(button, false); - note.textContent = (outcome && outcome.message) || 'Couldn’t start the update.'; + note.textContent = (outcome && outcome.message) || t('Couldn’t start the update.'); note.classList.toggle('error', outcome?.reason !== 'declined'); }); return { button, note }; @@ -544,7 +546,7 @@ function makeUpdateCheck(api, { openLink, flushEdits = null }) { // The Store installs the update; this only opens StepForge's Store page. const storeButton = () => { - const button = el('button.primary', { type: 'button' }, 'Open Microsoft Store'); + const button = el('button.primary', { type: 'button' }, t('Open Microsoft Store')); const note = el('div.setting-desc.update-now-note', {}); button.addEventListener('click', async () => { note.textContent = ''; @@ -556,7 +558,7 @@ function makeUpdateCheck(api, { openLink, flushEdits = null }) { outcome = null; } if (!outcome || !outcome.ok) { - note.textContent = 'Couldn’t open the Microsoft Store. Open it from the Start menu, then choose Library → Get updates.'; + note.textContent = t('Couldn’t open the Microsoft Store. Open it from the Start menu, then choose Library → Get updates.'); note.classList.add('error'); } }); @@ -564,14 +566,14 @@ function makeUpdateCheck(api, { openLink, flushEdits = null }) { }; checkUpdatesBtn.addEventListener('click', async () => { - setButtonLoading(checkUpdatesBtn, true, 'Checking…'); + setButtonLoading(checkUpdatesBtn, true, t('Checking…')); clearNode(updateStatus); updateStatus.className = 'update-status'; let result; try { result = await api.app.checkForUpdates(); } catch { - result = { status: 'error', message: 'Couldn’t check for updates. Try again later.' }; + result = { status: 'error', message: t('Couldn’t check for updates. Try again later.') }; } finally { setButtonLoading(checkUpdatesBtn, false); } @@ -580,28 +582,28 @@ function makeUpdateCheck(api, { openLink, flushEdits = null }) { if (result.store) { const store = storeButton(); updateStatus.append( - el('div.update-status-title', {}, `Version ${result.latestVersion} is available`), - el('div.setting-desc', {}, `You have ${result.currentVersion}. ${result.hint || ''}`), + el('div.update-status-title', {}, t('Version {version} is available', { version: result.latestVersion })), + el('div.setting-desc', {}, `${t('You have {version}.', { version: result.currentVersion })} ${result.hint || ''}`), el('div.settings-actions', {}, store.button), store.note); return; } // With Update now, downloading stays available as the secondary action. const updateNow = result.updateNow ? updateNowButton(result) : null; - const download = el(updateNow ? 'button' : 'button.primary', { type: 'button', onClick: openLink(result.releaseUrl) }, 'View release and download'); + const download = el(updateNow ? 'button' : 'button.primary', { type: 'button', onClick: openLink(result.releaseUrl) }, t('View release and download')); updateStatus.append( - el('div.update-status-title', {}, `Version ${result.latestVersion} is available`), - el('div.setting-desc', {}, `You have ${result.currentVersion}. ${result.hint || ''}`), + el('div.update-status-title', {}, t('Version {version} is available', { version: result.latestVersion })), + el('div.setting-desc', {}, `${t('You have {version}.', { version: result.currentVersion })} ${result.hint || ''}`), el('div.settings-actions', {}, updateNow ? updateNow.button : null, download), ...(updateNow ? [updateNow.note] : [])); } else if (result.status === 'up-to-date') { updateStatus.classList.add('current'); - updateStatus.append(el('div.update-status-title', {}, 'You’re up to date'), - el('div.setting-desc', {}, `Version ${result.currentVersion} is the latest release.`)); + updateStatus.append(el('div.update-status-title', {}, t('You’re up to date')), + el('div.setting-desc', {}, t('Version {version} is the latest release.', { version: result.currentVersion }))); } else { updateStatus.classList.add('error'); - updateStatus.append(el('div.setting-desc', {}, result.message || 'Couldn’t check for updates.')); - checkUpdatesBtn.textContent = 'Try again'; + updateStatus.append(el('div.setting-desc', {}, result.message || t('Couldn’t check for updates.'))); + checkUpdatesBtn.textContent = t('Try again'); } }); return { checkUpdatesBtn, updateStatus }; @@ -624,21 +626,21 @@ function showSettingsDialog({ if (accountView) initialSection = 'accounts'; const accountsPanel = makeAccountsSettings(api, { view: accountView, onPublishToWeb }); const brandingPanel = makeBrandingSettings(api, { activeProfileId: settings.branding?.activeProfileId || '' }); - const storagePath = el('code.settings-path', {}, 'Checking guide storage location…'); + const storagePath = el('code.settings-path', {}, t('Checking guide storage location…')); const storageNote = el('div.setting-desc', {}, ''); const showStorageError = (message) => { storageNote.textContent = message; storageNote.classList.add('error'); }; - const chooseStorageBtn = el('button', { type: 'button' }, 'Choose folder…'); - const resetStorageBtn = el('button', { type: 'button' }, 'Use default'); - const cancelStorageBtn = el('button', { type: 'button', hidden: true }, 'Cancel pending move'); + const chooseStorageBtn = el('button', { type: 'button' }, t('Choose folder…')); + const resetStorageBtn = el('button', { type: 'button' }, t('Use default')); + const cancelStorageBtn = el('button', { type: 'button', hidden: true }, t('Cancel pending move')); let storageInfo = null; const refreshStorage = async () => { try { storageInfo = await api.storage.status(); storagePath.textContent = storageInfo.current; storagePath.title = storageInfo.current; - const notes = [storageInfo.current === storageInfo.defaultPath ? 'Default location.' : `Default: ${storageInfo.defaultPath}`]; - if (storageInfo.pending) notes.push(`Moving to ${storageInfo.pending} the next time StepForge starts.`); - if (storageInfo.locked) notes.push('Set by STEPFORGE_DATA_DIR for this session.'); + const notes = [storageInfo.current === storageInfo.defaultPath ? t('Default location.') : t('Default: {path}', { path: storageInfo.defaultPath })]; + if (storageInfo.pending) notes.push(t('Moving to {path} the next time StepForge starts.', { path: storageInfo.pending })); + if (storageInfo.locked) notes.push(t('Set by STEPFORGE_DATA_DIR for this session.')); if (storageInfo.error) notes.push(storageInfo.error); storageNote.textContent = notes.join(' '); storageNote.classList.toggle('error', Boolean(storageInfo.error)); @@ -646,14 +648,14 @@ function showSettingsDialog({ resetStorageBtn.disabled = storageInfo.locked || storageInfo.current === storageInfo.defaultPath; cancelStorageBtn.hidden = !storageInfo.pending; } catch (error) { - showStorageError(error.message || 'Could not read guide storage location.'); + showStorageError(error.message || t('Could not read guide storage location.')); } }; chooseStorageBtn.addEventListener('click', async () => { - try { await api.storage.choose(); await refreshStorage(); } catch (error) { showStorageError(error.message || 'Could not schedule the library move.'); } + try { await api.storage.choose(); await refreshStorage(); } catch (error) { showStorageError(error.message || t('Could not schedule the library move.')); } }); resetStorageBtn.addEventListener('click', async () => { - try { await api.storage.reset(); await refreshStorage(); } catch (error) { showStorageError(error.message || 'Could not schedule the library move.'); } + try { await api.storage.reset(); await refreshStorage(); } catch (error) { showStorageError(error.message || t('Could not schedule the library move.')); } }); cancelStorageBtn.addEventListener('click', async () => { await api.storage.cancelMove(); await refreshStorage(); }); void refreshStorage(); @@ -663,43 +665,48 @@ function showSettingsDialog({ const dataDirLabel = el('code.settings-path', {}, '—'); api.app.info().then((info) => { versionLabel.textContent = info.buildVersion || info.version; - buildLabel.textContent = info.devBuild ? 'Development build' : 'Release'; + buildLabel.textContent = info.devBuild ? t('Development build') : t('Release'); buildLabel.classList.toggle('dev', Boolean(info.devBuild)); licenseLabel.textContent = info.license || '—'; dataDirLabel.textContent = info.dataDir || '—'; dataDirLabel.title = info.dataDir || ''; - }).catch(() => { versionLabel.textContent = 'Unavailable'; }); + }).catch(() => { versionLabel.textContent = t('Unavailable'); }); const openLink = (url) => () => { void api.shell.openExternal({ url }).catch(() => {}); }; // An inline link in a setting's description; opens in the browser. const docLink = (label, url) => el('a', { href: url, onClick: (e) => { e.preventDefault(); openLink(url)(); } }, label); const { checkUpdatesBtn, updateStatus } = makeUpdateCheck(api, { openLink, flushEdits }); const appearance = makeSelect(settings.appearance || 'system', [ - { value: 'system', label: 'System' }, - { value: 'light', label: 'Light' }, - { value: 'dark', label: 'Dark' }, + { value: 'system', label: t('System') }, + { value: 'light', label: t('Light') }, + { value: 'dark', label: t('Dark') }, ]); const spellcheck = el('input', { type: 'checkbox', checked: Boolean(settings.spellcheck) }); + // Each language is listed in its own name, so anyone can find theirs. + const language = makeSelect(settings.language || 'system', [ + { value: 'system', label: t('System default') }, + ...StepForgeI18n.LANGUAGES.map((l) => ({ value: l.code, label: l.name })), + ]); const delayMs = makeInput(settings.capture?.delayMs ?? 0, 'number', { min: 0, step: 50 }); // An older settings file may still hold 'region', which recording always // treated as fullscreen. Show what actually happens. const captureMode = makeSelect(settings.capture?.mode === 'window' ? 'window' : 'fullscreen', [ - { value: 'fullscreen', label: 'Fullscreen' }, - { value: 'window', label: 'Window' }, + { value: 'fullscreen', label: t('Fullscreen') }, + { value: 'window', label: t('Window') }, ]); const smartCropping = el('input', { type: 'checkbox', checked: settings.capture?.smartCropping !== false }); const redactOnCapture = el('input', { type: 'checkbox', checked: settings.redaction?.onCapture === true }); - const redactTerms = el('textarea.redact-terms', { rows: 4, spellcheck: false, placeholder: 'One per line, for example a name or project code', - 'aria-label': 'Words to always hide' }); + const redactTerms = el('textarea.redact-terms', { rows: 4, spellcheck: false, placeholder: t('One per line, for example a name or project code'), + 'aria-label': t('Words to always hide') }); redactTerms.value = (settings.redaction?.terms || []).join('\n'); const focusValue = Number(settings.capture?.focusAmount ?? 1.25); const focusAmount = el('input', { type: 'range', min: 1, max: 2, step: 0.05, - 'aria-label': 'Default focus amount', value: Number.isFinite(focusValue) ? Math.max(1, Math.min(2, focusValue)) : 1.25 }); + 'aria-label': t('Default focus amount'), value: Number.isFinite(focusValue) ? Math.max(1, Math.min(2, focusValue)) : 1.25 }); const focusLabel = el('output', {}, `${Number(focusAmount.value).toFixed(2)}×`); focusAmount.addEventListener('input', () => { focusLabel.textContent = `${Number(focusAmount.value).toFixed(2)}×`; }); const focusControl = el('div.row.settings-range', {}, focusAmount, focusLabel, el('button', { type: 'button', onClick: () => { focusAmount.value = '1.25'; focusLabel.textContent = '1.25×'; - } }, 'Reset')); + } }, t('Reset'))); const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) }); const captureHotkey = makeHotkeyInput(settings.capture?.hotkeyCapture || ''); const pauseHotkey = makeHotkeyInput(settings.capture?.hotkeyPauseResume || ''); @@ -710,23 +717,23 @@ function showSettingsDialog({ CODE_THEME_CHOICES); const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) }); const fallbackTrigger = makeSelect(settings.capture?.fallbackTrigger === 'interval' ? 'interval' : 'hotkey', [ - { value: 'hotkey', label: 'Hotkey' }, - { value: 'interval', label: 'Timed interval' }, + { value: 'hotkey', label: t('Hotkey') }, + { value: 'interval', label: t('Timed interval') }, ]); const autoIntervalSec = makeInput(settings.capture?.autoIntervalSec ?? 5, 'number', { min: 1, step: 1 }); const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 }); const trashDays = makeSelect(String(settings.library?.trashDays ?? 30), [ - { value: '7', label: 'After 7 days' }, - { value: '30', label: 'After 30 days' }, - { value: '90', label: 'After 90 days' }, - { value: '0', label: 'Never' }, + { value: '7', label: t('After 7 days') }, + { value: '30', label: t('After 30 days') }, + { value: '90', label: t('After 90 days') }, + { value: '0', label: t('Never') }, ]); const reviewAfterDays = review.normalizeReviewDays(settings.library?.reviewAfterDays) ?? review.DEFAULT_REVIEW_DAYS; const reviewDefault = makeSelect(String(reviewAfterDays), [ - ...review.REVIEW_INTERVALS.map((i) => ({ value: String(i.days), label: `After ${i.label}` })), + ...review.REVIEW_INTERVALS.map((i) => ({ value: String(i.days), label: t('After {interval}', { interval: intervalText(i.days) }) })), ...(reviewAfterDays > 0 && !review.REVIEW_INTERVALS.some((i) => i.days === reviewAfterDays) - ? [{ value: String(reviewAfterDays), label: `After ${review.intervalLabel(reviewAfterDays)}` }] : []), - { value: '0', label: 'Never' }, + ? [{ value: String(reviewAfterDays), label: t('After {interval}', { interval: intervalText(reviewAfterDays) }) }] : []), + { value: '0', label: t('Never') }, ]); const aiEnabled = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.enabled) }); const aiAutoDoc = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.autoDoc) }); @@ -740,11 +747,11 @@ function showSettingsDialog({ const agentsScreenshots = el('input', { type: 'checkbox', checked: settings.ai?.agents?.screenshots !== false }); const agentsBranding = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.agents?.branding) }); // How Claude Code, Codex, or any MCP app starts StepForge for agents. - const agentSetup = el('div.agent-setup', {}, el('div.setting-desc', {}, 'Loading the setup commands…')); + const agentSetup = el('div.agent-setup', {}, el('div.setting-desc', {}, t('Loading the setup commands…'))); const AGENT_SETUPS = [ - ['claude', 'Claude Code', 'Run this in a terminal once.'], - ['codex', 'Codex', 'Run this in a terminal once.'], - ['json', 'Claude Desktop, Cursor, and other apps', 'Add this to the app’s MCP servers settings.'], + ['claude', 'Claude Code', t('Run this in a terminal once.')], + ['codex', 'Codex', t('Run this in a terminal once.')], + ['json', t('Claude Desktop, Cursor, and other apps'), t('Add this to the app’s MCP servers settings.')], ]; api.agents.setup().then((texts) => { agentSetup.replaceChildren(...AGENT_SETUPS.map(([kind, label, hint]) => { @@ -752,20 +759,20 @@ function showSettingsDialog({ type: 'button', onClick: async () => { await api.agents.copy({ kind }); - copy.textContent = 'Copied'; - setTimeout(() => { copy.textContent = 'Copy'; }, 1500); + copy.textContent = t('Copied'); + setTimeout(() => { copy.textContent = t('Copy'); }, 1500); }, - }, 'Copy'); + }, t('Copy')); return el('div.agent-setup-row', {}, el('div.setting-text', {}, el('div.setting-title', {}, label), el('div.setting-desc', {}, hint)), el('pre.agent-command', {}, texts[kind]), copy); })); }).catch((err) => { - agentSetup.replaceChildren(el('div.setting-desc.error', {}, `Could not build the setup commands: ${err.message || err}`)); + agentSetup.replaceChildren(el('div.setting-desc.error', {}, t('Could not build the setup commands: {error}', { error: err.message || err }))); }); - const aiStatus = el('div', { className: 'ai-status' }, 'Not tested yet. Vision-capable models can also inspect each step’s screenshot.'); - const testAiBtn = el('button', { type: 'button' }, 'Test connection'); + const aiStatus = el('div', { className: 'ai-status' }, t('Not tested yet. Vision-capable models can also inspect each step’s screenshot.')); + const testAiBtn = el('button', { type: 'button' }, t('Test connection')); const persistOllamaModel = debounce(() => { const model = ollamaModel.value.trim(); // Keep the last model choice even if the dialog is dismissed without a full save. @@ -787,8 +794,8 @@ function showSettingsDialog({ }; const testAiConnection = async () => { - setButtonLoading(testAiBtn, true, 'Testing…'); - updateAiStatus('Checking Ollama at the configured host…'); + setButtonLoading(testAiBtn, true, t('Testing…')); + updateAiStatus(t('Checking Ollama at the configured host…')); try { const result = await api.ai.test({ ollama: { @@ -797,22 +804,22 @@ function showSettingsDialog({ }, }); if (!result.ok) { - updateAiStatus(result.reason || 'Could not connect to Ollama.', { error: true }); + updateAiStatus(result.reason || t('Could not connect to Ollama.'), { error: true }); return; } ollamaModels.replaceChildren(...(result.models || []).map((model) => el('option', { value: model }))); if (result.installed) { updateAiStatus(result.vision - ? `Connected to ${result.host} with ${result.model}. It can inspect screenshots.` - : `Connected to ${result.host} with ${result.model}. This model is text-only, so StepForge will use OCR and metadata only.`, { ok: true }); + ? t('Connected to {host} with {model}. It can inspect screenshots.', { host: result.host, model: result.model }) + : t('Connected to {host} with {model}. This model is text-only, so StepForge will use OCR and metadata only.', { host: result.host, model: result.model }), { ok: true }); } else { const installed = (result.models || []).join(', '); updateAiStatus(installed - ? `Connected to ${result.host}, but ${result.model || 'that model'} isn’t installed. Installed models: ${installed}.` - : `Connected to ${result.host}, but it has no models yet. Run “ollama pull gemma3” first.`, { error: true }); + ? t('Connected to {host}, but {model} isn’t installed. Installed models: {installed}.', { host: result.host, model: result.model || t('that model'), installed }) + : t('Connected to {host}, but it has no models yet. Run “ollama pull gemma3” first.', { host: result.host }), { error: true }); } } catch (err) { - updateAiStatus(err.message || 'Could not connect to Ollama.', { error: true }); + updateAiStatus(err.message || t('Could not connect to Ollama.'), { error: true }); } finally { setButtonLoading(testAiBtn, false); } @@ -821,19 +828,19 @@ function showSettingsDialog({ ollamaModel.addEventListener('blur', () => persistOllamaModel.flush()); const placeholderHeader = el('div.global-placeholder-header.hidden', {}, - el('span', {}, 'Placeholder name'), el('span', {}, 'Content')); + el('span', {}, t('Placeholder name')), el('span', {}, t('Content'))); const placeholderRows = el('div', { className: 'placeholder-rows global-placeholder-rows' }, placeholderHeader); const rows = []; const addPlaceholderRow = (key = '', value = '') => { - const keyInput = el('textarea.global-placeholder-name', { rows: 4, 'aria-label': 'Placeholder name', placeholder: '[[placeholder-name]]' }, key); + const keyInput = el('textarea.global-placeholder-name', { rows: 4, 'aria-label': t('Placeholder name'), placeholder: '[[placeholder-name]]' }, key); keyInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') event.preventDefault(); }); keyInput.addEventListener('input', () => { keyInput.value = keyInput.value.replace(/[\r\n]/g, ''); }); - const valueInput = el('textarea.global-placeholder-value', { rows: 4, 'aria-label': 'Placeholder content', placeholder: 'Markdown or regular text' }, typeof value === 'object' ? value.text || '' : value); + const valueInput = el('textarea.global-placeholder-value', { rows: 4, 'aria-label': t('Placeholder content'), placeholder: t('Markdown or regular text') }, typeof value === 'object' ? value.text || '' : value); const valueCell = el('div.placeholder-markdown-content', {}, valueInput); const removeBtn = el('button.icon', { type: 'button', - title: 'Remove placeholder', + title: t('Remove placeholder'), onClick: () => { row.remove(); rows.splice(rows.indexOf(row), 1); @@ -858,94 +865,96 @@ function showSettingsDialog({ const addPlaceholderBtn = el('button', { type: 'button', onClick: () => addPlaceholderRow(), - }, 'Add placeholder'); + }, t('Add placeholder')); const sections = [ - { id: 'general', label: 'General', description: 'Appearance, exports, where your guides and deleted guides are kept, and when guides are due for review.', content: [ - settingsCard('Appearance', - settingRow('Theme', 'Match your system, or always use light or dark.', appearance), - settingRow('Spellcheck', 'Underline misspelled words while editing.', makeSwitch(spellcheck, 'Spellcheck'))), - settingsCard('Exports', - settingRow('Open folder after export', 'Show the exported file in your file manager.', makeSwitch(openFolder, 'Open folder after export')), - settingRow('Preview step count', 'How many steps the export preview renders.', previewCount), - settingRow('Code theme', 'Syntax colors for code blocks in PDF, Word, HTML and Confluence exports.', codeTheme)), - settingsCard('Guide storage', + { id: 'general', label: t('General'), description: t('Appearance, exports, where your guides and deleted guides are kept, and when guides are due for review.'), content: [ + settingsCard(t('Appearance'), + settingRow(t('Theme'), t('Match your system, or always use light or dark.'), appearance), + settingRow(t('Spellcheck'), t('Underline misspelled words while editing.'), makeSwitch(spellcheck, t('Spellcheck')))), + settingsCard(t('Exports'), + settingRow(t('Open folder after export'), t('Show the exported file in your file manager.'), makeSwitch(openFolder, t('Open folder after export'))), + settingRow(t('Preview step count'), t('How many steps the export preview renders.'), previewCount), + settingRow(t('Code theme'), t('Syntax colors for code blocks in PDF, Word, HTML and Confluence exports.'), codeTheme)), + settingsCard(t('Guide storage'), el('div.settings-storage', {}, storagePath, storageNote), el('div.settings-actions', {}, chooseStorageBtn, resetStorageBtn, cancelStorageBtn), - el('div.setting-desc', {}, 'Choose an empty folder. StepForge copies and verifies your library, then switches to it the next time it starts. The old library is kept as a backup.')), - settingsCard('Trash', - settingRow('Empty the trash', 'Deleted guides are removed for good after this long.', trashDays)), - settingsCard('Guide reviews', - settingRow('Flag guides for review', 'Guides that haven’t been saved or marked verified for this long show “May need review” in the library. Each guide can change this in Guide information.', reviewDefault)), + el('div.setting-desc', {}, t('Choose an empty folder. StepForge copies and verifies your library, then switches to it the next time it starts. The old library is kept as a backup.'))), + settingsCard(t('Trash'), + settingRow(t('Empty the trash'), t('Deleted guides are removed for good after this long.'), trashDays)), + settingsCard(t('Guide reviews'), + settingRow(t('Flag guides for review'), t('Guides that haven’t been saved or marked verified for this long show “May need review” in the library. Each guide can change this in Guide information.'), reviewDefault)), + settingsCard(t('Language'), + settingRow(t('Language'), t('The language of StepForge’s menus, buttons and messages. Exported guides stay in English.'), language)), ] }, - { id: 'branding', label: 'Branding', description: 'Customize how exported guides appear.', content: [brandingPanel.node] }, - { id: 'capture', label: 'Capture', description: 'How StepForge records steps.', content: [ - settingsCard('Recording', - settingRow('Default mode', 'Record the whole screen or the active window. For a region, use Capture → Capture region….', captureMode), - settingRow('Delay', 'Milliseconds to wait before each screenshot.', delayMs), - settingRow('Click marker', 'Draw a marker where you clicked.', makeSwitch(clickMarker, 'Click marker')), - settingRow('Capture outside clicks', 'Record clicks in other apps while a capture session is running.', makeSwitch(captureOutside, 'Capture outside clicks'))), - settingsCard('When clicks can’t be detected', - settingRow('Capture with', 'Some desktops don’t report clicks. Choose how steps are captured instead.', fallbackTrigger), - (intervalRow.append(settingRow('Timer interval', 'Seconds between timed captures.', autoIntervalSec)), intervalRow)), - settingsCard('Hotkeys', - settingRow('Capture', 'Take a step. Also used when clicks can’t be detected.', captureHotkey), - settingRow('Pause / resume', 'Pause or resume the capture session.', pauseHotkey)), + { id: 'branding', label: t('Branding'), description: t('Customize how exported guides appear.'), content: [brandingPanel.node] }, + { id: 'capture', label: t('Capture'), description: t('How StepForge records steps.'), content: [ + settingsCard(t('Recording'), + settingRow(t('Default mode'), t('Record the whole screen or the active window. For a region, use Capture → Capture region….'), captureMode), + settingRow(t('Delay'), t('Milliseconds to wait before each screenshot.'), delayMs), + settingRow(t('Click marker'), t('Draw a marker where you clicked.'), makeSwitch(clickMarker, t('Click marker'))), + settingRow(t('Capture outside clicks'), t('Record clicks in other apps while a capture session is running.'), makeSwitch(captureOutside, t('Capture outside clicks')))), + settingsCard(t('When clicks can’t be detected'), + settingRow(t('Capture with'), t('Some desktops don’t report clicks. Choose how steps are captured instead.'), fallbackTrigger), + (intervalRow.append(settingRow(t('Timer interval'), t('Seconds between timed captures.'), autoIntervalSec)), intervalRow)), + settingsCard(t('Hotkeys'), + settingRow(t('Capture'), t('Take a step. Also used when clicks can’t be detected.'), captureHotkey), + settingRow(t('Pause / resume'), t('Pause or resume the capture session.'), pauseHotkey)), ] }, - { id: 'editor', label: 'Editor', description: 'Defaults for new steps and automatic backups.', content: [ - settingsCard('New steps', - settingRow('Focus recorded clicks', 'Zoom new screenshots toward where you clicked.', makeSwitch(smartCropping, 'Focus recorded clicks')), - settingRow('Default focus amount', 'More context (1×) to closer focus (2×). Applies to new captures only.', focusControl), - settingRow('Focused view for new steps', 'Open new steps in focused view.', makeSwitch(focusedDefault, 'Focused view for new steps'))), - settingsCard('Backups', - settingRow('Snapshots to keep', 'Older backup snapshots of each guide are removed. 0 keeps all of them.', keepLast)), + { id: 'editor', label: t('Editor'), description: t('Defaults for new steps and automatic backups.'), content: [ + settingsCard(t('New steps'), + settingRow(t('Focus recorded clicks'), t('Zoom new screenshots toward where you clicked.'), makeSwitch(smartCropping, t('Focus recorded clicks'))), + settingRow(t('Default focus amount'), t('More context (1×) to closer focus (2×). Applies to new captures only.'), focusControl), + settingRow(t('Focused view for new steps'), t('Open new steps in focused view.'), makeSwitch(focusedDefault, t('Focused view for new steps')))), + settingsCard(t('Backups'), + settingRow(t('Snapshots to keep'), t('Older backup snapshots of each guide are removed. 0 keeps all of them.'), keepLast)), ] }, - { id: 'privacy', label: 'Privacy', description: 'Blur private details in screenshots. Everything is checked on this computer.', content: [ - settingsCard('Private details', - el('div.setting-desc', {}, 'Before a guide goes on the web, StepForge reads its screenshots and blurs email addresses, phone and card numbers, Social Security numbers, IP addresses, passwords, keys, and links with private tokens. You can review every blur and undo any that aren’t needed.'), - settingRow('Blur while recording', 'Check each new capture as soon as it’s taken, instead of waiting until you publish.', makeSwitch(redactOnCapture, 'Blur while recording')), - settingRow('Always hide these words', 'Names, project codes, or anything else to blur wherever it appears. One per line.', redactTerms)), + { id: 'privacy', label: t('Privacy'), description: t('Blur private details in screenshots. Everything is checked on this computer.'), content: [ + settingsCard(t('Private details'), + el('div.setting-desc', {}, t('Before a guide goes on the web, StepForge reads its screenshots and blurs email addresses, phone and card numbers, Social Security numbers, IP addresses, passwords, keys, and links with private tokens. You can review every blur and undo any that aren’t needed.')), + settingRow(t('Blur while recording'), t('Check each new capture as soon as it’s taken, instead of waiting until you publish.'), makeSwitch(redactOnCapture, t('Blur while recording'))), + settingRow(t('Always hide these words'), t('Names, project codes, or anything else to blur wherever it appears. One per line.'), redactTerms)), ] }, - { id: 'ai', label: 'AI', description: 'Let AI write your guides: a model on your own computer through Ollama, or an AI agent such as Claude or Codex.', content: [ + { id: 'ai', label: t('AI'), description: t('Let AI write your guides: a model on your own computer through Ollama, or an AI agent such as Claude or Codex.'), content: [ settingsCard(null, - settingRow('Enable AI', 'Turn on the AI ▾ menu in the guide toolbar: write a whole guide, a step, or one field.', makeSwitch(aiEnabled, 'Enable AI')), - settingRow('Auto-document captures', 'Write a title and description for each new capture as you record. Turn off to use AI only when you ask.', makeSwitch(aiAutoDoc, 'Auto-document captures')), - settingRow('Let the model see screenshots', 'Models that can read images see each step’s screenshot, with blurred areas already hidden. Turn off to send text only.', makeSwitch(aiScreenshots, 'Let the model see screenshots'))), - settingsCard('Ollama', - el('div.setting-desc', {}, 'Runs on your computer, so your screenshots and text stay here.'), - settingRow('Host', 'Where Ollama is running.', ollamaHost), - settingRow('Model', el('span', {}, - 'Any installed model. Choose Test connection to list them. See the ', - docLink('AI guide', AI_GUIDE_URL), ' and ', docLink('recommended models', `${AI_GUIDE_URL}#recommended-models`), '.'), + settingRow(t('Enable AI'), t('Turn on the AI ▾ menu in the guide toolbar: write a whole guide, a step, or one field.'), makeSwitch(aiEnabled, t('Enable AI'))), + settingRow(t('Auto-document captures'), t('Write a title and description for each new capture as you record. Turn off to use AI only when you ask.'), makeSwitch(aiAutoDoc, t('Auto-document captures'))), + settingRow(t('Let the model see screenshots'), t('Models that can read images see each step’s screenshot, with blurred areas already hidden. Turn off to send text only.'), makeSwitch(aiScreenshots, t('Let the model see screenshots')))), + settingsCard(t('Ollama'), + el('div.setting-desc', {}, t('Runs on your computer, so your screenshots and text stay here.')), + settingRow(t('Host'), t('Where Ollama is running.'), ollamaHost), + settingRow(t('Model'), el('span', {}, + t('Any installed model. Choose Test connection to list them.'), ' ', + docLink(t('AI guide'), AI_GUIDE_URL), ' · ', docLink(t('Recommended models'), `${AI_GUIDE_URL}#recommended-models`)), ollamaModel), el('div.settings-test', {}, aiStatus, testAiBtn, ollamaModels)), - settingsCard('AI agents (Claude, Codex)', - el('div.setting-desc', {}, 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.'), - settingRow('Let AI agents edit your guides', 'Turn off to cut off every connected agent at once.', makeSwitch(agentsEnabled, 'Let AI agents edit your guides')), - settingRow('Agents can see screenshots', 'Blurred areas are filled in first. Turn off to share step text only.', makeSwitch(agentsScreenshots, 'Agents can see screenshots')), - settingRow('Let AI agents edit brand profiles', 'Agents can make and change brand profiles: logo, colors, fonts and CSS, and choose the one exports use. They can’t delete a profile.', makeSwitch(agentsBranding, 'Let AI agents edit brand profiles')), + settingsCard(t('AI agents (Claude, Codex)'), + el('div.setting-desc', {}, t('AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.')), + settingRow(t('Let AI agents edit your guides'), t('Turn off to cut off every connected agent at once.'), makeSwitch(agentsEnabled, t('Let AI agents edit your guides'))), + settingRow(t('Agents can see screenshots'), t('Blurred areas are filled in first. Turn off to share step text only.'), makeSwitch(agentsScreenshots, t('Agents can see screenshots'))), + settingRow(t('Let AI agents edit brand profiles'), t('Agents can make and change brand profiles: logo, colors, fonts and CSS, and choose the one exports use. They can’t delete a profile.'), makeSwitch(agentsBranding, t('Let AI agents edit brand profiles'))), agentSetup), ] }, - { id: 'accounts', label: 'Accounts', description: 'Sync guides with Google Drive, OneDrive, Dropbox or Nextcloud, share them on the web with GitHub, or publish them to Confluence or Wiki.js.', content: [accountsPanel.node] }, - { id: 'placeholders', label: 'Placeholders', description: 'Reusable text for every guide. Type [[name]] in a guide to insert it.', content: [ + { id: 'accounts', label: t('Accounts'), description: t('Sync guides with Google Drive, OneDrive, Dropbox or Nextcloud, share them on the web with GitHub, or publish them to Confluence or Wiki.js.'), content: [accountsPanel.node] }, + { id: 'placeholders', label: t('Placeholders'), description: t('Reusable text for every guide. Type [[name]] in a guide to insert it.'), content: [ settingsCard(null, placeholderRows, el('div.settings-actions', {}, addPlaceholderBtn)), ] }, - { id: 'about', label: 'About', description: '', content: [ + { id: 'about', label: t('About'), description: '', content: [ el('div.settings-about', {}, el('img.settings-about-mark', { src: '../assets/stepforge.png', alt: '', width: 56, height: 56 }), el('div', {}, el('div.settings-about-name', {}, 'StepForge'), - el('div.setting-desc', {}, 'Local-first step-by-step guides.'))), + el('div.setting-desc', {}, t('Local-first step-by-step guides.')))), settingsCard(null, - settingRow('Version', null, el('div.row', {}, versionLabel, buildLabel)), - settingRow('License', null, licenseLabel), - settingRow('Data folder', null, dataDirLabel)), + settingRow(t('Version'), null, el('div.row', {}, versionLabel, buildLabel)), + settingRow(t('License'), null, licenseLabel), + settingRow(t('Data folder'), null, dataDirLabel)), settingsCard(null, - settingRow('Updates', 'Checks GitHub for a newer release. StepForge only connects when you press the button.', checkUpdatesBtn), + settingRow(t('Updates'), t('Checks GitHub for a newer release. StepForge only connects when you press the button.'), checkUpdatesBtn), updateStatus), el('div.settings-actions', {}, - el('button', { type: 'button', onClick: openLink('https://github.com/Twest2/StepForge') }, 'Project on GitHub'), - el('button', { type: 'button', onClick: openLink('https://github.com/Twest2/StepForge/releases') }, 'Release notes'), - el('button', { type: 'button', onClick: openLink('https://github.com/Twest2/StepForge/issues') }, 'Report an issue')), + el('button', { type: 'button', onClick: openLink('https://github.com/Twest2/StepForge') }, t('Project on GitHub')), + el('button', { type: 'button', onClick: openLink('https://github.com/Twest2/StepForge/releases') }, t('Release notes')), + el('button', { type: 'button', onClick: openLink('https://github.com/Twest2/StepForge/issues') }, t('Report an issue'))), ] }, ]; const navButtons = new Map(); @@ -958,7 +967,7 @@ function showSettingsDialog({ for (const [key, pane] of panes) pane.hidden = key !== id; content.scrollTop = 0; }; - const nav = el('nav.settings-nav', { role: 'tablist', 'aria-label': 'Settings sections' }); + const nav = el('nav.settings-nav', { role: 'tablist', 'aria-label': t('Settings sections') }); const content = el('div.settings-content'); for (const item of sections) { const button = el('button.settings-nav-item', { type: 'button', role: 'tab', onClick: () => { @@ -981,17 +990,18 @@ function showSettingsDialog({ showSection(navButtons.has(initialSection) ? initialSection : 'general'); const { close } = openModal({ - title: 'Settings', + title: t('Settings'), body: form, wide: true, footer: [ - el('button', { type: 'button', onClick: () => { accountsPanel.dispose(); close(); resolve(false); } }, 'Cancel'), + el('button', { type: 'button', onClick: () => { accountsPanel.dispose(); close(); resolve(false); } }, t('Cancel')), el('button.primary', { type: 'submit', onClick: async (e) => { e.preventDefault(); const next = { appearance: appearance.value, + language: language.value, spellcheck: spellcheck.checked, capture: { ...settings.capture, @@ -1064,7 +1074,7 @@ function showSettingsDialog({ close(); resolve(true); }, - }, 'Save'), + }, t('Save')), ], onClose: () => { accountsPanel.dispose(); resolve(false); }, }); @@ -1075,7 +1085,7 @@ function showSettingsDialog({ updateAiStatus( aiEnabled.checked ? 'Save, then use the AI ▾ menu in a guide. Test connection checks that Ollama is ready.' - : 'AI is off. These settings are kept for later.', + : t('AI is off. These settings are kept for later.'), ); }); }); @@ -1116,17 +1126,17 @@ function showExportDialog({ // A list option can only hold text, so the badge goes in its label too. const formatSelect = makeSelect(defaultFormat, formatOptions.map((f) => ({ ...f, label: f.badge ? `${f.label} (${f.badge})` : f.label }))); const formatDesc = el('div.muted.export-format-desc'); - const templateSelect = makeSelect('', [{ value: '', label: 'Default template' }]); - const outDirInput = makeInput(defaultOutDir, 'text', { placeholder: 'Choose an output folder' }); + const templateSelect = makeSelect('', [{ value: '', label: t('Default template') }]); + const outDirInput = makeInput(defaultOutDir, 'text', { placeholder: t('Choose an output folder') }); const optionsHost = el('div', { className: 'export-options' }); // Branding: the active profile unless another (or none) is picked for this export. const brandProfiles = branding?.profiles || []; const brandChoice = makeSelect(brandProfiles.some((p) => p.id === branding?.activeId) ? branding.activeId : '', - [{ value: '', label: 'No branding' }, ...brandProfiles.map((p) => ({ value: p.id, label: p.name }))]); + [{ value: '', label: t('No branding') }, ...brandProfiles.map((p) => ({ value: p.id, label: p.name }))]); brandChoice.setAttribute('aria-label', 'Branding'); const brandNote = el('span.muted'); const refreshBrandNote = () => { - brandNote.textContent = formatSelect.value === 'json' ? 'JSON exports keep the raw data and aren’t branded.' : 'Change the default in Settings → Branding.'; + brandNote.textContent = formatSelect.value === 'json' ? t('JSON exports keep the raw data and aren’t branded.') : t('Change the default in Settings → Branding.'); }; // The effective option set shown to (and edited by) the user. @@ -1168,14 +1178,14 @@ function showExportDialog({ return input; } if (info.type === 'file') { - const input = makeInput(value, 'text', { readOnly: true, placeholder: 'None' }); + const input = makeInput(value, 'text', { readOnly: true, placeholder: t('None') }); const clear = el('button', { type: 'button', disabled: !value, - onClick: () => { input.value = ''; clear.disabled = true; set(''); } }, 'Clear'); + onClick: () => { input.value = ''; clear.disabled = true; set(''); } }, t('Clear')); const choose = el('button', { type: 'button', disabled: typeof onChooseImage !== 'function', onClick: async () => { const picked = await onChooseImage?.(); if (picked) { input.value = picked; clear.disabled = false; set(picked); } - } }, 'Choose…'); + } }, t('Choose…')); return el('div.row', {}, input, choose, clear); } const input = makeInput(value, 'text', { placeholder: info.placeholder || '' }); @@ -1190,7 +1200,7 @@ function showExportDialog({ .filter(([key, v]) => ['boolean', 'number', 'string'].includes(typeof v) && !meta[key]?.hidden) .filter(([key]) => !meta[key]?.dependsOn || (current[meta[key].dependsOn] ?? defaults[meta[key].dependsOn])); if (!entries.length) { - optionsHost.append(el('div.muted', {}, 'This format has no adjustable options.')); + optionsHost.append(el('div.muted', {}, t('This format has no adjustable options.'))); return; } for (const [key, defVal] of entries) { @@ -1212,7 +1222,7 @@ function showExportDialog({ function refreshTemplates(list = templatesByFormat[formatSelect.value] || []) { clearNode(templateSelect); - templateSelect.append(el('option', { value: '' }, 'Default template')); + templateSelect.append(el('option', { value: '' }, t('Default template'))); for (const name of list) templateSelect.append(el('option', { value: name }, name)); } @@ -1225,18 +1235,18 @@ function showExportDialog({ // site (Settings → Accounts → GitHub), using the options chosen here. const publishInput = el('input', { type: 'checkbox', disabled: !webPublish?.ready }); const publishDays = makeSelect(String(webPublish?.defaultExpiryDays || 7), - (webPublish?.expiryDays || []).map((days) => ({ value: String(days), label: days === 1 ? '1 day' : `${days} days` }))); - const publishDaysRow = settingRow('Keep it online for', 'The guide is removed from the web after this.', publishDays); + (webPublish?.expiryDays || []).map((days) => ({ value: String(days), label: tn(days, '{count} day', '{count} days') }))); + const publishDaysRow = settingRow(t('Keep it online for'), t('The guide is removed from the web after this.'), publishDays); const publishWarning = el('p.gh-note', { role: 'note' }, - el('strong', {}, 'Published guides are public. '), - 'Anyone with the link can open it, and anyone who looks at your GitHub repository can find it. Blur passwords and other private details before you export.'); + el('strong', {}, t('Published guides are public.')), ' ', + t('Anyone with the link can open it, and anyone who looks at your GitHub repository can find it. Blur passwords and other private details before you export.')); const publishBox = el('fieldset.export-publish', {}, - el('legend', {}, 'Publish on the web'), - settingRow('Publish on the web', + el('legend', {}, t('Publish on the web')), + settingRow(t('Publish on the web'), webPublish?.ready - ? 'After exporting, also put this guide on your GitHub Pages site and get a link to share.' - : 'Connect GitHub in Settings → Accounts → GitHub to publish guides on the web.', - makeSwitch(publishInput, 'Publish on the web')), + ? t('After exporting, also put this guide on your GitHub Pages site and get a link to share.') + : t('Connect GitHub in Settings → Accounts → GitHub to publish guides on the web.'), + makeSwitch(publishInput, t('Publish on the web'))), publishDaysRow, publishWarning); const publishing = () => Boolean(webPublish?.ready && publishInput.checked && (webPublish.formats || []).includes(formatSelect.value)); @@ -1245,7 +1255,7 @@ function showExportDialog({ publishDaysRow.hidden = !publishing(); publishWarning.hidden = !publishing(); // Runs only after the footer buttons exist; leaves a busy button alone. - if (!exportBtn.disabled) exportBtn.textContent = publishing() ? 'Export and publish' : 'Export'; + if (!exportBtn.disabled) exportBtn.textContent = publishing() ? t('Export and publish') : t('Export'); } publishInput.addEventListener('change', () => refreshPublish()); @@ -1257,10 +1267,10 @@ function showExportDialog({ const site = publishTargets[formatSelect.value]; publishInsteadNote.hidden = !site || typeof onPublishInstead !== 'function'; if (publishInsteadNote.hidden) return; - publishInsteadBtn.textContent = `Publish to ${site}…`; + publishInsteadBtn.textContent = t('Publish to {site}…', { site }); publishInsteadNote.replaceChildren( - el('span', {}, el('strong', {}, `Publishing to ${site} is easier. `), - `Share → Publish to ${site}… creates the page for you, uploads its screenshots and updates the same page when you publish again. Export only if you’d rather upload the files yourself.`), + el('span', {}, el('strong', {}, t('Publishing to {site} is easier.', { site })), ' ', + t('Share → Publish to {site}… creates the page for you, uploads its screenshots and updates the same page when you publish again. Export only if you’d rather upload the files yourself.', { site })), publishInsteadBtn); } publishInsteadBtn.addEventListener('click', () => { @@ -1292,14 +1302,14 @@ function showExportDialog({ const saveTplBtn = el('button', { type: 'button', onClick: async () => { - const name = await promptText({ title: 'Save template', label: 'Template name', value: templateSelect.value || '' }); + const name = await promptText({ title: t('Save template'), label: t('Template name'), value: templateSelect.value || '' }); if (!name || !name.trim()) return; await onSaveTemplate?.(formatSelect.value, name.trim(), effectiveOptions()); const list = await onManageTemplates?.(formatSelect.value, null); refreshTemplates(list || []); templateSelect.value = name.trim(); }, - }, 'Save as template…'); + }, t('Save as template…')); const manageBtn = el('button', { type: 'button', @@ -1308,14 +1318,14 @@ function showExportDialog({ refreshTemplates(list || []); refreshOptions(); }, - }, 'Manage…'); + }, t('Manage…')); const body = el('div.export-dialog', {}, qualityPanel(quality, (finding) => { close(); resolve(false); onShowFinding?.(finding); }), - labeledRow('Format', el('div.export-format', {}, formatSelect, formatDesc, publishInsteadNote)), - labeledRow('Template', el('div.row', {}, templateSelect, saveTplBtn, manageBtn)), - branding ? labeledRow('Branding', el('div.row', {}, brandChoice, brandNote)) : null, - labeledRow('Output folder', el('div.row', {}, outDirInput, el('button', { + labeledRow(t('Format'), el('div.export-format', {}, formatSelect, formatDesc, publishInsteadNote)), + labeledRow(t('Template'), el('div.row', {}, templateSelect, saveTplBtn, manageBtn)), + branding ? labeledRow(t('Branding'), el('div.row', {}, brandChoice, brandNote)) : null, + labeledRow(t('Output folder'), el('div.row', {}, outDirInput, el('button', { type: 'button', disabled: typeof onChooseDir !== 'function', onClick: async () => { @@ -1323,8 +1333,8 @@ function showExportDialog({ const chosen = await onChooseDir(formatSelect.value); if (chosen) outDirInput.value = chosen; }, - }, 'Choose…'))), - el('fieldset', {}, el('legend', {}, 'Options'), optionsHost), + }, t('Choose…')))), + el('fieldset', {}, el('legend', {}, t('Options')), optionsHost), publishBox, ); @@ -1337,24 +1347,24 @@ function showExportDialog({ brandProfileId: branding ? brandChoice.value : undefined, }); - const cancelBtn = el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'); + const cancelBtn = el('button', { onClick: () => { close(); resolve(false); } }, t('Cancel')); const previewBtn = el('button', { onClick: async () => { if (typeof onPreview !== 'function') return; - setButtonLoading(previewBtn, true, 'Preview…'); + setButtonLoading(previewBtn, true, t('Preview…')); try { await onPreview(payload()); // keep dialog open so settings can be tweaked } finally { setButtonLoading(previewBtn, false); } }, - }, 'Preview'); + }, t('Preview')); const exportBtn = el('button.primary', { onClick: async () => { if (typeof onExport !== 'function') return; cancelBtn.disabled = true; previewBtn.disabled = true; - setButtonLoading(exportBtn, true, 'Exporting…'); + setButtonLoading(exportBtn, true, t('Exporting…')); try { const ok = await onExport(payload()); if (ok !== false) { @@ -1369,11 +1379,11 @@ function showExportDialog({ refreshPublish(); } }, - }, 'Export'); + }, t('Export')); refreshPublish(); const { close } = openModal({ - title: 'Export', + title: t('Export'), body, footer: [cancelBtn, previewBtn, exportBtn], wide: true, @@ -1391,7 +1401,7 @@ function showTemplateManager({ format, names = [], onRename, onDuplicate, onDele function renderRows() { clearNode(rows); if (!list.length) { - rows.append(el('div.muted', {}, 'No templates saved for this format yet.')); + rows.append(el('div.muted', {}, t('No templates saved for this format yet.'))); return; } for (const name of list) { @@ -1401,16 +1411,16 @@ function showTemplateManager({ format, names = [], onRename, onDuplicate, onDele el('button', { type: 'button', onClick: async () => { - const next = await promptText({ title: 'Rename template', label: 'Name', value: name }); + const next = await promptText({ title: t('Rename template'), label: t('Name'), value: name }); if (next && next.trim() && next.trim() !== name) { list = await onRename(name, next.trim()); renderRows(); } }, - }, 'Rename'), - el('button', { type: 'button', onClick: async () => { list = await onDuplicate(name); renderRows(); } }, 'Duplicate'), - el('button', { type: 'button', onClick: async () => { await onExport(name); } }, 'Share…'), - el('button.danger', { type: 'button', onClick: async () => { list = await onDelete(name); renderRows(); } }, 'Delete'), + }, t('Rename')), + el('button', { type: 'button', onClick: async () => { list = await onDuplicate(name); renderRows(); } }, t('Duplicate')), + el('button', { type: 'button', onClick: async () => { await onExport(name); } }, t('Share…')), + el('button.danger', { type: 'button', onClick: async () => { list = await onDelete(name); renderRows(); } }, t('Delete')), ), )); } @@ -1420,12 +1430,12 @@ function showTemplateManager({ format, names = [], onRename, onDuplicate, onDele title: `Templates — ${format}`, body: el('div', {}, el('div.row', { style: { marginBottom: '10px' } }, - el('button', { type: 'button', onClick: async () => { list = await onImport(); renderRows(); } }, 'Import .sfglt…'), - el('span.muted', {}, 'Templates are shareable as .sfglt files.'), + el('button', { type: 'button', onClick: async () => { list = await onImport(); renderRows(); } }, t('Import .sfglt…')), + el('span.muted', {}, t('Templates are shareable as .sfglt files.')), ), rows, ), - footer: [el('button.primary', { onClick: () => { close(); resolve(list); } }, 'Done')], + footer: [el('button.primary', { onClick: () => { close(); resolve(list); } }, t('Done'))], wide: true, onClose: () => resolve(list), }); @@ -1439,42 +1449,42 @@ function showLinkedGuideDialog({ guide, lock, onSave, onForceSave, onOpenArchive const conflict = lock && !lock.acquired; const conflictInfo = lock && lock.conflict ? lock.conflict : {}; const lockInfo = conflict - ? `Locked by ${conflictInfo.user || 'another user'}@${conflictInfo.host || 'another host'}` - : 'No active conflict'; + ? t('Locked by {user}@{host}', { user: conflictInfo.user || t('another user'), host: conflictInfo.host || t('another host') }) + : t('No active conflict'); const body = el('div', { className: 'linked-guide' }, el('div', { className: 'card-list' }, - el('div.row', {}, el('span.muted', {}, 'Archive'), el('strong', {}, linked.path || 'Not linked')), - el('div.row', {}, el('span.muted', {}, 'Opened'), el('span', {}, fmtDate(linked.openedAt) || 'Unknown')), - el('div.row', {}, el('span.muted', {}, 'Last saved'), el('span', {}, fmtDate(linked.lastSavedAt) || 'Never')), - el('div.row', {}, el('span.muted', {}, 'Lock'), el('span', {}, lockInfo)), + el('div.row', {}, el('span.muted', {}, t('Archive')), el('strong', {}, linked.path || t('Not linked'))), + el('div.row', {}, el('span.muted', {}, t('Opened')), el('span', {}, fmtDate(linked.openedAt) || t('Unknown'))), + el('div.row', {}, el('span.muted', {}, t('Last saved')), el('span', {}, fmtDate(linked.lastSavedAt) || t('Never'))), + el('div.row', {}, el('span.muted', {}, t('Lock')), el('span', {}, lockInfo)), ), - conflict ? el('div', { className: 'warn-banner' }, 'Another editor has the archive locked. You can force-save if you intend to overwrite it.') : null, + conflict ? el('div', { className: 'warn-banner' }, t('Another editor has the archive locked. You can force-save if you intend to overwrite it.')) : null, ); const { close } = openModal({ - title: 'Linked Guide', + title: t('Linked Guide'), body, footer: [ - el('button', { onClick: () => { close(); resolve(false); } }, 'Close'), + el('button', { onClick: () => { close(); resolve(false); } }, t('Close')), el('button', { onClick: async () => { await onOpenArchive?.(guide); }, - }, 'Show file'), + }, t('Show file')), conflict ? el('button.primary', { onClick: async () => { await onForceSave?.(guide); close(); resolve(true); }, - }, 'Force save') : el('button.primary', { + }, t('Force save')) : el('button.primary', { onClick: async () => { await onSave?.(guide); close(); resolve(true); }, - }, 'Save now'), + }, t('Save now')), ], wide: true, onClose: () => resolve(false), @@ -1487,7 +1497,7 @@ function snapshotLabel(name) { const match = name.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z(?:-(.+?))?\.(?:zip|snapshot)$/); if (!match) return name; const [, day, h, m, sec, ms, label] = match; - const when = new Date(`${day}T${h}:${m}:${sec}.${ms}Z`).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); + const when = new Date(`${day}T${h}:${m}:${sec}.${ms}Z`).toLocaleString(dateLocale, { dateStyle: 'medium', timeStyle: 'short' }); return label ? `${when} · ${label.replace(/_/g, ' ')}` : when; } @@ -1499,15 +1509,15 @@ function showBackupsDialog({ snapshots = [], onCreate, onRestore, onCompare } = function renderRows() { clearNode(rows); if (!list.length) { - rows.append(el('div.muted', {}, 'No snapshots yet. Automatic snapshots are created as you work; create one manually any time.')); + rows.append(el('div.muted', {}, t('No snapshots yet. Automatic snapshots are created as you work; create one manually any time.'))); return; } for (const name of list) { const compareBtn = onCompare ? el('button', { type: 'button', - title: 'See what changed between this snapshot and the guide now', + title: t('See what changed between this snapshot and the guide now'), onClick: async () => { - setButtonLoading(compareBtn, true, 'Comparing…'); + setButtonLoading(compareBtn, true, t('Comparing…')); let restored = false; try { restored = await onCompare(name); @@ -1516,7 +1526,7 @@ function showBackupsDialog({ snapshots = [], onCreate, onRestore, onCompare } = } if (restored) { close(); resolve(true); } }, - }, 'Compare') : null; + }, t('Compare')) : null; rows.append(el('div.row', { style: { justifyContent: 'space-between', gap: '10px' } }, el('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: '1' }, title: name }, snapshotLabel(name)), compareBtn, @@ -1526,7 +1536,7 @@ function showBackupsDialog({ snapshots = [], onCreate, onRestore, onCompare } = const restored = await onRestore?.(name); if (restored) close(); }, - }, 'Restore'), + }, t('Restore')), )); } } @@ -1540,16 +1550,16 @@ function showBackupsDialog({ snapshots = [], onCreate, onRestore, onCompare } = renderRows(); } }, - }, 'Create snapshot'); + }, t('Create snapshot')); const { close } = openModal({ - title: 'Backups & snapshots', + title: t('Backups & snapshots'), body: el('div', {}, el('div.row', { style: { marginBottom: '10px' } }, createBtn, - el('span.muted', {}, 'Compare shows what changed since a snapshot. Restores are undoable — the current state is snapshotted first.')), + el('span.muted', {}, t('Compare shows what changed since a snapshot. Restores are undoable — the current state is snapshotted first.'))), rows, ), - footer: [el('button', { onClick: () => { close(); resolve(true); } }, 'Close')], + footer: [el('button', { onClick: () => { close(); resolve(true); } }, t('Close'))], wide: true, onClose: () => resolve(true), }); @@ -1562,11 +1572,11 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {} const rowsHost = el('div', { className: 'placeholder-rows' }); const rows = []; const addRow = (key = '', value = '') => { - const keyInput = makeInput(key, 'text', { placeholder: 'Name' }); - const valueInput = makeInput(value, 'text', { placeholder: 'Value' }); + const keyInput = makeInput(key, 'text', { placeholder: t('Name') }); + const valueInput = makeInput(value, 'text', { placeholder: t('Value') }); const row = el('div.placeholder-row', {}, keyInput, valueInput, el('button.icon', { - type: 'button', title: 'Remove', + type: 'button', title: t('Remove'), onClick: () => { row.remove(); rows.splice(rows.indexOf(row), 1); }, }, '−')); rows.push(row); @@ -1581,10 +1591,10 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {} hint ? el('div.muted', { style: { marginBottom: '10px' } }, hint) : null, rowsHost, el('div.row', { style: { marginTop: '8px' } }, - el('button', { type: 'button', onClick: () => addRow() }, 'Add placeholder')), + el('button', { type: 'button', onClick: () => addRow() }, t('Add placeholder'))), ), footer: [ - el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'), + el('button', { onClick: () => { close(); resolve(false); } }, t('Cancel')), el('button.primary', { onClick: async () => { const next = rows.reduce((acc, row) => { @@ -1597,7 +1607,7 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {} close(); resolve(true); }, - }, 'Save'), + }, t('Save')), ], onClose: () => resolve(false), }); @@ -1611,17 +1621,17 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {} */ function showGuideInfoDialog({ values = {}, reviewDefaultDays, onSave, onCloudSnapshots, onDeleteCloudSnapshots } = {}) { return new Promise((resolve) => { - const versionInput = makeInput(values.softwareVersion || '', 'text', { placeholder: 'e.g. Windows 11 24H2', maxLength: 200 }); + const versionInput = makeInput(values.softwareVersion || '', 'text', { placeholder: t('e.g. Windows 11 24H2'), maxLength: 200 }); const verifiedInput = makeInput(review.normalizeDateOnly(values.lastVerified), 'date', { max: review.toDateOnly(Date.now()) }); const reviewDays = review.normalizeReviewDays(values.reviewAfterDays); const defaultDays = review.normalizeReviewDays(reviewDefaultDays) ?? review.DEFAULT_REVIEW_DAYS; const reviewSelect = makeSelect(reviewDays === null ? '' : String(reviewDays), [ - { value: '', label: defaultDays > 0 ? `Use the default (${review.intervalLabel(defaultDays)})` : 'Use the default (off)' }, - ...review.REVIEW_INTERVALS.map((i) => ({ value: String(i.days), label: `After ${i.label}` })), + { value: '', label: defaultDays > 0 ? t('Use the default ({interval})', { interval: intervalText(defaultDays) }) : t('Use the default (off)') }, + ...review.REVIEW_INTERVALS.map((i) => ({ value: String(i.days), label: t('After {interval}', { interval: intervalText(i.days) }) })), // A custom interval set elsewhere (an agent, an older import) stays selectable. ...(reviewDays > 0 && !review.REVIEW_INTERVALS.some((i) => i.days === reviewDays) - ? [{ value: String(reviewDays), label: `After ${review.intervalLabel(reviewDays)}` }] : []), - { value: '0', label: 'Never' }, + ? [{ value: String(reviewDays), label: t('After {interval}', { interval: intervalText(reviewDays) }) }] : []), + { value: '0', label: t('Never') }, ]); const reviewHint = el('div.muted', { style: { marginTop: '-4px', marginBottom: '12px' } }); const readReviewDays = () => (reviewSelect.value === '' ? null : Number(reviewSelect.value)); @@ -1632,49 +1642,50 @@ function showGuideInfoDialog({ values = {}, reviewDefaultDays, onSave, onCloudSn metadata: { lastVerified: verifiedInput.value, reviewAfterDays: readReviewDays() }, }, defaultDays); reviewHint.textContent = status - ? `After you save, “May need review” shows in the library on ${new Date(status.due).toLocaleDateString(undefined, { dateStyle: 'medium' })} unless the guide is saved or verified again first.` - : 'This guide is never flagged for review.'; + ? t('After you save, “May need review” shows in the library on {date} unless the guide is saved or verified again first.', + { date: new Date(status.due).toLocaleDateString(dateLocale, { dateStyle: 'medium' }) }) + : t('This guide is never flagged for review.'); }; const todayBtn = el('button', { type: 'button', onClick: () => { verifiedInput.value = review.toDateOnly(Date.now()); updateReviewHint(); }, - }, 'Today'); + }, t('Today')); verifiedInput.addEventListener('input', updateReviewHint); reviewSelect.addEventListener('change', updateReviewHint); updateReviewHint(); - const authorInput = makeInput(values.author || '', 'text', { placeholder: 'e.g. Jane Doe' }); - const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: 'e.g. Alex Lee, Sam Patel' }); - const organizationInput = makeInput(values.organization || '', 'text', { placeholder: 'e.g. Acme Corp' }); + const authorInput = makeInput(values.author || '', 'text', { placeholder: t('e.g. Jane Doe') }); + const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: t('e.g. Alex Lee, Sam Patel') }); + const organizationInput = makeInput(values.organization || '', 'text', { placeholder: t('e.g. Acme Corp') }); const descriptionInput = el('textarea', { rows: 4, - placeholder: 'A short summary of this guide.', + placeholder: t('A short summary of this guide.'), }, values.description || ''); const shareInput = el('input', { type: 'checkbox', checked: values.sharingEnabled !== false }); const { close } = openModal({ - title: 'Guide information', + title: t('Guide information'), body: el('div', {}, el('div.muted', { style: { marginBottom: '10px' } }, - 'All fields are optional. They appear below the title on the guide and on the PDF cover page.'), - labeledRow('Author', authorInput), - labeledRow('Co-authors', coAuthorsInput), - labeledRow('Organization', organizationInput), - labeledRow('Description', descriptionInput, { stacked: true }), + t('All fields are optional. They appear below the title on the guide and on the PDF cover page.')), + labeledRow(t('Author'), authorInput), + labeledRow(t('Co-authors'), coAuthorsInput), + labeledRow(t('Organization'), organizationInput), + labeledRow(t('Description'), descriptionInput, { stacked: true }), el('div.muted', { style: { marginTop: '-4px' } }, - 'Shown on the first page of the PDF and at the top of other export formats.'), - el('h3', { style: { margin: '14px 0 6px' } }, 'Review'), - labeledRow('Software version', versionInput), - labeledRow('Last verified', el('div.row', {}, verifiedInput, todayBtn)), - labeledRow('Review again', reviewSelect), + t('Shown on the first page of the PDF and at the top of other export formats.')), + el('h3', { style: { margin: '14px 0 6px' } }, t('Review')), + labeledRow(t('Software version'), versionInput), + labeledRow(t('Last verified'), el('div.row', {}, verifiedInput, todayBtn)), + labeledRow(t('Review again'), reviewSelect), reviewHint, - el('label.cloud-enable', {}, shareInput, ' Include this guide in cloud sync'), + el('label.cloud-enable', {}, shareInput, ' ', t('Include this guide in cloud sync')), el('div.muted', { style: { marginTop: '-4px' } }, - 'When off, this guide stays only on this device.'), + t('When off, this guide stays only on this device.')), ), footer: [ - el('button', { type: 'button', onClick: () => onCloudSnapshots?.() }, 'Cloud snapshots'), - el('button', { type: 'button', className: 'danger', onClick: () => onDeleteCloudSnapshots?.() }, 'Remove cloud copies'), - el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'), + el('button', { type: 'button', onClick: () => onCloudSnapshots?.() }, t('Cloud snapshots')), + el('button', { type: 'button', className: 'danger', onClick: () => onDeleteCloudSnapshots?.() }, t('Remove cloud copies')), + el('button', { onClick: () => { close(); resolve(false); } }, t('Cancel')), el('button.primary', { onClick: async () => { await onSave?.({ @@ -1690,7 +1701,7 @@ function showGuideInfoDialog({ values = {}, reviewDefaultDays, onSave, onCloudSn close(); resolve(true); }, - }, 'Save'), + }, t('Save')), ], onClose: () => resolve(false), }); @@ -1701,47 +1712,47 @@ function showCloudSnapshotsDialog({ snapshots = [], onRestore } = {}) { return new Promise((resolve) => { const list = snapshots.length ? el('div', {}, ...snapshots.map((snapshot, index) => el('div.form-row', {}, - el('div', {}, index === 0 ? 'Current cloud snapshot' : `Previous snapshot ${index}`, snapshot.createdTime ? el('div.muted', {}, new Date(snapshot.createdTime).toLocaleString()) : null), - index === 0 ? el('span.muted', {}, 'Current') : el('button', { type: 'button', onClick: async () => { await onRestore?.(snapshot); close(); resolve(true); } }, 'Restore'), + el('div', {}, index === 0 ? t('Current cloud snapshot') : t('Previous snapshot {number}', { number: index }), snapshot.createdTime ? el('div.muted', {}, new Date(snapshot.createdTime).toLocaleString(dateLocale)) : null), + index === 0 ? el('span.muted', {}, t('Current')) : el('button', { type: 'button', onClick: async () => { await onRestore?.(snapshot); close(); resolve(true); } }, t('Restore')), ))) - : el('div.muted', {}, 'No cloud snapshots are available for this guide.'); + : el('div.muted', {}, t('No cloud snapshots are available for this guide.')); const { close } = openModal({ - title: 'Cloud snapshots', body: el('div', {}, - el('div.muted', { style: { marginBottom: '10px' } }, 'The current snapshot and up to two previous snapshots are retained.'), list), - footer: [el('button.primary', { onClick: () => { close(); resolve(false); } }, 'Close')], + title: t('Cloud snapshots'), body: el('div', {}, + el('div.muted', { style: { marginBottom: '10px' } }, t('The current snapshot and up to two previous snapshots are retained.')), list), + footer: [el('button.primary', { onClick: () => { close(); resolve(false); } }, t('Close'))], onClose: () => resolve(false), }); }); } const SHORTCUTS = [ - ['Capture & steps', [ - ['Ctrl+S', 'Save (writes linked archive when guide is linked)'], - ['Ctrl+/', 'Quick actions palette'], - ['Ctrl+H', 'Find and replace across the guide'], - ['PageUp / PageDown', 'Previous / next step'], - ['Alt+↑ / Alt+↓', 'Move step (and its substeps) up / down'], - ['Ctrl+Delete', 'Delete current step'], - ['Ctrl+Z / Ctrl+Shift+Z', 'Undo / redo (including step moves, deleting steps and AI writes)'], - ['Ctrl+V', 'Paste annotation, or clipboard image as new step'], + [t('Capture & steps'), [ + ['Ctrl+S', t('Save (writes linked archive when guide is linked)')], + ['Ctrl+/', t('Quick actions palette')], + ['Ctrl+H', t('Find and replace across the guide')], + ['PageUp / PageDown', t('Previous / next step')], + ['Alt+↑ / Alt+↓', t('Move step (and its substeps) up / down')], + ['Ctrl+Delete', t('Delete current step')], + ['Ctrl+Z / Ctrl+Shift+Z', t('Undo / redo (including step moves, deleting steps and AI writes)')], + ['Ctrl+V', t('Paste annotation, or clipboard image as new step')], ]], - ['Canvas tools', [ - ['S R O L A T', 'Select · Rectangle · Oval · Line · Arrow · Text'], - ['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'], - ['Ctrl+C', 'Copy selected annotation (in step-select mode, paste it onto many steps)'], - ['Delete', 'Delete selected annotation'], - ['Esc', 'Deselect annotation'], - ['Arrows / Shift+Arrows', 'Nudge selection by 1 px / 10 px'], + [t('Canvas tools'), [ + ['S R O L A T', t('Select · Rectangle · Oval · Line · Arrow · Text')], + ['G N B H M U C', t('Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop')], + ['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')], ]], - ['View', [ - ['Ctrl+= / Ctrl+-', 'Zoom in / out'], - ['Ctrl+0', 'Fit image to window'], + [t('View'), [ + ['Ctrl+= / Ctrl+-', t('Zoom in / out')], + ['Ctrl+0', t('Fit image to window')], ]], - ['Compare with a snapshot', [ - ['N / Alt+↓', 'Next change'], - ['P / Alt+↑', 'Previous change'], - ['V', 'Mark the current change as viewed and go to the next one'], - ['Esc', 'Close the comparison'], + [t('Compare with a snapshot'), [ + ['N / Alt+↓', t('Next change')], + ['P / Alt+↑', t('Previous change')], + ['V', t('Mark the current change as viewed and go to the next one')], + ['Esc', t('Close the comparison')], ]], ]; @@ -1757,9 +1768,9 @@ function showShortcutsDialog() { ), )); const { close } = openModal({ - title: 'Keyboard shortcuts', + title: t('Keyboard shortcuts'), body: el('div', {}, ...sections), - footer: [el('button.primary', { onClick: () => { close(); resolve(true); } }, 'Close')], + footer: [el('button.primary', { onClick: () => { close(); resolve(true); } }, t('Close'))], onClose: () => resolve(true), }); }); @@ -1770,26 +1781,26 @@ function showInfoDialog(title, bodyText) { const { close } = openModal({ title, body: el('div', {}, bodyText), - footer: [el('button.primary', { onClick: () => { close(); resolve(true); } }, 'OK')], + footer: [el('button.primary', { onClick: () => { close(); resolve(true); } }, t('OK'))], onClose: () => resolve(false), }); }); } function showRecordingReminder({ - actionLabel = 'Continue', - headline = 'StepForge will hide after you continue.', + actionLabel = t('Continue'), + headline = t('StepForge will hide after you continue.'), } = {}) { return new Promise((resolve) => { const { close } = openModal({ - title: 'Before recording starts', + title: t('Before recording starts'), body: el('div.recording-notice', {}, - el('div.recording-notice__badge', {}, 'Recording tip'), + el('div.recording-notice__badge', {}, t('Recording tip')), el('div.recording-notice__title', {}, headline), el('div.recording-notice__text', {}, document.body.classList.contains('platform-linux') - ? 'To stop, use StepForge REC in the GNOME top panel, or restore StepForge from the dock.' - : 'When you want to pause or stop, use the red tray icon in the system tray.', + ? t('To stop, use StepForge REC in the GNOME top panel, or restore StepForge from the dock.') + : t('When you want to pause or stop, use the red tray icon in the system tray.'), ), ), footer: [ diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 04b1574e..ea44dcb6 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -96,22 +96,22 @@ const ANNOTATION_FIELDS = { // Display names for annotation types in the "Type" dropdown. // What a blur added by Find private details hides (core/redaction.js KINDS). const PRIVATE_DETAIL_LABELS = { - email: 'email address', phone: 'phone number', card: 'card number', ssn: 'Social Security number', - ip: 'IP address', secret: 'password or key', url: 'link with a private token', custom: 'word you asked to hide', + email: t('email address'), phone: t('phone number'), card: t('card number'), ssn: t('Social Security number'), + ip: t('IP address'), secret: t('password or key'), url: t('link with a private token'), custom: t('word you asked to hide'), }; const ANNOTATION_TYPE_LABELS = { - rect: 'Rectangle', - oval: 'Oval', - line: 'Line', - arrow: 'Arrow', - text: 'Text', - tooltip: 'Tooltip', - number: 'Number', - blur: 'Blur', - highlight: 'Highlight', - magnify: 'Magnify', - cursor: 'Cursor', + rect: t('Rectangle'), + oval: t('Oval'), + line: t('Line'), + arrow: t('Arrow'), + text: t('Text'), + tooltip: t('Tooltip'), + number: t('Number'), + blur: t('Blur'), + highlight: t('Highlight'), + magnify: t('Magnify'), + cursor: t('Cursor'), }; /** White or near-black text, whichever reads better on a badge or tooltip of color `hex`. */ @@ -149,7 +149,7 @@ function nextBlockOrder(step) { } function blockLabel(kind) { - return kind === 'text' ? 'Text block' : kind === 'code' ? 'Code block' : 'Table'; + return kind === 'text' ? t('Text block') : kind === 'code' ? t('Code block') : t('Table'); } function stepDepth(step, stepMap) { @@ -286,25 +286,25 @@ class GuideEditor { /** Items for the AI ▾ menu in the guide toolbar. */ aiMenuItems() { if (this.aiRun) { - return [{ label: 'Stop writing', action: () => this.stopAiWriting() }]; + return [{ label: t('Stop writing'), action: () => this.stopAiWriting() }]; } const enabled = this.isAiEnabled(); const hasStep = Boolean(this.currentStep); const unavailable = (available) => !enabled || !available; return [ - ...(enabled ? [] : [{ label: 'Turn on AI in Settings…', action: () => this.openSettings('ai') }, 'sep']), - { label: 'Write the whole guide', disabled: unavailable(this.steps.length > 0), action: () => this.writeGuideWithAi() }, - { label: 'Write the whole step', disabled: unavailable(hasStep), action: () => this.writeStepWithAi('step') }, + ...(enabled ? [] : [{ label: t('Turn on AI in Settings…'), action: () => this.openSettings('ai') }, 'sep']), + { label: t('Write the whole guide'), disabled: unavailable(this.steps.length > 0), action: () => this.writeGuideWithAi() }, + { label: t('Write the whole step'), disabled: unavailable(hasStep), action: () => this.writeStepWithAi('step') }, 'sep', - { label: 'Write the title', disabled: unavailable(hasStep), action: () => this.writeStepWithAi('title') }, - { label: 'Write the description', disabled: unavailable(hasStep), action: () => this.writeStepWithAi('description') }, - { label: 'Write the blocks', disabled: unavailable(hasStep), action: () => this.writeStepWithAi('blocks') }, + { label: t('Write the title'), disabled: unavailable(hasStep), action: () => this.writeStepWithAi('title') }, + { label: t('Write the description'), disabled: unavailable(hasStep), action: () => this.writeStepWithAi('description') }, + { label: t('Write the blocks'), disabled: unavailable(hasStep), action: () => this.writeStepWithAi('blocks') }, ]; } /** Short progress text for the AI button while AI is writing, else ''. */ aiProgressLabel() { - return this.aiRun ? this.aiRun.progress || 'writing…' : ''; + return this.aiRun ? this.aiRun.progress || t('writing…') : ''; } setAiProgress(progress) { @@ -318,7 +318,7 @@ class GuideEditor { if (this.guideId) api.ai.cancel({ guideId: this.guideId }).catch(() => {}); if (!this.aiRun) return; this.aiRun.cancelled = true; - if (!quiet) this.onToast('Stopping AI…'); + if (!quiet) this.onToast(t('Stopping AI…')); } /** Show an AI-written step without losing edits the user made meanwhile. */ @@ -340,12 +340,13 @@ class GuideEditor { const step = this.currentStep; if (!step || this.aiRun) return; if (!this.isAiEnabled()) { - this.onToast('Turn on AI in Settings first.', { error: true }); + this.onToast(t('Turn on AI in Settings first.'), { error: true }); return; } if (this.pendingSave) await this.flushStep(); - const what = { - step: 'this step', title: 'the title', description: 'the description', blocks: 'the blocks', + const failure = { + step: t('AI could not write this step.'), title: t('AI could not write the title.'), + description: t('AI could not write the description.'), blocks: t('AI could not write the blocks.'), }[target]; const run = { cancelled: false, progress: '' }; this.aiRun = run; @@ -355,7 +356,7 @@ class GuideEditor { const result = await api.ai.fillStep({ guideId: this.guideId, stepId: step.stepId, target }); if (run.cancelled) return; if (!result || !result.ok) { - this.onToast(result?.reason || `AI could not write ${what}.`, { error: true }); + this.onToast(result?.reason || failure, { error: true }); return; } if (this.applyAiStep(result.step)) { @@ -365,7 +366,7 @@ class GuideEditor { this.onToast(`AI wrote ${what}.`); } } catch (err) { - if (!run.cancelled) this.onToast(err.message || `AI could not write ${what}.`, { error: true }); + if (!run.cancelled) this.onToast(err.message || failure, { error: true }); } finally { if (this.aiRun === run) this.aiRun = null; this.emitMeta(); @@ -380,7 +381,7 @@ class GuideEditor { async writeGuideWithAi() { if (this.aiRun || !this.steps.length) return; if (!this.isAiEnabled()) { - this.onToast('Turn on AI in Settings first.', { error: true }); + this.onToast(t('Turn on AI in Settings first.'), { error: true }); return; } // A step with no screenshot and no text gives AI nothing to work from. @@ -390,23 +391,23 @@ class GuideEditor { .map((id) => this.stepMap.get(id)) .filter((step) => step && (step.image || hasText(step))); if (!queue.length) { - this.onToast('There is nothing for AI to write from yet. Capture some steps first.', { error: true }); + this.onToast(t('There is nothing for AI to write from yet. Capture some steps first.'), { error: true }); return; } const ok = await confirmDialog( - `AI will write the title, description and blocks for ${queue.length} step${queue.length === 1 ? '' : 's'}, ` - + 'make substeps where steps belong together, add placeholders for values that repeat, ' - + 'then write the guide’s title and description. Text you already wrote is polished, not replaced with something new. ' - + 'A snapshot is saved first, so you can undo this from More → Backups & snapshots.', - { okLabel: 'Write the whole guide' }, + `${tn(queue.length, 'AI will write the title, description and blocks for {count} step.', + 'AI will write the title, description and blocks for {count} steps, make substeps where steps belong together, and add placeholders for values that repeat.')} ${ + t('Then it writes the guide’s title and description. Text you already wrote is polished, not replaced with something new.')} ${ + t('A snapshot is saved first, so you can undo this from More → Backups & snapshots.')}`, + { okLabel: t('Write the whole guide') }, ); if (!ok) return; if (this.pendingSave) await this.flushStep(); if (this.pendingGuideSave) await this.flushGuide(); try { - await api.snapshots.create({ guideId: this.guideId, label: 'before AI' }); + await api.snapshots.create({ guideId: this.guideId, label: t('before AI') }); } catch (err) { - this.onToast(`Could not save a snapshot first, so AI did not start: ${err.message || err}`, { error: true }); + this.onToast(t('Could not save a snapshot first, so AI did not start: {error}', { error: err.message || err }), { error: true }); return; } @@ -433,7 +434,7 @@ class GuideEditor { written += 1; this.applyAiStep(result.step); } else { - problems.push(result?.reason || 'AI could not write a step.'); + problems.push(result?.reason || t('AI could not write a step.')); } } if (!run.cancelled && written) { @@ -445,7 +446,7 @@ class GuideEditor { substeps = organized.substeps || 0; newPlaceholders = (organized.placeholders || []).map((p) => `[[${p.name}]]`); } else if (!run.cancelled) { - problems.push(organized?.reason || 'AI could not organize the guide.'); + problems.push(organized?.reason || t('AI could not organize the guide.')); } } if (!run.cancelled && written) { @@ -455,7 +456,7 @@ class GuideEditor { if (!run.cancelled && result?.ok) { this.guide = result.guide; } else if (!run.cancelled) { - problems.push(result?.reason || 'AI could not write the guide title.'); + problems.push(result?.reason || t('AI could not write the guide title.')); } } } finally { @@ -466,15 +467,15 @@ class GuideEditor { // Substeps and placeholders changed steps and numbering: show the guide as saved. await this.reloadAfterOutsideChange(); const extras = [ - substeps ? `made ${substeps} substep${substeps === 1 ? '' : 's'}` : '', - newPlaceholders.length ? `added ${newPlaceholders.join(', ')}` : '', - ].filter(Boolean).join(' and '); + substeps ? tn(substeps, 'Made {count} substep.', 'Made {count} substeps.') : '', + newPlaceholders.length ? t('Added {placeholders}.', { placeholders: newPlaceholders.join(', ') }) : '', + ].filter(Boolean).join(' '); if (run.cancelled) { - this.onToast(`Stopped. AI wrote ${written} of ${queue.length} step${queue.length === 1 ? '' : 's'}.`); + this.onToast(tn(queue.length, 'Stopped. AI wrote {written} of {count} step.', 'Stopped. AI wrote {written} of {count} steps.', { written })); } else if (problems.length) { - this.onToast(`AI wrote ${written} of ${queue.length} steps. ${problems[0]}`, { error: true, ms: 6000 }); + this.onToast(`${tn(queue.length, 'AI wrote {written} of {count} step.', 'AI wrote {written} of {count} steps.', { written })} ${problems[0]}`, { error: true, ms: 6000 }); } else { - this.onToast(`AI wrote the whole guide (${written} step${written === 1 ? '' : 's'})${extras ? ` and ${extras}` : ''}.`, { ms: 5000 }); + this.onToast(`${tn(written, 'AI wrote the whole guide ({count} step).', 'AI wrote the whole guide ({count} steps).')}${extras ? ` ${extras}` : ''}`, { ms: 5000 }); } } @@ -577,51 +578,51 @@ class GuideEditor { el('aside.pane-steps', {}, el('div.pane-head', {}, el('div', {}, - el('div.eyebrow', {}, 'Steps'), - this.dom.stepCount = el('div.muted', {}, '0 steps'), + el('div.eyebrow', {}, t('Steps')), + this.dom.stepCount = el('div.muted', {}, tn(0, '{count} step', '{count} steps')), ), el('div.row', {}, - this.dom.addStepBtn = el('button.primary', { type: 'button' }, 'Add'), - this.dom.importBtn = el('button', { type: 'button' }, 'Import'), - this.dom.selectStepsBtn = el('button', { type: 'button', title: 'Select steps; Shift-click to select a range' }, 'Select'), + this.dom.addStepBtn = el('button.primary', { type: 'button' }, t('Add')), + this.dom.importBtn = el('button', { type: 'button' }, t('Import')), + this.dom.selectStepsBtn = el('button', { type: 'button', title: t('Select steps; Shift-click to select a range') }, t('Select')), ), ), this.dom.stepsList = el('div.steps-list'), this.dom.stepBulkBar = el('div'), this.dom.paneFoot = el('div.pane-foot', {}, - this.dom.moveUpBtn = el('button.icon', { type: 'button', title: 'Move step up' }, '↑'), - this.dom.moveDownBtn = el('button.icon', { type: 'button', title: 'Move step down' }, '↓'), - this.dom.duplicateBtn = el('button', { type: 'button' }, 'Duplicate'), - this.dom.deleteBtn = el('button.danger', { type: 'button' }, 'Delete'), + this.dom.moveUpBtn = el('button.icon', { type: 'button', title: t('Move step up') }, '↑'), + this.dom.moveDownBtn = el('button.icon', { type: 'button', title: t('Move step down') }, '↓'), + this.dom.duplicateBtn = el('button', { type: 'button' }, t('Duplicate')), + this.dom.deleteBtn = el('button.danger', { type: 'button' }, t('Delete')), ), ), el('section.pane-canvas', {}, el('div.canvas-toolbar', {}, ...toolButtons.map(([tool, label]) => this.dom[`tool-${tool}`] = el('button.tool', { type: 'button', dataset: { tool } }, label)), el('span.sep'), - this.dom.zoomFitBtn = el('button.tool', { type: 'button' }, 'Fit'), + this.dom.zoomFitBtn = el('button.tool', { type: 'button' }, t('Fit')), this.dom.zoom100Btn = el('button.tool', { type: 'button' }, '100%'), this.dom.zoom125Btn = el('button.tool', { type: 'button' }, '125%'), this.dom.zoom150Btn = el('button.tool', { type: 'button' }, '150%'), el('span.sep'), - this.dom.undoBtn = el('button.tool', { type: 'button' }, 'Undo'), - this.dom.redoBtn = el('button.tool', { type: 'button' }, 'Redo'), + this.dom.undoBtn = el('button.tool', { type: 'button' }, t('Undo')), + this.dom.redoBtn = el('button.tool', { type: 'button' }, t('Redo')), ), this.dom.canvasWrap = el('div.canvas-wrap', {}, this.dom.canvas = el('canvas', { width: 1, height: 1 }), - this.dom.canvasEmpty = el('div.canvas-empty', {}, 'Select an image step to edit annotations.'), + this.dom.canvasEmpty = el('div.canvas-empty', {}, t('Select an image step to edit annotations.')), ), ), el('aside.pane-props', {}, el('section', {}, - el('h3', {}, 'Step'), + el('h3', {}, t('Step')), el('div.row', {}, - this.dom.titleInput = el('input', { type: 'text', placeholder: 'Step title', style: { flex: 1 } }), + this.dom.titleInput = el('input', { type: 'text', placeholder: t('Step title'), style: { flex: 1 } }), ), this.dom.statusSelect = makeSelect('todo', [ - { value: 'todo', label: 'Todo' }, - { value: 'in-progress', label: 'In progress' }, - { value: 'done', label: 'Done' }, + { value: 'todo', label: t('Todo') }, + { value: 'in-progress', label: t('In progress') }, + { value: 'done', label: t('Done') }, ]), el('div.row', {}, this.dom.hiddenToggle = el('label', {}, el('input', { type: 'checkbox' }), ' Hidden'), @@ -632,17 +633,17 @@ class GuideEditor { this.dom.focusedViewToggle = el('label', {}, el('input', { type: 'checkbox' }), ' Focused'), ), this.dom.focusedControls = el('div.focused-controls.hidden', {}, - el('div.form-row', {}, el('label', {}, 'Zoom'), + el('div.form-row', {}, el('label', {}, t('Zoom')), this.dom.fvZoom = el('input', { type: 'range', min: 1, max: 3, step: 0.05, value: 1.5 })), - el('div.form-row', {}, el('label', {}, 'Pan X'), + el('div.form-row', {}, el('label', {}, t('Pan X')), this.dom.fvPanX = el('input', { type: 'range', min: 0, max: 1, step: 0.01, value: 0.5 })), - el('div.form-row', {}, el('label', {}, 'Pan Y'), + el('div.form-row', {}, el('label', {}, t('Pan Y')), this.dom.fvPanY = el('input', { type: 'range', min: 0, max: 1, step: 0.01, value: 0.5 })), - el('div.muted', {}, 'Exports crop to this view; the original image is never modified.'), + el('div.muted', {}, t('Exports crop to this view; the original image is never modified.')), ), ), el('section', {}, - el('h3', {}, 'Description'), + el('h3', {}, t('Description')), this.dom.richToolbar = el('div.rich-toolbar', {}, this.toolbarBtn('bold', 'Bold'), this.toolbarBtn('italic', 'Italic'), @@ -655,24 +656,24 @@ class GuideEditor { this.dom.descEditor = el('div.rich-editor', { contentEditable: 'true', spellcheck: true }), ), el('section', {}, - el('h3', {}, 'Annotations'), + el('h3', {}, t('Annotations')), this.dom.annotationList = el('div', { className: 'annotation-list' }), this.dom.annotationEditor = el('div', { className: 'annotation-editor' }), ), el('section', {}, - el('h3', {}, 'Blocks'), + el('h3', {}, t('Blocks')), this.dom.blocksList = el('div', { className: 'blocks-list' }), el('div.row', {}, - this.dom.addTextBlockBtn = el('button', { type: 'button' }, '+ Text block'), - this.dom.addCodeBlockBtn = el('button', { type: 'button' }, '+ Code'), - this.dom.addTableBlockBtn = el('button', { type: 'button' }, '+ Table'), + this.dom.addTextBlockBtn = el('button', { type: 'button' }, t('+ Text block')), + this.dom.addCodeBlockBtn = el('button', { type: 'button' }, t('+ Code')), + this.dom.addTableBlockBtn = el('button', { type: 'button' }, t('+ Table')), ), ), el('section', {}, - el('h3', {}, 'Guide'), + el('h3', {}, t('Guide')), this.dom.guideSummary = el('div.muted', {}), - this.dom.saveNowBtn = el('button.primary', { type: 'button', title: 'Save changes. For guides linked to a shared archive, also writes the archive file.' }, 'Save now'), - this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'), + this.dom.saveNowBtn = el('button.primary', { type: 'button', title: t('Save changes. For guides linked to a shared archive, also writes the archive file.') }, t('Save now')), + this.dom.snapshotBtn = el('button', { type: 'button' }, t('Snapshot')), ), ), ); @@ -765,7 +766,7 @@ class GuideEditor { this.pendingSave = true; this.saveStepDebounced(); const row = [...this.dom.stepsList.children].find((node) => node.dataset.stepId === this.selectedStepId); - if (row) row.querySelector('.t').textContent = this.currentStep.title || 'Untitled step'; + if (row) row.querySelector('.t').textContent = this.currentStep.title || t('Untitled step'); this.emitMeta(); }); @@ -887,7 +888,7 @@ class GuideEditor { addBlock(kind) { const step = this.currentStep; if (!step) { - this.onToast('Select a step first.', { error: true }); + this.onToast(t('Select a step first.'), { error: true }); return; } this.syncBlockEditors(step); @@ -950,7 +951,7 @@ class GuideEditor { clearNode(this.dom.blocksList); const step = this.currentStep; if (!step) { - this.dom.blocksList.append(el('div.muted', {}, 'Select a step to add blocks.')); + this.dom.blocksList.append(el('div.muted', {}, t('Select a step to add blocks.'))); return; } const save = () => { @@ -996,7 +997,7 @@ class GuideEditor { reflowBlockCards(); }; const removeBtn = (onRemove) => el('button.icon.danger', { - type: 'button', title: 'Remove block', + type: 'button', title: t('Remove block'), onClick: () => { onRemove(); save(); this.renderBlocksPanel(); }, }, '✕'); @@ -1022,8 +1023,8 @@ class GuideEditor { el('strong', {}, blockLabel(kind)), el('span.muted', { dataset: { blockOrder: 'true' } }, `#${Number.isFinite(block.order) ? block.order : index + 1}`), el('span.spacer'), - el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp, dataset: { blockMove: 'up' } }, '↑'), - el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown, dataset: { blockMove: 'down' } }, '↓'), + el('button.icon', { type: 'button', title: t('Move block up'), disabled: !canMoveUp, onClick: moveUp, dataset: { blockMove: 'up' } }, '↑'), + el('button.icon', { type: 'button', title: t('Move block down'), disabled: !canMoveDown, onClick: moveDown, dataset: { blockMove: 'down' } }, '↓'), removeBtn(() => { if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block); else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block); @@ -1055,24 +1056,24 @@ class GuideEditor { if (kind === 'text') { card.dataset.level = block.level || 'info'; const position = makeSelect(block.position, [ - { value: 'before-title', label: 'Before title' }, - { value: 'after-title', label: 'After title' }, - { value: 'before-image', label: 'Before image' }, - { value: 'after-image', label: 'After image' }, - { value: 'before-description', label: 'Before description' }, - { value: 'after-description', label: 'After description' }, + { value: 'before-title', label: t('Before title') }, + { value: 'after-title', label: t('After title') }, + { value: 'before-image', label: t('Before image') }, + { value: 'after-image', label: t('After image') }, + { value: 'before-description', label: t('Before description') }, + { value: 'after-description', label: t('After description') }, ]); position.dataset.blockField = 'position'; const level = makeSelect(block.level, [ - { value: 'info', label: 'Note' }, - { value: 'warn', label: 'Warning' }, - { value: 'error', label: 'Important' }, - { value: 'success', label: 'Tip' }, + { value: 'info', label: t('Note') }, + { value: 'warn', label: t('Warning') }, + { value: 'error', label: t('Important') }, + { value: 'success', label: t('Tip') }, ]); level.dataset.blockField = 'level'; - const title = el('input', { type: 'text', value: block.title || '', placeholder: 'Block title' }); + const title = el('input', { type: 'text', value: block.title || '', placeholder: t('Block title') }); title.dataset.blockField = 'title'; - const body = el('textarea', { rows: 2, placeholder: 'Block text' }); + const body = el('textarea', { rows: 2, placeholder: t('Block text') }); body.dataset.blockField = 'body'; body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, ''); position.addEventListener('change', () => { block.position = position.value; save(); }); @@ -1085,9 +1086,9 @@ class GuideEditor { body, ); } else if (kind === 'code') { - const lang = el('input', { type: 'text', value: block.language || '', placeholder: 'Language (e.g. bash)' }); + const lang = el('input', { type: 'text', value: block.language || '', placeholder: t('Language (e.g. bash)') }); lang.dataset.blockField = 'language'; - const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false }); + const code = el('textarea', { rows: 3, placeholder: t('Code'), spellcheck: false }); code.dataset.blockField = 'code'; code.value = blockText(block); code.style.fontFamily = 'monospace'; @@ -1095,7 +1096,7 @@ class GuideEditor { code.addEventListener('input', () => { block.code = code.value; save(); }); card.append(lang, code); } else if (kind === 'table') { - const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false }); + const grid = el('textarea', { rows: 3, placeholder: t('One row per line, cells separated by |'), spellcheck: false }); grid.dataset.blockField = 'rows'; grid.value = (block.rows || []).map((r) => r.join(' | ')).join('\n'); grid.addEventListener('input', () => { @@ -1104,7 +1105,7 @@ class GuideEditor { save(); }); card.append( - el('div.muted', {}, 'First line is the header row.'), + el('div.muted', {}, t('First line is the header row.')), grid, ); } @@ -1113,7 +1114,7 @@ class GuideEditor { } if (!blocks.length) { - this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks can be reordered with drag handles or arrows.')); + this.dom.blocksList.append(el('div.muted', {}, t('Informational text, code, and table blocks can be reordered with drag handles or arrows.'))); } } @@ -1121,7 +1122,7 @@ class GuideEditor { const current = this.currentStep; const numbers = stepNumberMap(this.steps); clearNode(this.dom.stepsList); - this.dom.stepCount.textContent = `${this.steps.length} step${this.steps.length === 1 ? '' : 's'}`; + this.dom.stepCount.textContent = tn(this.steps.length, '{count} step', '{count} steps'); this.dom.selectStepsBtn.className = this.stepSelectMode ? 'primary' : ''; for (const step of this.steps) { const number = numbers.get(step.stepId) || ''; @@ -1146,35 +1147,35 @@ class GuideEditor { if (this.stepSelectMode) return; this.selectStep(step.stepId); contextMenu(e.clientX, e.clientY, [ - { label: 'Add substep', action: () => this.addSubstep(step.stepId) }, + { label: t('Add substep'), action: () => this.addSubstep(step.stepId) }, { - label: 'Make substep of…', + label: t('Make substep of…'), submenu: () => { const subtreeIds = new Set([step.stepId, ...this.getStepDescendantIds(step.stepId)]); return this.steps .filter((s) => !subtreeIds.has(s.stepId)) .map((s) => ({ - label: `${numbers.get(s.stepId)} ${s.title || 'Untitled step'}`, + label: `${numbers.get(s.stepId)} ${s.title || t('Untitled step')}`, action: () => this.makeSubstepOf(step.stepId, s.stepId), })); }, }, - { label: 'Duplicate step', action: () => this.duplicateSelectedStep() }, - { label: 'Choose image…', action: () => this.chooseScreenshot(step.stepId) }, + { label: t('Duplicate step'), action: () => this.duplicateSelectedStep() }, + { label: t('Choose image…'), action: () => this.chooseScreenshot(step.stepId) }, { - label: 'Retake screenshot', + label: t('Retake screenshot'), submenu: [ - { label: 'Full screen', action: () => this.retakeScreenshot(step.stepId, 'fullscreen') }, - { label: 'Window', action: () => this.retakeScreenshot(step.stepId, 'window') }, - { label: 'Region…', action: () => this.retakeScreenshot(step.stepId, 'region') }, + { label: t('Full screen'), action: () => this.retakeScreenshot(step.stepId, 'fullscreen') }, + { label: t('Window'), action: () => this.retakeScreenshot(step.stepId, 'window') }, + { label: t('Region…'), action: () => this.retakeScreenshot(step.stepId, 'region') }, ], }, 'sep', - { label: 'Move up', action: () => this.moveSelectedStep(-1) }, - { label: 'Move down', action: () => this.moveSelectedStep(1) }, - { label: 'Move to…', action: () => this.moveStepTo(step.stepId) }, + { label: t('Move up'), action: () => this.moveSelectedStep(-1) }, + { label: t('Move down'), action: () => this.moveSelectedStep(1) }, + { label: t('Move to…'), action: () => this.moveStepTo(step.stepId) }, 'sep', - { label: 'Delete step', danger: true, action: () => this.deleteSelectedStep() }, + { label: t('Delete step'), danger: true, action: () => this.deleteSelectedStep() }, ]); }, }; @@ -1184,7 +1185,7 @@ class GuideEditor { ? el('input', { type: 'checkbox', checked, - 'aria-label': `Select step ${number || step.title || ''}`, + 'aria-label': t('Select step {step}', { step: number || step.title || '' }), onClick: (e) => { e.stopPropagation(); this.toggleStepSelection(step.stepId, e.shiftKey); @@ -1193,7 +1194,7 @@ class GuideEditor { : null, el('span.status-dot', { className: `status-dot status-${step.status}` }), el('span.num', {}, number || '•'), - el('span.t', {}, step.title || 'Untitled step'), + el('span.t', {}, step.title || t('Untitled step')), el('span.flags', {}, [ step.parentStepId ? 'sub' : '', step.hidden ? 'hidden' : '', @@ -1203,7 +1204,7 @@ class GuideEditor { this.dom.stepsList.append(item); } if (!this.steps.length) { - this.dom.stepsList.append(el('div.empty-state', { style: { marginTop: '40px' } }, 'No steps yet.')); + this.dom.stepsList.append(el('div.empty-state', { style: { marginTop: '40px' } }, t('No steps yet.'))); } this.renderStepBulkBar(); } @@ -1256,16 +1257,16 @@ class GuideEditor { el('button', { type: 'button', onClick: () => (allSelected ? this.clearStepSelection() : this.selectAllSteps()), - }, allSelected ? 'Clear' : 'Select all'), + }, allSelected ? t('Clear') : t('Select all')), this.annotationClipboard ? el('button', { type: 'button', disabled: !n, - title: 'Add the copied annotation at the same position on each selected step', + title: t('Add the copied annotation at the same position on each selected step'), onClick: () => this.pasteAnnotationToSelectedSteps(), - }, `Paste annotation to ${n} step${n === 1 ? '' : 's'}`) + }, tn(n, 'Paste annotation to {count} step', 'Paste annotation to {count} steps')) : null, - el('button.danger', { type: 'button', disabled: !n, onClick: () => this.deleteSelectedSteps() }, 'Delete'), + el('button.danger', { type: 'button', disabled: !n, onClick: () => this.deleteSelectedSteps() }, t('Delete')), ), ); } @@ -1273,7 +1274,7 @@ class GuideEditor { async deleteSelectedSteps() { const ids = [...this.selectedSteps]; if (!ids.length) return; - const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' }); + const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: t('Delete') }); if (!ok) return; if (this.pendingSave) await this.flushStep(); const order = this.steps.map((s) => s.stepId); @@ -1315,7 +1316,7 @@ class GuideEditor { this.dom.forceNewPageToggle.querySelector('input').checked = Boolean(step.forceNewPage); this.dom.focusedViewToggle.querySelector('input').checked = Boolean(step.focusedView?.enabled); this.dom.guideSummary.textContent = guide - ? `${guide.title} · ${guide.linkedSource ? 'linked' : 'local'} · ${this.steps.length} steps` + ? `${guide.title} · ${guide.linkedSource ? t('linked') : t('local')} · ${tn(this.steps.length, '{count} step', '{count} steps')}` : ''; } @@ -1357,15 +1358,15 @@ class GuideEditor { clearNode(this.dom.annotationList); const step = this.currentStep; if (!step) { - this.dom.annotationList.append(el('div.muted', {}, 'No step selected.')); + this.dom.annotationList.append(el('div.muted', {}, t('No step selected.'))); clearNode(this.dom.annotationEditor); - this.dom.annotationEditor.append(el('div.muted', {}, 'Select a step to edit annotations.')); + this.dom.annotationEditor.append(el('div.muted', {}, t('Select a step to edit annotations.'))); return; } const anns = step.annotations || []; if (!anns.length) { - this.dom.annotationList.append(el('div.muted', {}, 'No annotations yet. Pick a tool and drag on the canvas.')); + this.dom.annotationList.append(el('div.muted', {}, t('No annotations yet. Pick a tool and drag on the canvas.'))); } else { for (const ann of anns) { const selected = this.canvas.selected() && this.canvas.selected().id === ann.id; @@ -1375,7 +1376,7 @@ class GuideEditor { }, el('div.row', {}, el('strong', {}, ANNOTATION_TYPE_LABELS[ann.type] || ann.type), // Blurs StepForge added for a private detail say what they hide. - el('span.muted', {}, ann.redact ? `Private detail: ${PRIVATE_DETAIL_LABELS[ann.redact.kind] || 'added by StepForge'}` : ann.text || ann.value || '')), + el('span.muted', {}, ann.redact ? t('Private detail: {kind}', { kind: PRIVATE_DETAIL_LABELS[ann.redact.kind] || t('added by StepForge') }) : ann.text || ann.value || '')), el('div.muted', {}, `${ann.x.toFixed(3)}, ${ann.y.toFixed(3)} · ${ann.w.toFixed(3)} × ${ann.h.toFixed(3)}`))); } } @@ -1383,7 +1384,7 @@ class GuideEditor { const selected = this.canvas.selected(); clearNode(this.dom.annotationEditor); if (!selected) { - this.dom.annotationEditor.append(el('div.muted', {}, 'Select an annotation to edit its style.')); + this.dom.annotationEditor.append(el('div.muted', {}, t('Select an annotation to edit its style.'))); return; } @@ -1391,7 +1392,7 @@ class GuideEditor { const typeSelect = makeSelect(selected.type, [ 'rect', 'oval', 'line', 'arrow', 'text', 'tooltip', 'number', 'blur', 'highlight', 'magnify', 'cursor', ].map((type) => ({ value: type, label: ANNOTATION_TYPE_LABELS[type] || type }))); - const valueInput = el('input', { type: 'number', value: Number.isFinite(selected.value) ? selected.value : '', placeholder: 'Value' }); + const valueInput = el('input', { type: 'number', value: Number.isFinite(selected.value) ? selected.value : '', placeholder: t('Value') }); const strokeInput = el('input', { type: 'color', value: style.stroke || '#E5484D' }); const fillInput = el('input', { type: 'color', value: style.fill && style.fill !== 'transparent' ? style.fill : '#ffffff' }); const strokeWidthInput = el('input', { type: 'number', min: 1, step: 1, value: style.strokeWidth || 3 }); @@ -1400,10 +1401,10 @@ class GuideEditor { const zoomInput = el('input', { type: 'number', min: 1, step: 0.1, value: selected.zoom || 2 }); const radiusInput = el('input', { type: 'number', min: 1, step: 1, value: selected.radius || 8 }); const tailInput = makeSelect(style.tail || 'bottom', [ - { value: 'bottom', label: 'Bottom' }, - { value: 'top', label: 'Top' }, - { value: 'left', label: 'Left' }, - { value: 'right', label: 'Right' }, + { value: 'bottom', label: t('Bottom') }, + { value: 'top', label: t('Top') }, + { value: 'left', label: t('Left') }, + { value: 'right', label: t('Right') }, ]); // Light-weight apply: mutate the selected annotation, redraw, and let the @@ -1424,41 +1425,41 @@ class GuideEditor { }; const fields = new Set(ANNOTATION_FIELDS[selected.type] || []); - const strokeLabel = (selected.type === 'text' || selected.type === 'number') ? 'Color' : 'Stroke'; + const strokeLabel = (selected.type === 'text' || selected.type === 'number') ? t('Color') : t('Stroke'); const typeLabel = ANNOTATION_TYPE_LABELS[selected.type] || selected.type; const textInput = fields.has('text') ? el('textarea', { rows: Math.max(3, Math.min(8, String(selected.text || '').split('\n').length)), - placeholder: 'Annotation text', + placeholder: t('Annotation text'), spellcheck: true, }) - : el('input', { type: 'text', value: selected.text || '', placeholder: 'Annotation text' }); + : el('input', { type: 'text', value: selected.text || '', placeholder: t('Annotation text') }); if (fields.has('text')) textInput.value = selected.text || ''; - const rows = [labeledRow('Type', typeSelect)]; - if (fields.has('text')) rows.push(labeledRow('Text', textInput, { stacked: true })); - if (fields.has('value')) rows.push(labeledRow('Value', valueInput)); + const rows = [labeledRow(t('Type'), typeSelect)]; + if (fields.has('text')) rows.push(labeledRow(t('Text'), textInput, { stacked: true })); + if (fields.has('value')) rows.push(labeledRow(t('Value'), valueInput)); if (fields.has('stroke')) rows.push(labeledRow(strokeLabel, strokeInput)); - if (fields.has('fill')) rows.push(labeledRow('Fill', fillInput)); - if (fields.has('strokeWidth')) rows.push(labeledRow('Stroke width', strokeWidthInput)); - if (fields.has('fontSize')) rows.push(labeledRow('Font size', fontSizeInput)); - if (fields.has('textColor')) rows.push(labeledRow('Text color', textColorInput)); - if (fields.has('zoom')) rows.push(labeledRow('Zoom', zoomInput)); - if (fields.has('radius')) rows.push(labeledRow('Radius', radiusInput)); - if (fields.has('tail')) rows.push(labeledRow('Tail', tailInput)); + if (fields.has('fill')) rows.push(labeledRow(t('Fill'), fillInput)); + if (fields.has('strokeWidth')) rows.push(labeledRow(t('Stroke width'), strokeWidthInput)); + if (fields.has('fontSize')) rows.push(labeledRow(t('Font size'), fontSizeInput)); + if (fields.has('textColor')) rows.push(labeledRow(t('Text color'), textColorInput)); + if (fields.has('zoom')) rows.push(labeledRow(t('Zoom'), zoomInput)); + if (fields.has('radius')) rows.push(labeledRow(t('Radius'), radiusInput)); + if (fields.has('tail')) rows.push(labeledRow(t('Tail'), tailInput)); rows.push( - el('div.muted', {}, `Copy this style to every other "${typeLabel}" annotation:`), + el('div.muted', {}, t('Copy this style to every other “{type}” annotation:', { type: typeLabel })), el('div.row', {}, el('button', { type: 'button', - title: `Overwrite the style of every "${typeLabel}" annotation on this step with the style shown above.`, + title: t('Overwrite the style of every “{type}” annotation on this step with the style shown above.', { type: typeLabel }), onClick: () => this.applyStyleAcross('step'), - }, 'This step'), + }, t('This step')), el('button', { type: 'button', - title: `Overwrite the style of every "${typeLabel}" annotation across all steps in this guide with the style shown above.`, + title: t('Overwrite the style of every “{type}” annotation across all steps in this guide with the style shown above.', { type: typeLabel }), onClick: () => this.applyStyleAcross('guide'), - }, 'Entire guide'), + }, t('Entire guide')), ), ); @@ -1666,7 +1667,7 @@ class GuideEditor { try { await api.step.reorder({ guideId: this.guideId, order, parents }); } catch (err) { - this.onToast(`Could not undo the move: ${err.message || err}`, { error: true }); + this.onToast(t('Could not undo the move: {error}', { error: err.message || err }), { error: true }); return undefined; } await this.reload(this.selectedStepId); @@ -1688,9 +1689,9 @@ class GuideEditor { }); if (stale.length) { const ok = await confirmDialog( - `${stale.length === 1 ? 'A step was' : `${stale.length} steps were`} changed since this edit, ` - + 'perhaps by AI or another app. Undo anyway and overwrite those changes?', - { danger: true, okLabel: 'Undo anyway' }, + tn(stale.length, 'A step was changed since this edit, perhaps by AI or another app. Undo anyway and overwrite those changes?', + '{count} steps were changed since this edit, perhaps by AI or another app. Undo anyway and overwrite those changes?'), + { danger: true, okLabel: t('Undo anyway') }, ); if (!ok) return null; } @@ -1711,7 +1712,7 @@ class GuideEditor { failed.push(step); } } - if (error) this.onToast(`Could not undo every step: ${error.message || error}`, { error: true }); + if (error) this.onToast(t('Could not undo every step: {error}', { error: error.message || error }), { error: true }); await this.reload(this.selectedStepId); if (!saved.length) return null; const inverse = this.multiStepRecord(replaced, saved); @@ -1767,7 +1768,7 @@ class GuideEditor { async undo() { if (!this.canvasHistory.length) { - this.onToast('Nothing to undo.'); + this.onToast(t('Nothing to undo.')); return; } if (!(await this.settlePendingSave())) return; @@ -1788,7 +1789,7 @@ class GuideEditor { async redo() { if (!this.canvasFuture.length) { - this.onToast('Nothing to redo.'); + this.onToast(t('Nothing to redo.')); return; } if (!(await this.settlePendingSave())) return; @@ -1870,9 +1871,9 @@ class GuideEditor { saved = await api.step.save({ guideId: this.guideId, step }); } catch (err) { this.pendingSave = true; - this.saveError = (err && err.message) || 'Save failed'; + this.saveError = (err && err.message) || t('Save failed'); this.emitMeta(); - this.onToast('Could not save this step — your changes are kept. Retrying…', { error: true }); + this.onToast(t('Could not save this step — your changes are kept. Retrying…'), { error: true }); return null; } this.pendingSave = false; @@ -1927,9 +1928,9 @@ class GuideEditor { await api.guide.save({ guide: this.guide }); } catch (err) { this.pendingGuideSave = true; - this.saveError = (err && err.message) || 'Save failed'; + this.saveError = (err && err.message) || t('Save failed'); this.emitMeta(); - this.onToast('Could not save guide details — your changes are kept. Retrying…', { error: true }); + this.onToast(t('Could not save guide details — your changes are kept. Retrying…'), { error: true }); return; } this.pendingGuideSave = false; @@ -1953,13 +1954,13 @@ class GuideEditor { const result = await this.runLinkedSave({ force: false }); if (result.saved) { if (this.guide.linkedSource) this.guide.linkedSource.lastSavedAt = new Date().toISOString(); - this.toastLinkedSave(result, 'Saved and synced to linked archive.'); + this.toastLinkedSave(result, t('Saved and synced to linked archive.')); } else { - this.onToast('Saved locally, but the linked archive is locked by another session.', { error: true }); + this.onToast(t('Saved locally, but the linked archive is locked by another session.'), { error: true }); } return; } - this.onToast('Saved.'); + this.onToast(t('Saved.')); } /** @@ -2032,23 +2033,23 @@ class GuideEditor { toastLinkedSave(result, message) { if (!result.merged) return this.onToast(message); const conflicts = result.conflicts - ? ` ${result.conflicts} ${result.conflicts === 1 ? 'conflict was' : 'conflicts were'} settled in favor of the most recent edit.` + ? ` ${tn(result.conflicts, '{count} conflict was settled in favor of the most recent edit.', '{count} conflicts were settled in favor of the most recent edit.')}` : ''; - return this.onToast(`${message} Merged changes from another session.${conflicts}`); + return this.onToast(`${message} ${t('Merged changes from another session.')}${conflicts}`); } async createSnapshot() { if (!this.guideId) return; const label = await dialogs.promptText({ - title: 'Create snapshot', - label: 'Snapshot name', + title: t('Create snapshot'), + label: t('Snapshot name'), placeholder: 'manual', }); if (label == null) return; if (this.currentStep) await this.flushStep(); if (this.guide) await this.flushGuide(); await api.snapshots.create({ guideId: this.guideId, label: label.trim() || 'manual' }); - this.onToast('Snapshot created.'); + this.onToast(t('Snapshot created.')); } async selectStep(stepId) { @@ -2071,32 +2072,32 @@ class GuideEditor { async addEmptyStep() { const title = await dialogs.promptText({ - title: 'Add Step', - label: 'Step title', + title: t('Add Step'), + label: t('Step title'), value: '', - placeholder: 'Untitled step', + placeholder: t('Untitled step'), }); if (title == null) return; const step = await api.step.add({ guideId: this.guideId, fields: { kind: 'empty', - title: title.trim() || 'Untitled step', + title: title.trim() || t('Untitled step'), status: 'todo', }, position: this.steps.length, }); await this.reload(step.stepId); - this.onToast('Step added.'); + this.onToast(t('Step added.')); } async addSubstep(parentStepId = this.selectedStepId) { if (!parentStepId) return; const title = await dialogs.promptText({ - title: 'Add Substep', - label: 'Substep title', + title: t('Add Substep'), + label: t('Substep title'), value: '', - placeholder: 'Untitled substep', + placeholder: t('Untitled substep'), }); if (title == null) return; const parent = this.stepMap.get(parentStepId); @@ -2105,14 +2106,14 @@ class GuideEditor { guideId: this.guideId, fields: { kind: 'empty', - title: title.trim() || 'Untitled substep', + title: title.trim() || t('Untitled substep'), parentStepId, status: 'todo', }, position: parentIndex + 1, }); await this.reload(step.stepId); - this.onToast(parent ? 'Substep added.' : 'Step added.'); + this.onToast(parent ? t('Substep added.') : t('Step added.')); } /** All step ids whose ancestor chain leads back to `stepId`. */ @@ -2138,11 +2139,11 @@ class GuideEditor { const numbers = stepNumberMap(this.steps); const subtreeIds = new Set([stepId, ...this.getStepDescendantIds(stepId)]); if (subtreeIds.has(targetStepId)) { - this.onToast('A step cannot be made a substep of itself or one of its own substeps.', { error: true }); + this.onToast(t('A step cannot be made a substep of itself or one of its own substeps.'), { error: true }); return; } if (step.parentStepId === targetStepId) { - this.onToast(`“${step.title || 'Untitled step'}” is already a substep of step ${numbers.get(targetStepId)}.`); + this.onToast(t('“{title}” is already a substep of step {number}.', { title: step.title || t('Untitled step'), number: numbers.get(targetStepId) })); return; } @@ -2163,7 +2164,7 @@ class GuideEditor { this.pushCanvasHistory(before); await this.reload(stepId); - this.onToast(`“${step.title || 'Untitled step'}” is now a substep of step ${numbers.get(targetStepId)}.`); + this.onToast(t('“{title}” is now a substep of step {number}.', { title: step.title || t('Untitled step'), number: numbers.get(targetStepId) })); } async duplicateSelectedStep() { @@ -2171,7 +2172,7 @@ class GuideEditor { if (!step) return; const copy = clone(step); copy.stepId = undefined; - copy.title = copy.title ? `${copy.title} copy` : 'Untitled step copy'; + copy.title = copy.title ? `${copy.title} copy` : t('Untitled step copy'); const image = await this.currentStepImageToBase64(); const newStep = await api.step.add({ guideId: this.guideId, @@ -2184,7 +2185,7 @@ class GuideEditor { position: this.steps.findIndex((s) => s.stepId === step.stepId) + 1, }); await this.reload(newStep.stepId); - this.onToast('Step duplicated.'); + this.onToast(t('Step duplicated.')); } /** @@ -2204,7 +2205,7 @@ class GuideEditor { async retakeScreenshot(stepId, mode = 'fullscreen', delayMs = RETAKE_DELAY_MS) { if (stepId && stepId !== this.selectedStepId) await this.selectStep(stepId); if ((await api.capture.state())?.active) { - this.onToast('Stop the recording session before retaking a screenshot.', { error: true }); + this.onToast(t('Stop the recording session before retaking a screenshot.'), { error: true }); return; } await this.replaceStepScreenshot(stepId, (step) => api.step.retake({ guideId: this.guideId, stepId: step.stepId, mode, delayMs }), 'retaken'); @@ -2234,16 +2235,15 @@ class GuideEditor { if (before) this.pushCanvasHistory(before); await this.reload(step.stepId); const kept = (result.step.annotations || []).length; - const blurs = result.removedBlurs ? ' Private details will be checked again before publishing.' : ''; - this.onToast(kept - ? `Screenshot ${verb}. Its annotations were kept, so check they still line up.${blurs} Press Ctrl+Z to undo.` - : `Screenshot ${verb}.${blurs} Press Ctrl+Z to undo.`); + const done = verb === 'retaken' ? t('Screenshot retaken.') : t('Screenshot replaced.'); + const blurs = result.removedBlurs ? ` ${t('Private details will be checked again before publishing.')}` : ''; + this.onToast(`${done}${kept ? ` ${t('Its annotations were kept, so check they still line up.')}` : ''}${blurs} ${t('Press Ctrl+Z to undo.')}`); } async deleteSelectedStep() { const step = this.currentStep; if (!step) return; - const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' }); + const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: t('Delete') }); if (!ok) return; if (this.pendingSave) await this.flushStep(); const order = this.steps.map((s) => s.stepId); @@ -2254,7 +2254,7 @@ class GuideEditor { || this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1] || null; await this.reload(next && next.stepId); - this.onToast('Step deleted. Press Ctrl+Z to undo.'); + this.onToast(t('Step deleted. Press Ctrl+Z to undo.')); } /** Move the current step, with its substeps, past the neighbouring step and its substeps. */ @@ -2283,7 +2283,7 @@ class GuideEditor { try { await api.step.reorder({ guideId: this.guideId, order: moved.order, parents }); } catch (err) { - this.onToast(`Could not move the step: ${err.message || err}`, { error: true }); + this.onToast(t('Could not move the step: {error}', { error: err.message || err }), { error: true }); return false; } this.pushCanvasHistory(before); @@ -2298,7 +2298,7 @@ class GuideEditor { const order = this.steps.map((s) => s.stepId); const parentOf = outline.parentMap(this.steps); const numbers = stepNumberMap(this.steps); - const nameOf = (id) => `${numbers.get(id)} ${this.stepMap.get(id).title || 'Untitled step'}`; + const nameOf = (id) => `${numbers.get(id)} ${this.stepMap.get(id).title || t('Untitled step')}`; const index = order.indexOf(stepId); const blockEnd = index + outline.subtreeOf(order, parentOf, stepId).length; const positions = []; @@ -2314,19 +2314,19 @@ class GuideEditor { levels.set(parentId || '', { value: String(depth), depth: parentId ? outline.depthOf(result.parentOf, parentId) + 1 : 0, - label: parentId ? `Substep of ${nameOf(parentId)}` : 'Top level', + label: parentId ? `Substep of ${nameOf(parentId)}` : t('Top level'), }); } positions.push({ value: String(gap), - label: gap === 0 ? 'At the top' : `After ${nameOf(order[gap - 1])}`, + label: gap === 0 ? t('At the top') : `After ${nameOf(order[gap - 1])}`, current: gap === index, levels: [...levels.values()].sort((a, b) => a.depth - b.depth), }); } const choice = await dialogs.showMoveStepDialog({ - title: `Move “${step.title || 'Untitled step'}”`, - hint: this.getStepDescendantIds(stepId).length ? 'Its substeps move with it.' : '', + title: t('Move “{title}”', { title: step.title || t('Untitled step') }), + hint: this.getStepDescendantIds(stepId).length ? t('Its substeps move with it.') : '', positions, currentDepth: outline.depthOf(parentOf, stepId), }); @@ -2341,10 +2341,10 @@ class GuideEditor { ? outline.moveSubtree(nowOrder, outline.parentMap(this.steps), stepId, nowGap, Number(choice.depth)) : null; if (!moved) { - this.onToast('The guide changed while you were choosing, so the step was not moved. Try Move to… again.', { error: true }); + this.onToast(t('The guide changed while you were choosing, so the step was not moved. Try Move to… again.'), { error: true }); return; } - if (await this.applyStepMove(stepId, moved)) this.onToast('Step moved. Press Ctrl+Z to undo.'); + if (await this.applyStepMove(stepId, moved)) this.onToast(t('Step moved. Press Ctrl+Z to undo.')); } /** Where a dragged step would land for a pointer at (x, y): { gap, depth, moved } with moved null if refused. */ @@ -2401,7 +2401,7 @@ class GuideEditor { this.endStepDrag(); if (!target) return; if (!target.moved) { - this.onToast('A step can’t be dropped inside its own substeps.', { error: true }); + this.onToast(t('A step can’t be dropped inside its own substeps.'), { error: true }); return; } await this.applyStepMove(stepId, target.moved); @@ -2435,7 +2435,7 @@ class GuideEditor { if (!result || !result.ok) return; const last = result.steps && result.steps[result.steps.length - 1]; await this.reload(last ? last.stepId : this.selectedStepId); - this.onToast('Images imported.'); + this.onToast(t('Images imported.')); } async captureStep(mode, delayMs = null) { @@ -2444,7 +2444,7 @@ class GuideEditor { : await api.capture.shoot({ guideId: this.guideId, mode, delayMs }); if (result && result.ok) { await this.reload(result.step.stepId); - this.onToast('Captured.'); + this.onToast(t('Captured.')); } else if (result && result.reason) { this.onToast(result.reason, { error: true }); } @@ -2455,15 +2455,15 @@ class GuideEditor { const rect = event.target.getBoundingClientRect(); const session = (await api.capture.state())?.active; const items = [ - { label: 'Capture full screen', action: () => this.captureStep('fullscreen') }, - { label: 'Capture window', action: () => this.captureStep('window') }, - { label: 'Capture region…', action: () => this.captureStep('region') }, + { label: t('Capture full screen'), action: () => this.captureStep('fullscreen') }, + { label: t('Capture window'), action: () => this.captureStep('window') }, + { label: t('Capture region…'), action: () => this.captureStep('region') }, 'sep', - { label: 'Paste image as step', action: () => this.pasteClipboardStep() }, - { label: 'Import images…', action: () => this.importImageSteps() }, + { label: t('Paste image as step'), action: () => this.pasteClipboardStep() }, + { label: t('Import images…'), action: () => this.importImageSteps() }, ]; if (!session) { - items.push('sep', { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() }); + items.push('sep', { label: t('Start capture session (hotkey)'), action: () => this.startCaptureSession() }); } contextMenu(rect.left, rect.bottom + 4, items); } @@ -2472,15 +2472,15 @@ class GuideEditor { const result = await api.step.fromClipboard({ guideId: this.guideId }); if (result && result.ok) { await this.reload(result.step.stepId); - this.onToast('Image pasted as a new step.'); + this.onToast(t('Image pasted as a new step.')); } else { - this.onToast(result?.reason || 'Clipboard has no image.', { error: true }); + this.onToast(result?.reason || t('Clipboard has no image.'), { error: true }); } } async shareAsFile() { const result = await api.archive.export({ guideId: this.guideId }); - if (result && result.ok) this.onToast(`Shared to ${result.path}`); + if (result && result.ok) this.onToast(t('Shared to {path}', { path: result.path })); } async openBackupsDialog() { @@ -2488,20 +2488,20 @@ class GuideEditor { const snapshots = await api.snapshots.list({ guideId: this.guideId }); const restore = async (name) => { const ok = await confirmDialog( - `Restore "${name}"? Current state is snapshotted first, so this is undoable.`, - { okLabel: 'Restore' }, + t('Restore “{name}”? Current state is snapshotted first, so this is undoable.', { name }), + { okLabel: t('Restore') }, ); if (!ok) return false; await api.snapshots.restore({ guideId: this.guideId, name }); await this.reload(); - this.onToast('Snapshot restored.'); + this.onToast(t('Snapshot restored.')); return true; }; await dialogs.showBackupsDialog({ snapshots, onCreate: async () => { await api.snapshots.create({ guideId: this.guideId, label: 'manual' }); - this.onToast('Snapshot created.'); + this.onToast(t('Snapshot created.')); return api.snapshots.list({ guideId: this.guideId }); }, onRestore: restore, @@ -2521,7 +2521,7 @@ class GuideEditor { try { diff = await api.snapshots.compare({ guideId: this.guideId, name }); } catch (err) { - this.onToast(`Could not compare with this snapshot: ${err.message || err}`, { error: true }); + this.onToast(t('Could not compare with this snapshot: {error}', { error: err.message || err }), { error: true }); return false; } return window.StepForgeCompare.showSnapshotCompare({ @@ -2536,13 +2536,13 @@ class GuideEditor { async openGuidePlaceholders() { if (!this.guide) return; await dialogs.showPlaceholdersDialog({ - title: 'Guide placeholders', - hint: 'Use [[Name]] in titles, descriptions, and blocks. Guide values override global ones.', + title: t('Guide placeholders'), + hint: t('Use [[Name]] in titles, descriptions, and blocks. Guide values override global ones.'), values: this.guide.placeholders || {}, onSave: async (values) => { this.guide.placeholders = values; await api.guide.save({ guide: this.guide }); - this.onToast('Placeholders saved.'); + this.onToast(t('Placeholders saved.')); }, }); } @@ -2561,7 +2561,7 @@ class GuideEditor { this.guide.descriptionHtml = textToHtml(description); this.guide.cloud = { ...(this.guide.cloud || {}), sharingEnabled }; await api.guide.save({ guide: this.guide }); - this.onToast('Guide information saved.'); + this.onToast(t('Guide information saved.')); }, onCloudSnapshots: async () => { try { @@ -2569,22 +2569,22 @@ class GuideEditor { await dialogs.showCloudSnapshotsDialog({ snapshots, onRestore: async (snapshot) => { - const ok = await confirmDialog('Restore this cloud snapshot? Your current local guide is kept as a cloud snapshot first.', { okLabel: 'Restore' }); + const ok = await confirmDialog(t('Restore this cloud snapshot? Your current local guide is kept as a cloud snapshot first.'), { okLabel: t('Restore') }); if (!ok) return; await api.cloud.restore({ guideId: this.guideId, versionId: snapshot.id }); await this.reload(); - this.onToast('Cloud snapshot restored.'); + this.onToast(t('Cloud snapshot restored.')); }, }); } catch (err) { this.onToast(err.message); } }, onDeleteCloudSnapshots: async () => { - const ok = await confirmDialog('Remove every cloud snapshot for this guide? This also turns off cloud sync for the guide. The local guide is kept.', { danger: true, okLabel: 'Remove cloud copies' }); + const ok = await confirmDialog(t('Remove every cloud snapshot for this guide? This also turns off cloud sync for the guide. The local guide is kept.'), { danger: true, okLabel: t('Remove cloud copies') }); if (!ok) return; try { await api.cloud.deleteGuideSnapshots({ guideId: this.guideId }); this.guide.cloud = { ...(this.guide.cloud || {}), sharingEnabled: false }; - this.onToast('Cloud copies removed.'); + this.onToast(t('Cloud copies removed.')); } catch (err) { this.onToast(err.message); } }, }); @@ -2619,7 +2619,7 @@ class GuideEditor { const saved = await this.flushStep(step); if (saved) after.push(saved); const undo = this.pushStyleHistory(before, after); - this.onToast(`Style applied to all ${source.type} annotations in this step.${undo}`); + this.onToast(`${t('Style applied to all “{type}” annotations in this step.', { type: ANNOTATION_TYPE_LABELS[source.type] || source.type })}${undo}`); } else { for (const step of this.steps) { const touched = restyle(step); @@ -2630,7 +2630,7 @@ class GuideEditor { } const undo = this.pushStyleHistory(before, after); await this.reload(this.selectedStepId); - this.onToast(`Style applied to all ${source.type} annotations in the guide.${undo}`); + this.onToast(`${t('Style applied to all “{type}” annotations in the guide.', { type: ANNOTATION_TYPE_LABELS[source.type] || source.type })}${undo}`); } } @@ -2638,7 +2638,7 @@ class GuideEditor { pushStyleHistory(before, after) { if (!before.length) return ''; this.pushCanvasHistory(this.multiStepRecord(before, after)); - return ' Press Ctrl+Z to undo.'; + return ` ${t('Press Ctrl+Z to undo.')}`; } async startCaptureSession() { @@ -2648,13 +2648,13 @@ class GuideEditor { async pauseCaptureSession() { await api.capture.session({ action: 'pause', guideId: this.guideId }); - this.onToast('Capture paused.'); + this.onToast(t('Capture paused.')); this.emitMeta(); } async resumeCaptureSession() { await api.capture.session({ action: 'resume', guideId: this.guideId }); - this.onToast('Capture resumed.'); + this.onToast(t('Capture resumed.')); this.emitMeta(); } @@ -2755,12 +2755,12 @@ class GuideEditor { }, onImport: async () => { const res = await api.templates.import(); - if (res?.ok) this.onToast(`Imported template for ${res.format}.`); + if (res?.ok) this.onToast(t('Imported template for {format}.', { format: res.format })); return api.templates.list({ format }); }, onExport: async (name) => { const res = await api.templates.export({ format, name }); - if (res?.ok) this.onToast('Template shared as .sfglt.'); + if (res?.ok) this.onToast(t('Template shared as .sfglt.')); }, }); } @@ -2770,7 +2770,7 @@ class GuideEditor { const preview = await api.export.preview({ guideId: this.guideId, format, options, brandProfileId }); if (preview && preview.file) { await api.shell.openProduced({ target: preview.file }); // open in default viewer - this.onToast('Preview opened (first steps only).'); + this.onToast(t('Preview opened (first steps only).')); } return true; }, @@ -2789,7 +2789,7 @@ class GuideEditor { return; } if (publishRequest) { - await showPublishProgressDialog({ api, guideId: this.guideId, guideTitle: this.guide?.title || 'This guide', request: publishRequest }); + await showPublishProgressDialog({ api, guideId: this.guideId, guideTitle: this.guide?.title || t('This guide'), request: publishRequest }); // Publishing blurs private details in screenshots that weren't checked yet. await this.reload(); } @@ -2816,7 +2816,7 @@ class GuideEditor { async openLinkedGuide() { if (!this.guide || !this.guide.linkedSource) { - await dialogs.showInfoDialog('Linked Guide', 'This guide is stored locally and is not linked to a shared archive.'); + await dialogs.showInfoDialog(t('Linked Guide'), 'This guide is stored locally and is not linked to a shared archive.'); return; } const library = await api.library.list(); @@ -2827,13 +2827,13 @@ class GuideEditor { lock: locked ? { acquired: false } : { acquired: true }, onSave: async () => { const result = await this.runLinkedSave({ force: false }); - if (result.saved) this.toastLinkedSave(result, 'Linked archive saved.'); - else this.onToast('Could not save linked archive.', { error: true }); + if (result.saved) this.toastLinkedSave(result, t('Linked archive saved.')); + else this.onToast(t('Could not save linked archive.'), { error: true }); }, onForceSave: async () => { const result = await this.runLinkedSave({ force: true }); - if (result.saved) this.toastLinkedSave(result, 'Linked archive force-saved.'); - else this.onToast('Could not save linked archive.', { error: true }); + if (result.saved) this.toastLinkedSave(result, t('Linked archive force-saved.')); + else this.onToast(t('Could not save linked archive.'), { error: true }); }, onOpenArchive: async () => { await api.shell.revealLinkedArchive({ guideId: this.guideId }); @@ -2843,11 +2843,11 @@ class GuideEditor { async openQuickActions() { const commands = [ - { kind: 'cmd', label: 'New guide', description: 'Create a blank guide', action: () => this.onBack('new') }, - { kind: 'cmd', label: 'Export', description: 'Export the current guide', action: () => this.openExportDialog() }, - { kind: 'cmd', label: 'Settings', description: 'Open application settings', action: () => this.openSettings() }, - { kind: 'cmd', label: 'Linked guide', description: 'Show linked archive details', action: () => this.openLinkedGuide() }, - { kind: 'cmd', label: 'Start capture session', description: 'Enable hotkey capture for this guide', action: () => this.startCaptureSession() }, + { kind: 'cmd', label: t('New guide'), description: t('Create a blank guide'), action: () => this.onBack('new') }, + { kind: 'cmd', label: t('Export'), description: t('Export the current guide'), action: () => this.openExportDialog() }, + { kind: 'cmd', label: t('Settings'), description: t('Open application settings'), action: () => this.openSettings() }, + { kind: 'cmd', label: t('Linked guide'), description: t('Show linked archive details'), action: () => this.openLinkedGuide() }, + { kind: 'cmd', label: t('Start capture session'), description: t('Enable hotkey capture for this guide'), action: () => this.startCaptureSession() }, ]; await dialogs.showQuickActions({ @@ -2897,15 +2897,15 @@ class GuideEditor { // autosave during the batch saves the replacement, not the old text. for (const step of result.steps) saved.push(this.commitBatchSave(await api.step.save({ guideId: this.guideId, step }))); } catch (err) { - this.onToast(`Could not save every step: ${err.message || err}`, { error: true }); + this.onToast(t('Could not save every step: {error}', { error: err.message || err }), { error: true }); } if (saved.length) this.pushCanvasHistory(this.multiStepRecord(result.before.slice(0, saved.length), saved)); await this.reload(this.selectedStepId); const skipped = result.refused.length + result.stale.length; - const stepWord = saved.length === 1 ? 'step' : 'steps'; + const skippedText = skipped ? ` ${t('{count} skipped.', { count: skipped })}` : ''; this.onToast(saved.length - ? `Replaced ${result.applied} in ${saved.length} ${stepWord}.${skipped ? ` ${skipped} skipped.` : ''} Press Ctrl+Z to undo.` - : `Nothing was replaced.${skipped ? ` ${skipped} skipped.` : ''}`); + ? `${tn(saved.length, 'Replaced {applied} in {count} step.', 'Replaced {applied} in {count} steps.', { applied: result.applied })}${skippedText} ${t('Press Ctrl+Z to undo.')}` + : `${t('Nothing was replaced.')}${skippedText}`); return saved.length ? result.applied : 0; } @@ -3077,12 +3077,14 @@ class GuideEditor { } if (before.length) this.pushCanvasHistory(this.multiStepRecord(before, after)); await this.reload(this.selectedStepId); - const plural = (n) => `${n} step${n === 1 ? '' : 's'}`; - let text = before.length ? `Annotation pasted onto ${plural(before.length)}. Press Ctrl+Z to undo.` : 'No steps changed.'; + let text = before.length + ? `${tn(before.length, 'Annotation pasted onto {count} step.', 'Annotation pasted onto {count} steps.')} ${t('Press Ctrl+Z to undo.')}` + : t('No steps changed.'); if (skipped.length) { - text += ` Skipped ${plural(skipped.length)} (already there, no image, or a different image shape): ${skipped.join(', ')}.`; + text += ` ${tn(skipped.length, 'Skipped {count} step (already there, no image, or a different image shape): {steps}.', + 'Skipped {count} steps (already there, no image, or a different image shape): {steps}.', { steps: skipped.join(', ') })}`; } - if (failed) text += ` Stopped early: ${failed.message || failed}`; + if (failed) text += ` ${t('Stopped early: {error}', { error: failed.message || failed })}`; this.onToast(text, failed ? { error: true } : undefined); } @@ -3111,7 +3113,7 @@ class GuideEditor { async onCanvasCrop(rect) { const step = this.currentStep; if (!step || !step.image) return; - const ok = await confirmDialog('Crop the working image to the selected area?'); + const ok = await confirmDialog(t('Crop the working image to the selected area?')); if (!ok) return; this.saveStepDebounced.cancel(); const snapshot = this.beforeCanvasSnapshot || await this.snapshotCurrentStep(true); @@ -3158,7 +3160,7 @@ class GuideEditor { step.annotations = nextAnnotations; await this.flushStep(step); await this.reload(step.stepId); - this.onToast('Image cropped.'); + this.onToast(t('Image cropped.')); } async editAnnotationText(ann) { @@ -3177,8 +3179,8 @@ class GuideEditor { if (persist) this.saveStepDebounced(); }; const value = await dialogs.promptText({ - title: ann.type === 'tooltip' ? 'Edit tooltip' : 'Edit text', - label: 'Text', + title: ann.type === 'tooltip' ? t('Edit tooltip') : t('Edit text'), + label: t('Text'), value: originalText, multiline: true, onInput: applyText, @@ -3223,7 +3225,7 @@ class GuideEditor { } case 'createLink': { const selectedText = window.getSelection().toString(); - const text = selectedText || 'Text'; + const text = selectedText || t('Text'); document.execCommand('insertText', false, `[${text}](Link)`); break; } @@ -3308,7 +3310,7 @@ class GuideEditor { this.annotationClipboardAspect = size && size.width > 0 && size.height > 0 ? size.width / size.height : imgW > 0 && imgH > 0 ? imgW / imgH : 0; this.renderStepBulkBar(); - this.onToast('Annotation copied.'); + this.onToast(t('Annotation copied.')); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { diff --git a/app/renderer/github.js b/app/renderer/github.js index 7019f6f3..21610bce 100644 --- a/app/renderer/github.js +++ b/app/renderer/github.js @@ -25,7 +25,7 @@ function githubExpiryLabel(days) { function githubWhen(iso) { const date = new Date(iso); return Number.isNaN(date.getTime()) ? 'an unknown date' - : date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + : date.toLocaleString(dateLocale, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } function githubTimeLeft(iso) { diff --git a/app/renderer/index.html b/app/renderer/index.html index cd245ad4..305fad46 100644 --- a/app/renderer/index.html +++ b/app/renderer/index.html @@ -10,17 +10,23 @@
-
StepForge
+
StepForge
- + - +
+ + + + + + diff --git a/app/renderer/util.js b/app/renderer/util.js index b1311d05..19dc735b 100644 --- a/app/renderer/util.js +++ b/app/renderer/util.js @@ -2,6 +2,13 @@ /* Small DOM + app helpers shared by all renderer modules. */ +// Interface text in the language the main process loaded this page in +// (?lang=, Settings → General → Language). core/i18n.js explains t and tn. +const { t, tn, language: uiLanguage } = StepForgeI18n.createTranslator( + new URLSearchParams(location.search).get('lang'), globalThis.StepForgeLocales); +// Dates follow the interface language; in English, the system's date format. +const dateLocale = uiLanguage === 'en' ? undefined : uiLanguage; + /** Element builder: el('div.cls#id', {attrs/on*}, ...children) */ function el(spec, props = {}, ...children) { const [tag, ...rest] = spec.split(/(?=[.#])/); @@ -32,7 +39,7 @@ function el(spec, props = {}, ...children) { /** The "Beta" chip beside features that are new and may still change (AI, Confluence, Wiki.js). */ function betaBadge(text = 'Beta') { - return el('span.settings-chip.beta-badge', {}, text); + return el('span.settings-chip.beta-badge', {}, t(text)); } /** Settings row: a title and optional description beside its control. */ @@ -135,7 +142,7 @@ function openModal({ title, body, footer, wide = false, onClose }) { }; document.addEventListener('keydown', escHandler, true); const modal = el('div.modal', { className: `modal${wide ? ' wide' : ''}` }, - el('header', {}, title, el('span.close', { onClick: dismiss, title: 'Close (Esc)' }, '✕')), + el('header', {}, title, el('span.close', { onClick: dismiss, title: t('Close (Esc)') }, '✕')), el('div.body', {}, body), footer ? el('footer', {}, footer) : null, ); @@ -148,13 +155,13 @@ function openModal({ title, body, footer, wide = false, onClose }) { } /** Simple confirm dialog returning a promise. */ -function confirmDialog(message, { danger = false, okLabel = 'OK' } = {}) { +function confirmDialog(message, { danger = false, okLabel = t('OK') } = {}) { return new Promise((resolve) => { const { close } = openModal({ - title: 'Confirm', + title: t('Confirm'), body: el('div', {}, message), footer: [ - el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'), + el('button', { onClick: () => { close(); resolve(false); } }, t('Cancel')), el('button', { className: `primary${danger ? ' danger' : ''}`, onClick: () => { close(); resolve(true); }, @@ -165,11 +172,19 @@ function confirmDialog(message, { danger = false, okLabel = 'OK' } = {}) { }); } +/** A review interval from core/review.js ("3 months") in the interface language. */ +function intervalText(days) { + const known = window.StepForgeReview.REVIEW_INTERVALS.some((i) => i.days === days); + if (known && days % 365 === 0) return tn(days / 365, '{count} year', '{count} years'); + if (known) return tn(days / 30, '{count} month', '{count} months'); + return tn(days, '{count} day', '{count} days'); +} + /** "Oct 20", with the year when it isn't this year; '' for no date. */ function shortDate(iso) { const date = new Date(iso || ''); if (Number.isNaN(date.getTime())) return ''; - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' }); + return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric', year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' }); } /** @@ -198,7 +213,7 @@ function confirmReplaceEdits(api, edited, { where, history }) { }); } -function promptDialog(title, { value = '', label = 'Name' } = {}) { +function promptDialog(title, { value = '', label = t('Name') } = {}) { return new Promise((resolve) => { const input = el('input', { type: 'text', value }); const done = (v) => { close(); resolve(v); }; @@ -206,8 +221,8 @@ function promptDialog(title, { value = '', label = 'Name' } = {}) { title, body: el('div.form-row', {}, el('label', {}, label), input), footer: [ - el('button', { onClick: () => done(null) }, 'Cancel'), - el('button.primary', { onClick: () => done(input.value.trim() || null) }, 'OK'), + el('button', { onClick: () => done(null) }, t('Cancel')), + el('button.primary', { onClick: () => done(input.value.trim() || null) }, t('OK')), ], onClose: () => resolve(null), }); @@ -240,7 +255,7 @@ function contextMenu(x, y, items) { const subItems = typeof item.submenu === 'function' ? item.submenu() : item.submenu; const sub = el('div.ctx-menu.ctx-submenu'); if (!subItems.length) { - sub.append(el('div.mi.disabled', {}, 'Nothing else to choose')); + sub.append(el('div.mi.disabled', {}, t('Nothing else to choose'))); } for (const subItem of subItems) { if (subItem === 'sep') { sub.append(el('hr')); continue; } diff --git a/core/i18n.js b/core/i18n.js new file mode 100644 index 00000000..05527fff --- /dev/null +++ b/core/i18n.js @@ -0,0 +1,97 @@ +'use strict'; + +/** + * Interface languages: Settings → General → Language. + * + * The English text is the key: code calls t('Settings') and each catalog in + * core/locales/ maps that text to its translation. Text a catalog doesn't + * have yet shows in English, so a missing translation never breaks the UI. + * `{name}` in a text is filled from the params object. Text that changes with + * a number uses tn(count, singular, plural), and its catalog entry is an + * object with one string per plural category of that language (Intl.PluralRules). + * + * The main process and the sandboxed renderer (which loads this file and the + * catalogs with script tags) share it. Exported guides stay in English. + */ + +(function attachI18n(root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } + if (root) { + root.StepForgeI18n = api; + } +})(typeof window !== 'undefined' ? window : null, () => { + // `store` is the Microsoft Store language tag (appxmanifest Resources). + const LANGUAGES = [ + { code: 'en', name: 'English', store: 'en-US' }, + { code: 'fr', name: 'Français', store: 'fr-FR' }, + { code: 'es', name: 'Español', store: 'es-ES' }, + { code: 'ru', name: 'Русский', store: 'ru-RU' }, + { code: 'zh-CN', name: '简体中文', store: 'zh-CN' }, + { code: 'ja', name: '日本語', store: 'ja-JP' }, + ]; + const CODES = LANGUAGES.map((l) => l.code); + + /** The supported language for a locale such as "fr-CA" or "zh-Hans-CN", or null. */ + function matchLocale(locale) { + const tag = String(locale || '').toLowerCase().replace(/_/g, '-'); + if (!tag) return null; + const exact = CODES.find((code) => code.toLowerCase() === tag); + if (exact) return exact; + const [base] = tag.split('-'); + // Only Simplified Chinese is translated: Traditional Chinese readers get English. + if (base === 'zh') return /-(hant|tw|hk|mo)(-|$)/.test(tag) ? null : 'zh-CN'; + return CODES.find((code) => code === base) || null; + } + + /** + * The language to show. `setting` is a language code or 'system'; with + * 'system', the first of the OS's preferred languages that StepForge has. + */ + function resolveLanguage(setting, systemLocales = []) { + if (CODES.includes(setting)) return setting; + for (const locale of systemLocales || []) { + const match = matchLocale(locale); + if (match) return match; + } + return 'en'; + } + + function fill(text, params) { + if (!params) return text; + return text.replace(/\{(\w+)\}/g, (whole, name) => (params[name] == null ? whole : String(params[name]))); + } + + /** + * { t, tn, language } for `language`. `catalogs` maps a language code to + * its messages. + */ + function createTranslator(language, catalogs = {}) { + const code = CODES.includes(language) ? language : 'en'; + const messages = (code !== 'en' && catalogs[code]) || {}; + let plurals = null; + const category = (count) => { + plurals = plurals || new Intl.PluralRules(code); + return plurals.select(count); + }; + const t = (text, params) => { + const found = messages[text]; + return fill(typeof found === 'string' && found ? found : text, params); + }; + // `plural` is the key; `{count}` is filled in unless params set it. + const tn = (count, singular, plural, params) => { + const values = { count, ...params }; + const found = messages[plural]; + if (found && typeof found === 'object') { + const form = found[category(count)] || found.other; + if (form) return fill(form, values); + } + return fill(count === 1 ? singular : plural, values); + }; + return { t, tn, language: code }; + } + + return { LANGUAGES, createTranslator, matchLocale, resolveLanguage }; +}); diff --git a/core/locales/es.js b/core/locales/es.js new file mode 100644 index 00000000..4288d77e --- /dev/null +++ b/core/locales/es.js @@ -0,0 +1,8 @@ +'use strict'; + +// Spanish interface text. Keys are the English text; core/i18n.js explains the format. +(function attachLocale(root, messages) { + if (typeof module !== 'undefined' && module.exports) module.exports = messages; + if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['es'] = messages; +})(typeof window !== 'undefined' ? window : null, { +}); diff --git a/core/locales/fr.js b/core/locales/fr.js new file mode 100644 index 00000000..ccb65395 --- /dev/null +++ b/core/locales/fr.js @@ -0,0 +1,8 @@ +'use strict'; + +// French interface text. Keys are the English text; core/i18n.js explains the format. +(function attachLocale(root, messages) { + if (typeof module !== 'undefined' && module.exports) module.exports = messages; + if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['fr'] = messages; +})(typeof window !== 'undefined' ? window : null, { +}); diff --git a/core/locales/index.js b/core/locales/index.js new file mode 100644 index 00000000..c63ea0e1 --- /dev/null +++ b/core/locales/index.js @@ -0,0 +1,11 @@ +'use strict'; + +// Every interface translation, for the main process. The renderer loads the +// same files with script tags (app/renderer/index.html). +module.exports = { + fr: require('./fr'), + es: require('./es'), + ru: require('./ru'), + 'zh-CN': require('./zh-CN'), + ja: require('./ja'), +}; diff --git a/core/locales/ja.js b/core/locales/ja.js new file mode 100644 index 00000000..79c016cf --- /dev/null +++ b/core/locales/ja.js @@ -0,0 +1,8 @@ +'use strict'; + +// Japanese interface text. Keys are the English text; core/i18n.js explains the format. +(function attachLocale(root, messages) { + if (typeof module !== 'undefined' && module.exports) module.exports = messages; + if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['ja'] = messages; +})(typeof window !== 'undefined' ? window : null, { +}); diff --git a/core/locales/ru.js b/core/locales/ru.js new file mode 100644 index 00000000..fcd4032a --- /dev/null +++ b/core/locales/ru.js @@ -0,0 +1,8 @@ +'use strict'; + +// Russian interface text. Keys are the English text; core/i18n.js explains the format. +(function attachLocale(root, messages) { + if (typeof module !== 'undefined' && module.exports) module.exports = messages; + if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['ru'] = messages; +})(typeof window !== 'undefined' ? window : null, { +}); diff --git a/core/locales/zh-CN.js b/core/locales/zh-CN.js new file mode 100644 index 00000000..49ad820f --- /dev/null +++ b/core/locales/zh-CN.js @@ -0,0 +1,8 @@ +'use strict'; + +// Simplified Chinese interface text. Keys are the English text; core/i18n.js explains the format. +(function attachLocale(root, messages) { + if (typeof module !== 'undefined' && module.exports) module.exports = messages; + if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['zh-CN'] = messages; +})(typeof window !== 'undefined' ? window : null, { +}); diff --git a/core/settings.js b/core/settings.js index 9eb4caa8..0a715a82 100644 --- a/core/settings.js +++ b/core/settings.js @@ -6,7 +6,9 @@ const { writeJsonSync, readJsonIfExists, deepClone } = require('./util'); const DEFAULT_SETTINGS = { schemaVersion: 1, appearance: 'system', // system | light | dark - language: 'en', + // Interface language: 'system' follows the OS, or a code from LANGUAGES in + // core/i18n.js. Exported guides stay in English. + language: 'system', // Explicit opt-in; credentials live in OS-encrypted storage. `provider` is the // service that syncs: google | onedrive | dropbox | webdav. cloud: { enabled: false, provider: 'google' }, diff --git a/package.json b/package.json index e6e61409..e04b74a5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "setup:google-dev": "node scripts/setup-dev-google-oauth.js", "test": "node scripts/run-unit-tests.js", "sample": "node scripts/make-sample-guide.js", + "translations": "node scripts/check-translations.js", "icons": "node scripts/make-icons.js", "package:windows": "node scripts/package-windows.js", "package:windows:store": "node scripts/package-windows-store.js", diff --git a/scripts/check-translations.js b/scripts/check-translations.js new file mode 100644 index 00000000..fd3df9e2 --- /dev/null +++ b/scripts/check-translations.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +'use strict'; + +// Lists interface text that a translation in core/locales/ doesn't have yet, +// and translations whose {placeholders} differ from the English text. +// Untranslated text shows in English, so this is a to-do list, not a failure. +// +// npm run translations # every language +// npm run translations -- fr # one language +// +// It finds text in t('…') and tn(count, '…', '…') calls with plain string +// literals, and in index.html's data-i18n-* attributes. Text built at run +// time can't be found, so keep each message one literal. + +const fs = require('node:fs'); +const path = require('node:path'); +const { LANGUAGES } = require('../core/i18n'); +const LOCALES = require('../core/locales'); + +const ROOT = path.resolve(__dirname, '..'); +const SOURCES = ['app/main.js', ...fs.readdirSync(path.join(ROOT, 'app/renderer')) + .filter((name) => name.endsWith('.js')).map((name) => `app/renderer/${name}`)]; + +const LITERAL = String.raw`'((?:[^'\\\n]|\\.)*)'`; +const CALLS = [ + { re: new RegExp(String.raw`(? text.replace(/\\(.)/g, '$1'); +const placeholders = (text) => [...String(text).matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort().join(','); + +/** Map of English text → { plural, singular?, where }. */ +function findMessages() { + const found = new Map(); + for (const file of SOURCES) { + const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + for (const { re, plural } of CALLS) { + for (const match of source.matchAll(re)) { + const line = source.slice(0, match.index).split('\n').length; + const key = unescape(plural ? match[2] : match[1]); + if (!found.has(key)) found.set(key, { plural, singular: plural ? unescape(match[1]) : null, where: `${file}:${line}` }); + } + } + } + const html = fs.readFileSync(path.join(ROOT, 'app/renderer/index.html'), 'utf8'); + for (const match of html.matchAll(/(title|placeholder|aria-label)="([^"]+)"([^>]*?)data-i18n-(title|placeholder|label)/g)) { + found.set(match[2], { plural: false, where: 'app/renderer/index.html' }); + } + return found; +} + +function check(codes) { + const messages = findMessages(); + let problems = 0; + for (const code of codes) { + const catalog = LOCALES[code] || {}; + const missing = []; + const wrong = []; + for (const [key, info] of messages) { + const value = catalog[key]; + if (value == null || value === '') { missing.push(`${info.where} ${key}`); continue; } + const forms = typeof value === 'object' ? Object.values(value) : [value]; + if (info.plural !== (typeof value === 'object')) wrong.push(`${key} (${info.plural ? 'needs plural forms' : 'is not plural'})`); + for (const form of forms) { + const expected = placeholders(key); + // A plural form may leave out {count}, as "one" forms often do. + if (placeholders(form) !== expected && placeholders(form) !== expected.replace(/(^|,)count(,|$)/, '$1').replace(/^,|,$/, '')) { + wrong.push(`${key} (placeholders differ: ${form})`); + } + } + } + const unused = Object.keys(catalog).filter((key) => !messages.has(key)); + problems += missing.length + wrong.length; + console.log(`${code}: ${messages.size - missing.length} of ${messages.size} translated`); + for (const line of missing) console.log(` missing ${line}`); + for (const line of wrong) console.log(` check ${line}`); + for (const key of unused) console.log(` unused ${key}`); + } + return problems; +} + +if (require.main === module) { + const asked = process.argv.slice(2); + const codes = asked.length ? asked : LANGUAGES.map((l) => l.code).filter((code) => code !== 'en'); + process.exitCode = check(codes) ? 1 : 0; +} + +module.exports = { findMessages }; diff --git a/scripts/package-windows-store.js b/scripts/package-windows-store.js index 8f8e0f42..8099e5a7 100644 --- a/scripts/package-windows-store.js +++ b/scripts/package-windows-store.js @@ -18,6 +18,7 @@ const path = require('node:path'); const { build, Platform, Arch } = require('electron-builder'); const { APP_ID, BUILD_VERSION, createWindowsInstallerConfig } = require('./package-windows'); +const { LANGUAGES } = require('../core/i18n'); const ROOT_DIR = path.resolve(__dirname, '..'); const STORE_DIR = path.join(ROOT_DIR, 'packaging', 'windows', 'store'); @@ -110,7 +111,9 @@ function createStorePackageConfig(outputDir, { identity, manifestPath }) { // Must be a name reserved in Partner Center, or certification fails. displayName: identity.displayName, backgroundColor: 'transparent', - languages: ['en-US'], + // Every interface language, so the Store lists them and Partner Center + // offers a Store listing in each. The first is the default. + languages: LANGUAGES.map((l) => l.store), minVersion: '10.0.17763.0', maxVersionTested: '10.0.26100.0', customManifestPath: manifestPath, diff --git a/tests/unit/ai-visibility.test.js b/tests/unit/ai-visibility.test.js index 05c9b44f..35a7ef30 100644 --- a/tests/unit/ai-visibility.test.js +++ b/tests/unit/ai-visibility.test.js @@ -5,10 +5,10 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { Settings } = require('../../core/settings'); -const { makeTmpDir, rmrf } = require('./helpers'); +const { makeTmpDir, rmrf, rendererI18n } = require('./helpers'); function editorContext({ api = {}, dialogs = {} } = {}) { - const context = { window: { stepforge: api, StepForgeDialogs: dialogs } }; + const context = { ...rendererI18n(), window: { stepforge: api, StepForgeDialogs: dialogs } }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/editor.js'), 'utf8'), context); const editor = Object.create(context.window.GuideEditor.prototype); const step = { stepId: 's1', title: 'Click Save' }; @@ -79,7 +79,7 @@ test('focus defaults are enabled but explicit saved choices remain disabled', t test('AI ▾ sits right before More ▾, asks the editor for its menu, and shows progress while writing', () => { let items; - const context = { + const context = { ...rendererI18n(), window: {}, document: { addEventListener() {} }, clearNode: node => { node.children = []; }, el: (selector, attrs, ...children) => ({ selector, ...attrs, children }), contextMenu: (x, y, next) => { items = next; }, diff --git a/tests/unit/branding-ui.test.js b/tests/unit/branding-ui.test.js index 58846d08..d7d6dd2d 100644 --- a/tests/unit/branding-ui.test.js +++ b/tests/unit/branding-ui.test.js @@ -5,6 +5,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { rendererI18n } = require('./helpers'); /** * The brand profile editor (app/renderer/branding.js) in a small DOM stand-in: @@ -51,7 +52,7 @@ function ui() { } const focused = []; const root = el('div'); - const context = { + const context = { ...rendererI18n(), document: { activeElement: null, getElementById: () => root, addEventListener() {}, removeEventListener() {} }, window: {}, setTimeout, clearTimeout, console, }; @@ -229,7 +230,7 @@ test('editor: annotation colors are off until turned on, and then saved with the test('step editor: new shapes, number badges and tooltips start in the profile’s colors only when it sets its own', async () => { const profiles = []; const api = { branding: { list: async () => profiles } }; - const context = { window: { stepforge: api, StepForgeDialogs: {} }, document: { activeElement: null }, setTimeout, clearTimeout }; + const context = { ...rendererI18n(), window: { stepforge: api, StepForgeDialogs: {} }, document: { activeElement: null }, setTimeout, clearTimeout }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/editor.js'), 'utf8'), context); const editor = Object.create(context.window.GuideEditor.prototype); const styles = () => JSON.parse(JSON.stringify(['rect', 'oval', 'arrow', 'text', 'number', 'tooltip', 'highlight'].map((tool) => editor.defaultStyleForTool(tool)))); diff --git a/tests/unit/capture-drafts.test.js b/tests/unit/capture-drafts.test.js index 4bdf3115..08c03b6d 100644 --- a/tests/unit/capture-drafts.test.js +++ b/tests/unit/capture-drafts.test.js @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { GuideStore } = require('../../core/store'); const { SearchIndex } = require('../../core/search'); -const { makeTmpDir, rmrf } = require('./helpers'); +const { makeTmpDir, rmrf, rendererI18n } = require('./helpers'); function setup(t) { const root = makeTmpDir('capture-drafts'); @@ -88,7 +88,7 @@ test('navigation waits for pending edits and refreshes the library after draft p const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); - const context = { window: {}, document: { addEventListener() {} } }; + const context = { ...rendererI18n(), window: {}, document: { addEventListener() {} } }; const source = fs.readFileSync(path.join(__dirname, '../../app/renderer/app.js'), 'utf8'); vm.runInNewContext(source.replace('\nboot();', '\n'), context); const app = Object.create(context.window.StepForgeApp.prototype); diff --git a/tests/unit/capture-mode-notice.test.js b/tests/unit/capture-mode-notice.test.js index b5d26477..71c38703 100644 --- a/tests/unit/capture-mode-notice.test.js +++ b/tests/unit/capture-mode-notice.test.js @@ -6,6 +6,7 @@ const path = require('node:path'); const vm = require('node:vm'); const CaptureService = require('../../app/capture'); const GnomeCaptureService = require('../../app/platform/linux/gnome-capture'); +const { rendererI18n } = require('./helpers'); /** * Window mode on a Wayland desktop without GNOME records the full screen. @@ -53,7 +54,7 @@ function app() { const toasts = []; // Like the DOM's append(): anything that isn't a node becomes text, so a stray null shows as "null". const node = () => ({ children: [], classList: { add() {}, remove() {} }, append(...kids) { this.children.push(...kids.map((k) => (k && typeof k === 'object' ? k : String(k)))); } }); - const context = vm.createContext({ window: {}, document: { addEventListener() {} } }); + const context = vm.createContext({ ...rendererI18n(), window: {}, document: { addEventListener() {} } }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/util.js'), 'utf8'), context); context.toast = (message, opts) => toasts.push([message, opts]); context.el = (selector, attrs = {}, ...children) => ({ selector, ...attrs, children: children.flat().filter((c) => c != null && c !== false) }); diff --git a/tests/unit/choose-screenshot.test.js b/tests/unit/choose-screenshot.test.js index 80924a4e..293df989 100644 --- a/tests/unit/choose-screenshot.test.js +++ b/tests/unit/choose-screenshot.test.js @@ -6,7 +6,7 @@ const path = require('node:path'); const vm = require('node:vm'); const { GuideStore } = require('../../core/store'); const { forgetScreenshotChecks } = require('../../core/redaction'); -const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers'); +const { makeTmpDir, rmrf, TINY_PNG, rendererI18n } = require('./helpers'); const OTHER_PNG = Buffer.concat([TINY_PNG, Buffer.from('different bytes')]); @@ -58,7 +58,7 @@ test('private-detail blurs for the old screenshot are removed, and the step is c }); function editor(api) { - const context = { window: { stepforge: api }, el: () => ({}), clearNode() {}, setTimeout, clearTimeout }; + const context = { ...rendererI18n(), window: { stepforge: api }, el: () => ({}), clearNode() {}, setTimeout, clearTimeout }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/editor.js'), 'utf8'), context); const instance = Object.create(context.window.GuideEditor.prototype); const step = { stepId: 's1', title: 'Old', image: { size: { width: 10, height: 10 } }, annotations: [{ type: 'arrow' }] }; @@ -115,5 +115,5 @@ test('cancelling the file picker changes nothing', async () => { test('the step menu offers Choose image…', () => { const source = fs.readFileSync(path.join(__dirname, '../../app/renderer/editor.js'), 'utf8'); - assert.match(source, /label: 'Choose image…', action: \(\) => this\.chooseScreenshot\(step\.stepId\)/); + assert.match(source, /label: t\('Choose image…'\), action: \(\) => this\.chooseScreenshot\(step\.stepId\)/); }); diff --git a/tests/unit/cloud-indicator.test.js b/tests/unit/cloud-indicator.test.js index ee83d419..70e35a66 100644 --- a/tests/unit/cloud-indicator.test.js +++ b/tests/unit/cloud-indicator.test.js @@ -4,9 +4,10 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { rendererI18n } = require('./helpers'); function indicator() { - const context = vm.createContext({ window: {}, document: { addEventListener() {} } }); + const context = vm.createContext({ ...rendererI18n(), window: {}, document: { addEventListener() {} } }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/util.js'), 'utf8'), context); const source = fs.readFileSync(path.join(__dirname, '../../app/renderer/app.js'), 'utf8'); vm.runInContext(source.replace('\nboot();', '\n'), context); diff --git a/tests/unit/cloud-settings-ui.test.js b/tests/unit/cloud-settings-ui.test.js index a80619cb..22f3eb76 100644 --- a/tests/unit/cloud-settings-ui.test.js +++ b/tests/unit/cloud-settings-ui.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { rendererI18n } = require('./helpers'); function ui() { const nodes = []; @@ -24,7 +25,7 @@ function ui() { } const root = el('root'); const keys = new Set(); - const context = { document: { activeElement: null, getElementById: () => root, + const context = { ...rendererI18n(), document: { activeElement: null, getElementById: () => root, addEventListener(type, fn) { if (type === 'keydown') keys.add(fn); }, removeEventListener(type, fn) { if (type === 'keydown') keys.delete(fn); }, }, window: {}, setTimeout, clearTimeout }; diff --git a/tests/unit/code-highlight.test.js b/tests/unit/code-highlight.test.js index bfd23997..ce290888 100644 --- a/tests/unit/code-highlight.test.js +++ b/tests/unit/code-highlight.test.js @@ -125,7 +125,7 @@ test('themes: eight of them, each with valid colors and a dark flag that matches test('the settings dialog offers exactly the code themes core knows', () => { const src = fs.readFileSync(path.join(__dirname, '../../app/renderer/dialogs.js'), 'utf8'); const block = src.match(/const CODE_THEME_CHOICES = \[([\s\S]*?)\];/)[1]; - const offered = [...block.matchAll(/value: '([^']+)', label: '([^']+)'/g)].map((m) => ({ value: m[1], label: m[2] })); + const offered = [...block.matchAll(/value: '([^']+)', label: (?:t\()?'([^']+)'/g)].map((m) => ({ value: m[1], label: m[2] })); assert.deepEqual(offered, CODE_THEME_CHOICES); }); diff --git a/tests/unit/export-publish-instead.test.js b/tests/unit/export-publish-instead.test.js index 70597091..dba9374d 100644 --- a/tests/unit/export-publish-instead.test.js +++ b/tests/unit/export-publish-instead.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { rendererI18n } = require('./helpers'); /** * Export → Confluence or Wiki.js recommends Share → Publish instead (#171): @@ -41,7 +42,7 @@ function ui() { return node; } const root = el('div'); - const context = { + const context = { ...rendererI18n(), document: { activeElement: null, getElementById: () => root, addEventListener() {}, removeEventListener() {} }, window: { addEventListener() {}, removeEventListener() {} }, setTimeout, clearTimeout, console, @@ -142,7 +143,7 @@ test('the editor opens the publish dialog after Export closes, and the app route const dialogs = { showExportDialog: async (args) => { exportArgs = args; calls.push('export closed'); if (args.publishTargets.confluence) args.onPublishInstead('confluence'); return false; }, }; - const context = { window: { stepforge: api, StepForgeDialogs: dialogs }, document: { activeElement: null }, setTimeout, clearTimeout }; + const context = { ...rendererI18n(), window: { stepforge: api, StepForgeDialogs: dialogs }, document: { activeElement: null }, setTimeout, clearTimeout }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/editor.js'), 'utf8'), context); const editor = Object.create(context.window.GuideEditor.prototype); editor.guideId = 'g1'; diff --git a/tests/unit/find-replace-ui.test.js b/tests/unit/find-replace-ui.test.js index bce2aae6..fcf9cc7b 100644 --- a/tests/unit/find-replace-ui.test.js +++ b/tests/unit/find-replace-ui.test.js @@ -6,6 +6,7 @@ const path = require('node:path'); const vm = require('node:vm'); const { findMatches, applyReplacements } = require('../../core/find-replace'); const { createStep } = require('../../core/schema'); +const { rendererI18n } = require('./helpers'); const json = (v) => JSON.parse(JSON.stringify(v)); @@ -32,7 +33,7 @@ function setupEditor(steps) { }; const shown = []; const dialogs = { showFindReplace: async (options) => { shown.push(options); } }; - const context = { + const context = { ...rendererI18n(), window: { stepforge: api, StepForgeDialogs: dialogs, StepForgeOutline: require('../../core/step-outline') }, document: { activeElement: null }, confirmDialog: async () => true, clearNode() {}, el() {}, }; @@ -115,7 +116,7 @@ test('refused matches are skipped and reported, and a batch that changes nothing /** The dialog, with a fake DOM just big enough to drive it. */ function setupDialog({ find, replace }) { const nodes = []; - const context = { + const context = { ...rendererI18n(), window: {}, setTimeout: () => {}, debounce: (fn) => fn, clearNode: (node) => { node.children = []; }, openModal: (options) => { context.modal = options; return { close() { context.closed = true; } }; }, diff --git a/tests/unit/github-ui.test.js b/tests/unit/github-ui.test.js index ae426d4a..ae4339f2 100644 --- a/tests/unit/github-ui.test.js +++ b/tests/unit/github-ui.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { rendererI18n } = require('./helpers'); // A small DOM stand-in with working classes, so visibility can be asserted. function ui({ realLoading = false } = {}) { @@ -51,7 +52,7 @@ function ui({ realLoading = false } = {}) { const root = el('div'); const windowListeners = {}; const toasts = []; - const context = { + const context = { ...rendererI18n(), document: { activeElement: null, getElementById: () => root, addEventListener() {}, removeEventListener() {} }, window: { addEventListener: (type, fn) => { (windowListeners[type] ||= new Set()).add(fn); }, @@ -687,7 +688,7 @@ test('cancelling Publish while the private-detail check runs resolves only after }); test('App.publishToWeb (the marker\'s route) saves the open guide first and reloads it after a cancelled check', async () => { - const context = vm.createContext({ window: {}, document: { addEventListener() {} } }); + const context = vm.createContext({ ...rendererI18n(), window: {}, document: { addEventListener() {} } }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/util.js'), 'utf8'), context); vm.runInContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/app.js'), 'utf8').replace('\nboot();', '\n'), context); const events = []; @@ -1205,7 +1206,7 @@ test('Export marks the Confluence and Wiki.js formats as Beta', async () => { }); test('the guide menus offer Publish to Wiki.js beside Confluence, both marked Beta', async () => { - const context = vm.createContext({ window: {}, document: { addEventListener() {} } }); + const context = vm.createContext({ ...rendererI18n(), window: {}, document: { addEventListener() {} } }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/util.js'), 'utf8'), context); vm.runInContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/app.js'), 'utf8').replace('\nboot();', '\n'), context); let items = null; @@ -1222,8 +1223,8 @@ test('the guide menus offer Publish to Wiki.js beside Confluence, both marked Be assert.deepEqual(opened, ['g1']); // The editor's Share menu has the same two items. const src = fs.readFileSync(path.join(__dirname, '../../app/renderer/app.js'), 'utf8'); - assert.equal((src.match(/label: 'Publish to Wiki\.js…', badge: 'Beta'/g) || []).length, 2); - assert.equal((src.match(/label: 'Publish to Confluence…', badge: 'Beta'/g) || []).length, 2); + assert.equal((src.match(/label: t\('Publish to Wiki\.js…'\), badge: 'Beta'/g) || []).length, 2); + assert.equal((src.match(/label: t\('Publish to Confluence…'\), badge: 'Beta'/g) || []).length, 2); // The menu draws the badge as the same chip Settings uses. const appended = []; diff --git a/tests/unit/google-signin-ui.test.js b/tests/unit/google-signin-ui.test.js index 71a4b447..aaa48ee8 100644 --- a/tests/unit/google-signin-ui.test.js +++ b/tests/unit/google-signin-ui.test.js @@ -7,7 +7,7 @@ const path = require('node:path'); const vm = require('node:vm'); const { configureGoogleOAuth } = require('../../scripts/configure-google-oauth'); -const { makeTmpDir, rmrf } = require('./helpers'); +const { makeTmpDir, rmrf, rendererI18n } = require('./helpers'); function panel(initial) { const nodes = []; @@ -15,7 +15,7 @@ function panel(initial) { let passedArgs; let onStatus; - const context = { + const context = { ...rendererI18n(), el(spec, props = {}, ...children) { const classes = new Set(spec.split('.').slice(1)); const node = { @@ -69,7 +69,7 @@ function panel(initial) { }; // Borrow the shared formatters without util.js replacing the fake el(). - const util = vm.createContext({}); + const util = vm.createContext({ ...rendererI18n() }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/util.js'), 'utf8'), util); Object.assign(context, { formatBytes: util.formatBytes, describeTransfer: util.describeTransfer }); @@ -248,7 +248,7 @@ test( let exposed; const calls = []; - const context = { + const context = { ...rendererI18n(), require(name) { assert.equal(name, 'electron'); diff --git a/tests/unit/guide-quality.test.js b/tests/unit/guide-quality.test.js index e5025003..842783cc 100644 --- a/tests/unit/guide-quality.test.js +++ b/tests/unit/guide-quality.test.js @@ -8,7 +8,7 @@ const { buildRenderAst } = require('../../core/renderast'); const { qualityFindings } = require('../../core/quality'); const { stepNumberMap } = require('../../core/step-outline'); const { buildFixtureGuide } = require('./fixture-guide'); -const { makeTmpDir, rmrf } = require('./helpers'); +const { makeTmpDir, rmrf, rendererI18n } = require('./helpers'); /** * Guide quality checks before exporting or publishing: placeholders with no @@ -92,7 +92,7 @@ test('substeps are numbered as in the step list', () => withGuide(({ store, guid function panel() { const node = (tag) => ({ tag, children: [], attrs: {}, append(...kids) { this.children.push(...kids); } }); - const context = vm.createContext({}); + const context = vm.createContext({ ...rendererI18n() }); context.el = (spec, props = {}, ...children) => { const n = node(spec); Object.assign(n.attrs, props); diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index 02e809d8..f312beee 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -19,4 +19,14 @@ const TINY_PNG = Buffer.from( 'base64' ); -module.exports = { makeTmpDir, rmrf, TINY_PNG }; +/** + * The globals renderer scripts translate with, for a vm context: what + * index.html's script tags and util.js give them in the app. + */ +function rendererI18n(language = 'en') { + const StepForgeI18n = require('../../core/i18n'); + const { t, tn } = StepForgeI18n.createTranslator(language, require('../../core/locales')); + return { StepForgeI18n, URLSearchParams, location: { search: `?lang=${language}` }, t, tn, uiLanguage: language, dateLocale: undefined }; +} + +module.exports = { makeTmpDir, rmrf, TINY_PNG, rendererI18n }; diff --git a/tests/unit/library-selection.test.js b/tests/unit/library-selection.test.js index 65b5e9eb..a031a2da 100644 --- a/tests/unit/library-selection.test.js +++ b/tests/unit/library-selection.test.js @@ -4,9 +4,10 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { rendererI18n } = require('./helpers'); function library(guides) { - const context = { window: {}, document: { addEventListener() {} } }; + const context = { ...rendererI18n(), window: {}, document: { addEventListener() {} } }; const source = fs.readFileSync(path.join(__dirname, '../../app/renderer/app.js'), 'utf8'); vm.runInNewContext(source.replace('\nboot();', '\n'), context); const app = Object.create(context.window.StepForgeApp.prototype); diff --git a/tests/unit/linked-save-editor.test.js b/tests/unit/linked-save-editor.test.js index 2c1bc25d..a6b7b5d8 100644 --- a/tests/unit/linked-save-editor.test.js +++ b/tests/unit/linked-save-editor.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { rendererI18n } = require('./helpers'); /** * The editor during a linked save that merges another session's changes: @@ -32,7 +33,7 @@ function setup(steps) { }, archive: {}, }; - const context = { window: { stepforge: api }, document: { activeElement: null }, confirmDialog: async () => true, clearNode() {}, el() {}, setTimeout, clearTimeout }; + const context = { ...rendererI18n(), window: { stepforge: api }, document: { activeElement: null }, confirmDialog: async () => true, clearNode() {}, el() {}, setTimeout, clearTimeout }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/editor.js'), 'utf8'), context); const editor = Object.create(context.window.GuideEditor.prototype); const toasts = []; diff --git a/tests/unit/package-windows-store.test.js b/tests/unit/package-windows-store.test.js index 63e7ad1e..b09d3b88 100644 --- a/tests/unit/package-windows-store.test.js +++ b/tests/unit/package-windows-store.test.js @@ -141,6 +141,8 @@ test('the Store package is the installer app with the Store identity, unsigned, assert.ok(fs.existsSync(path.join(root, config.appx.customExtensionsPath))); assert.deepEqual(config.extraMetadata, { microsoftStore: { productId: '9NBLGGH4R32N' } }); assert.match(config.appx.artifactName, /\.\$\{ext\}$/); + // The Store lists the languages StepForge's interface comes in, English first. + assert.deepEqual(config.appx.languages, ['en-US', 'fr-FR', 'es-ES', 'ru-RU', 'zh-CN', 'ja-JP']); const options = createStorePackageBuildOptions('/tmp/out', { identity: TEST_IDENTITY, manifestPath: '/tmp/m.xml' }); assert.equal(options.publish, 'never'); diff --git a/tests/unit/quick-actions.test.js b/tests/unit/quick-actions.test.js index 01ac627c..0b305d72 100644 --- a/tests/unit/quick-actions.test.js +++ b/tests/unit/quick-actions.test.js @@ -4,10 +4,11 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { rendererI18n } = require('./helpers'); function palette(commands) { const nodes = []; - const context = { + const context = { ...rendererI18n(), window: {}, setTimeout: () => {}, debounce: fn => fn, clearNode: node => { node.children = []; }, openModal: () => ({ close() {} }), diff --git a/tests/unit/retake-screenshot.test.js b/tests/unit/retake-screenshot.test.js index e4ef110e..7c054c2e 100644 --- a/tests/unit/retake-screenshot.test.js +++ b/tests/unit/retake-screenshot.test.js @@ -5,6 +5,7 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const CaptureService = require('../../app/capture'); +const { rendererI18n } = require('./helpers'); function makeService() { return new CaptureService({ @@ -123,7 +124,7 @@ test('the real hide, countdown and grab path: hidden first, grab after the count }); function editor(api) { - const context = { window: { stepforge: api }, el: () => ({}), clearNode() {}, setTimeout, clearTimeout }; + const context = { ...rendererI18n(), window: { stepforge: api }, el: () => ({}), clearNode() {}, setTimeout, clearTimeout }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/editor.js'), 'utf8'), context); const instance = Object.create(context.window.GuideEditor.prototype); const step = { stepId: 's1', title: 'Old', image: { size: { width: 10, height: 10 } }, annotations: [{ type: 'arrow' }] }; diff --git a/tests/unit/step-reorder.test.js b/tests/unit/step-reorder.test.js index 89aeebc7..6b5b9e1f 100644 --- a/tests/unit/step-reorder.test.js +++ b/tests/unit/step-reorder.test.js @@ -5,6 +5,7 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { isValidOutline } = require('../../core/step-outline'); +const { rendererI18n } = require('./helpers'); /** * Moving steps in the editor (Alt+Up/Down, drag and drop, Move to…) always @@ -40,7 +41,7 @@ function setup({ steps, pick = async () => null }) { }, ai: {}, }; - const context = { setTimeout, window: { stepforge: api, StepForgeOutline: require('../../core/step-outline'), StepForgeDialogs: { showMoveStepDialog: (options) => { dialogOptions.push(options); return pick(options); } } }, document: { activeElement: null }, confirmDialog: async () => true, clearNode() {}, el() {} }; + const context = { ...rendererI18n(), setTimeout, window: { stepforge: api, StepForgeOutline: require('../../core/step-outline'), StepForgeDialogs: { showMoveStepDialog: (options) => { dialogOptions.push(options); return pick(options); } } }, document: { activeElement: null }, confirmDialog: async () => true, clearNode() {}, el() {} }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/editor.js'), 'utf8'), context); const editor = Object.create(context.window.GuideEditor.prototype); Object.assign(editor, { diff --git a/tests/unit/step-selection.test.js b/tests/unit/step-selection.test.js index 31ce919d..b4ead2c0 100644 --- a/tests/unit/step-selection.test.js +++ b/tests/unit/step-selection.test.js @@ -4,9 +4,10 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { rendererI18n } = require('./helpers'); function editor() { - const context = { window: { StepForgeOutline: require('../../core/step-outline') }, + const context = { ...rendererI18n(), window: { StepForgeOutline: require('../../core/step-outline') }, el: (tag, props, ...children) => ({ tag, props, children }), clearNode: (node) => { node.children = []; }, }; diff --git a/tests/unit/undo-coverage.test.js b/tests/unit/undo-coverage.test.js index 1f41b1d8..d28f086c 100644 --- a/tests/unit/undo-coverage.test.js +++ b/tests/unit/undo-coverage.test.js @@ -5,6 +5,7 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { isValidOutline } = require('../../core/step-outline'); +const { rendererI18n } = require('./helpers'); /** * The editor's undo history for step moves, bulk edits and single-step AI @@ -51,7 +52,7 @@ function setup({ steps, confirm = async () => true }) { }); } } - const context = { window: { stepforge: api, StepForgeOutline: require('../../core/step-outline') }, document: { activeElement: null }, confirmDialog: confirm, clearNode() {}, el() {}, Image, setImmediate }; + const context = { ...rendererI18n(), window: { stepforge: api, StepForgeOutline: require('../../core/step-outline') }, document: { activeElement: null }, confirmDialog: confirm, clearNode() {}, el() {}, Image, setImmediate }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../app/renderer/editor.js'), 'utf8'), context); const editor = Object.create(context.window.GuideEditor.prototype); Object.assign(editor, { diff --git a/tests/unit/update-now-ui.test.js b/tests/unit/update-now-ui.test.js index 20244812..b25ca7fb 100644 --- a/tests/unit/update-now-ui.test.js +++ b/tests/unit/update-now-ui.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { rendererI18n } = require('./helpers'); /** * Settings → About: "Check for updates" and, for Windows installs registered @@ -41,7 +42,7 @@ function setup({ check, updateNow = async () => ({ ok: true }), openStore = asyn } const calls = []; const confirms = []; - const context = { + const context = { ...rendererI18n(), window: {}, setTimeout, el, clearNode: (node) => { node.children = []; node._text = undefined; }, setButtonLoading: (button, loading, label) => { button.disabled = loading; button.loadingLabel = loading ? label : undefined; }, From 68062787a1e30d4e5088b75ffd9d5fd888b5a7ac Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 10:33:05 -0500 Subject: [PATCH 2/7] Translate the interface into French, Spanish, Russian, Simplified Chinese and Japanese, marked Beta in Settings --- app/renderer/dialogs.js | 12 +- core/locales/es.js | 732 ++++++++++++++++++++++++++++++++++++++++ core/locales/fr.js | 732 ++++++++++++++++++++++++++++++++++++++++ core/locales/ja.js | 732 ++++++++++++++++++++++++++++++++++++++++ core/locales/ru.js | 732 ++++++++++++++++++++++++++++++++++++++++ core/locales/zh-CN.js | 732 ++++++++++++++++++++++++++++++++++++++++ tests/unit/i18n.test.js | 112 ++++++ 7 files changed, 3782 insertions(+), 2 deletions(-) create mode 100644 tests/unit/i18n.test.js diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index cc61cf58..72e2bb54 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -683,10 +683,17 @@ function showSettingsDialog({ ]); const spellcheck = el('input', { type: 'checkbox', checked: Boolean(settings.spellcheck) }); // Each language is listed in its own name, so anyone can find theirs. + // Translations are new, so every language but English is marked Beta. const language = makeSelect(settings.language || 'system', [ { value: 'system', label: t('System default') }, - ...StepForgeI18n.LANGUAGES.map((l) => ({ value: l.code, label: l.name })), + ...StepForgeI18n.LANGUAGES.map((l) => ({ value: l.code, label: l.code === 'en' ? l.name : `${l.name} (${t('Beta')})` })), ]); + const languageBeta = betaBadge(); + const syncLanguageBeta = () => { + languageBeta.hidden = StepForgeI18n.resolveLanguage(language.value, navigator.languages) === 'en'; + }; + language.addEventListener('change', syncLanguageBeta); + syncLanguageBeta(); const delayMs = makeInput(settings.capture?.delayMs ?? 0, 'number', { min: 0, step: 50 }); // An older settings file may still hold 'region', which recording always // treated as fullscreen. Show what actually happens. @@ -885,7 +892,8 @@ function showSettingsDialog({ settingsCard(t('Guide reviews'), settingRow(t('Flag guides for review'), t('Guides that haven’t been saved or marked verified for this long show “May need review” in the library. Each guide can change this in Guide information.'), reviewDefault)), settingsCard(t('Language'), - settingRow(t('Language'), t('The language of StepForge’s menus, buttons and messages. Exported guides stay in English.'), language)), + settingRow(t('Language'), t('The language of StepForge’s menus, buttons and messages. Exported guides stay in English.'), + el('div.row', {}, language, languageBeta))), ] }, { id: 'branding', label: t('Branding'), description: t('Customize how exported guides appear.'), content: [brandingPanel.node] }, { id: 'capture', label: t('Capture'), description: t('How StepForge records steps.'), content: [ diff --git a/core/locales/es.js b/core/locales/es.js index 4288d77e..4527052a 100644 --- a/core/locales/es.js +++ b/core/locales/es.js @@ -5,4 +5,736 @@ if (typeof module !== 'undefined' && module.exports) module.exports = messages; if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['es'] = messages; })(typeof window !== 'undefined' ? window : null, { + 'Import images as steps': 'Importar imágenes como pasos', + 'Choose a screenshot for this step': 'Elegir una captura de pantalla para este paso', + 'Choose guide storage folder': 'Elegir la carpeta de almacenamiento de guías', + 'Share guide as file': 'Compartir la guía como archivo', + 'Open guide archive': 'Abrir un archivo de guía', + 'Choose output folder': 'Elegir la carpeta de salida', + 'Choose an image': 'Elegir una imagen', + 'Choose a font file': 'Elegir un archivo de fuente', + 'Choose a CSS file': 'Elegir un archivo CSS', + 'Choose a certificate': 'Elegir un certificado', + '{host} asks for a certificate to sign you in.': '{host} solicita un certificado para iniciar sesión.', + 'Choose the one from your smart card that you use to sign in to websites. StepForge remembers your choice for this site.': 'Elige el de tu tarjeta inteligente que usas para iniciar sesión en sitios web. StepForge recuerda tu elección para este sitio.', + '{subject} (issued by {issuer})': '{subject} (emitido por {issuer})', + 'Cancel': 'Cancelar', + 'just now': 'ahora mismo', + '{count} min ago': 'hace {count} min', + '{count} hr ago': 'hace {count} h', + 'yesterday': 'ayer', + 'Last verified {date}': 'Última verificación: {date}', + 'Last saved {date}': 'Último guardado: {date}', + '{since}, over {interval} ago. Open the guide to check it still matches the software, or right-click → Mark as verified.': '{since}, hace más de {interval}. Abre la guía para comprobar que aún coincide con el software, o haz clic derecho → Marcar como verificada.', + 'Deleted for good within a day': 'Se eliminará definitivamente en menos de un día', + 'An AI agent updated your brand profiles.': 'Un agente de IA actualizó tus perfiles de marca.', + 'An AI agent updated this guide.': 'Un agente de IA actualizó esta guía.', + 'Documentation generated.': 'Documentación generada.', + 'Capture, annotate, and export step-by-step guides.': 'Captura, anota y exporta guías paso a paso.', + 'New Capture': 'Nueva captura', + 'Start a guide and capture your screen': 'Crea una guía y captura tu pantalla', + 'Existing Workspace': 'Espacio de trabajo existente', + 'Browse your guide library': 'Explora tu biblioteca de guías', + 'Settings': 'Configuración', + 'Theme, capture, and export options': 'Tema, captura y opciones de exportación', + 'Recent guides': 'Guías recientes', + 'Untitled capture': 'Captura sin título', + 'synced': 'sincronizado', + 'syncing…': 'sincronizando…', + 'pending': 'pendiente', + 'needs attention': 'requiere atención', + '{service} settings': 'Configuración de {service}', + 'Recording - stopped': 'Grabación - detenida', + 'New recording': 'Nueva grabación', + 'waiting for screen sharing': 'esperando el uso compartido de pantalla', + 'ready': 'listo', + 'paused': 'en pausa', + 'on click': 'al hacer clic', + 'every {seconds}s': 'cada {seconds} s', + 'hotkey only': 'solo atajo de teclado', + 'StepForge tucks away and starts capturing': 'StepForge se oculta y empieza a capturar', + 'Start recording': 'Iniciar grabación', + 'Stop recording': 'Detener grabación', + 'Capture session — {trigger}': 'Sesión de captura — {trigger}', + 'Recording - {trigger}': 'Grabación - {trigger}', + 'Full screen (window capture unavailable)': 'Pantalla completa (captura de ventana no disponible)', + 'Library': 'Biblioteca', + 'Capture a screenshot step': 'Capturar un paso', + 'Capture ▾': 'Capturar ▾', + 'Save': 'Guardar', + 'Export': 'Exportar', + 'Share this guide as a file or on the web': 'Compartir esta guía como archivo o en la web', + 'Save as a .sfgz file…': 'Guardar como archivo .sfgz…', + 'Publish to the web…': 'Publicar en la web…', + 'Publish to Confluence…': 'Publicar en Confluence…', + 'Publish to Wiki.js…': 'Publicar en Wiki.js…', + 'Share ▾': 'Compartir ▾', + 'AI is writing. Click to stop.': 'La IA está escribiendo. Haz clic para detenerla.', + 'Let AI write this guide': 'Dejar que la IA escriba esta guía', + 'AI ▾': 'IA ▾', + 'Rename guide…': 'Cambiar nombre de la guía…', + 'Guide information…': 'Información de la guía…', + 'Guide placeholders…': 'Marcadores de posición de la guía…', + 'Backups & snapshots…': 'Copias de seguridad e instantáneas…', + 'Find private details…': 'Buscar datos privados…', + 'Linked guide…': 'Guía vinculada…', + 'Linked guide (not linked)': 'Guía vinculada (sin vincular)', + 'Keyboard shortcuts…': 'Atajos de teclado…', + 'Quick actions (Ctrl+/)': 'Acciones rápidas (Ctrl+/)', + 'Settings…': 'Configuración…', + 'More ▾': 'Más ▾', + 'Results for “{query}”': 'Resultados para «{query}»', + 'All guides': 'Todas las guías', + 'Favorites': 'Favoritos', + 'Folders': 'Carpetas', + 'New folder': 'Nueva carpeta', + 'No folders yet': 'Aún no hay carpetas', + 'Trash': 'Papelera', + 'Select': 'Seleccionar', + 'Empty trash': 'Vaciar la papelera', + 'Import ▾': 'Importar ▾', + 'New guide': 'Nueva guía', + 'Import a copy of a guide (.sfgz)…': 'Importar una copia de una guía (.sfgz)…', + 'Open a linked guide (.sfgz)…': 'Abrir una guía vinculada (.sfgz)…', + 'Rename folder': 'Cambiar nombre de la carpeta', + 'Delete folder': 'Eliminar carpeta', + 'Search': 'Buscar', + 'No results for this query.': 'No hay resultados para esta búsqueda.', + 'No guides yet': 'Aún no hay guías', + 'Create a guide and start capturing steps, or import one someone shared with you.': 'Crea una guía y empieza a capturar pasos, o importa una que alguien haya compartido contigo.', + 'No favorites': 'No hay favoritos', + 'Star a guide to keep it here.': 'Marca una guía con una estrella para tenerla aquí.', + 'This folder is empty': 'Esta carpeta está vacía', + 'Right-click a guide, or select guides, to move them here.': 'Haz clic derecho en una guía, o selecciona guías, para moverlas aquí.', + 'Trash is empty': 'La papelera está vacía', + 'Deleted guides stay here until you empty the trash.': 'Las guías eliminadas permanecen aquí hasta que vacíes la papelera.', + 'Restore': 'Restaurar', + 'Deleted {when}': 'Eliminada {when}', + 'Deselect': 'Deseleccionar', + 'Open guide · Ctrl-click to select': 'Abrir guía · Ctrl+clic para seleccionar', + 'Remove from favorites': 'Quitar de favoritos', + 'Add to favorites': 'Añadir a favoritos', + 'Untitled guide': 'Guía sin título', + 'Saved to a linked .sfgz file': 'Guardada en un archivo .sfgz vinculado', + 'Linked': 'Vinculada', + 'Open on another device': 'Abierta en otro dispositivo', + 'Locked': 'Bloqueada', + 'May need review': 'Puede necesitar revisión', + 'Step': 'Paso', + 'Guide': 'Guía', + 'Move to {folder}': 'Mover a {folder}', + 'Move to no folder': 'Quitar de la carpeta', + 'Open guide': 'Abrir guía', + 'Unfavorite': 'Quitar de favoritos', + 'Favorite': 'Añadir a favoritos', + 'Mark as verified': 'Marcar como verificada', + 'Duplicate guide': 'Duplicar guía', + 'Delete guide': 'Eliminar guía', + 'Delete forever': 'Eliminar definitivamente', + 'Selection actions': 'Acciones de selección', + '{count} of {total} selected': '{count} de {total} seleccionadas', + 'Click guides to select them': 'Haz clic en las guías para seleccionarlas', + 'Clear': 'Borrar', + 'Select all': 'Seleccionar todo', + 'Move to ▾': 'Mover a ▾', + 'Record that these guides still match the software today': 'Indicar que estas guías siguen coincidiendo con el software actual', + 'Delete': 'Eliminar', + 'No folder': 'Sin carpeta', + 'New Guide': 'Nueva guía', + 'Title': 'Título', + 'Folder name': 'Nombre de la carpeta', + 'Delete the folder “{folder}”? Guides stay in the library.': '¿Eliminar la carpeta «{folder}»? Las guías se quedan en la biblioteca.', + 'Marked as verified today.': 'Marcada como verificada hoy.', + '{count} guides marked as verified today.': '{count} guías marcadas como verificadas hoy.', + 'Delete “{title}”? It moves to Trash. If it syncs to the cloud, it is also removed from your other devices and one cloud recovery snapshot is retained.': '¿Eliminar «{title}»? Se moverá a la papelera. Si se sincroniza con la nube, también se quitará de tus otros dispositivos y se conservará una instantánea de recuperación en la nube.', + 'Permanently empty the trash?': '¿Vaciar la papelera definitivamente?', + 'Rename guide': 'Cambiar nombre de la guía', + 'Create a blank guide': 'Crear una guía en blanco', + 'Import archive': 'Importar archivo', + 'Open a .sfgz guide archive': 'Abrir un archivo de guía .sfgz', + 'Open linked archive': 'Abrir archivo vinculado', + 'Import a linked guide from .sfgz': 'Importar una guía vinculada desde un .sfgz', + 'Open application settings': 'Abrir la configuración de la aplicación', + 'Refresh library': 'Actualizar biblioteca', + 'Reload guides and folders': 'Volver a cargar guías y carpetas', + '{count} days ago': { one: 'hace {count} día', other: 'hace {count} días' }, + 'Deleted for good in {count} days': { one: 'Se eliminará definitivamente en {count} día', other: 'Se eliminará definitivamente en {count} días' }, + 'Blurred {count} possible private details in this step.': { one: 'Se difuminó {count} posible dato privado en este paso.', other: 'Se difuminaron {count} posibles datos privados en este paso.' }, + '{count} deleted guides': { one: '{count} guía eliminada', other: '{count} guías eliminadas' }, + '{count} guides': { one: '{count} guía', other: '{count} guías' }, + 'Deleted guides stay here for {count} days, then are deleted for good. Change this in Settings → General.': { one: 'Las guías eliminadas permanecen aquí {count} día y luego se eliminan definitivamente. Cámbialo en Configuración → General.', other: 'Las guías eliminadas permanecen aquí {count} días y luego se eliminan definitivamente. Cámbialo en Configuración → General.' }, + 'Permanently delete {count} items? This cannot be undone.': { one: '¿Eliminar definitivamente {count} elemento? No se puede deshacer.', other: '¿Eliminar definitivamente {count} elementos? No se puede deshacer.' }, + 'Delete {count} guides? They’ll move to Trash. Guides that sync to the cloud are also removed from your other devices, with one cloud recovery snapshot retained.': { one: '¿Eliminar {count} guía? Se moverá a la papelera. Si se sincroniza con la nube, también se quitará de tus otros dispositivos y se conservará una instantánea de recuperación en la nube.', other: '¿Eliminar {count} guías? Se moverán a la papelera. Las guías que se sincronizan con la nube también se quitan de tus otros dispositivos y se conserva una instantánea de recuperación en la nube.' }, + 'Plain (no highlighting)': 'Sin formato (sin resaltado)', + 'Clear shortcut': 'Borrar atajo', + 'Click, then press a key combination': 'Haz clic y pulsa una combinación de teclas', + 'Press a key combination…': 'Pulsa una combinación de teclas…', + 'Click to set shortcut': 'Haz clic para definir el atajo', + 'Move step': 'Mover paso', + '{place} (current place)': '{place} (posición actual)', + 'Place': 'Posición', + 'Level': 'Nivel', + 'Move': 'Mover', + 'OK': 'Aceptar', + 'Search guides, steps, and commands': 'Buscar guías, pasos y comandos', + 'Type to search, arrows to move, Enter to open.': 'Escribe para buscar, flechas para moverte, Intro para abrir.', + 'No matches.': 'No hay coincidencias.', + 'Quick Actions': 'Acciones rápidas', + 'Close': 'Cerrar', + 'Find': 'Buscar', + 'Replace with': 'Reemplazar por', + 'Replace': 'Reemplazar', + 'Type something to find.': 'Escribe algo para buscar.', + 'Searching…': 'Buscando…', + 'Replace {count}': 'Reemplazar {count}', + 'Step {number}': 'Paso {number}', + 'Showing the first {count} matches. Narrow the search to see the rest.': 'Se muestran las primeras {count} coincidencias. Afina la búsqueda para ver el resto.', + 'Could not replace: {error}': 'No se pudo reemplazar: {error}', + 'Find and replace': 'Buscar y reemplazar', + 'Searches step titles and descriptions, text, code and table blocks, and annotation labels. Screenshots are not changed. Ctrl+Z undoes the whole replacement.': 'Busca en los títulos y descripciones de los pasos, los bloques de texto, código y tabla, y las etiquetas de las anotaciones. Las capturas de pantalla no cambian. Ctrl+Z deshace todo el reemplazo.', + 'Check for updates': 'Buscar actualizaciones', + 'Update now': 'Actualizar ahora', + 'Starting…': 'Iniciando…', + 'Your latest changes couldn’t be saved, so the update didn’t start.': 'No se pudieron guardar tus últimos cambios, así que la actualización no se inició.', + 'Couldn’t start the update. Try again, or download the new installer.': 'No se pudo iniciar la actualización. Inténtalo de nuevo o descarga el nuevo instalador.', + 'Closing StepForge…': 'Cerrando StepForge…', + 'An administrator window will install the update and reopen StepForge.': 'Una ventana de administrador instalará la actualización y volverá a abrir StepForge.', + 'Couldn’t start the update.': 'No se pudo iniciar la actualización.', + 'Open Microsoft Store': 'Abrir Microsoft Store', + 'Couldn’t open the Microsoft Store. Open it from the Start menu, then choose Library → Get updates.': 'No se pudo abrir Microsoft Store. Ábrela desde el menú Inicio y elige Biblioteca → Obtener actualizaciones.', + 'Checking…': 'Comprobando…', + 'Couldn’t check for updates. Try again later.': 'No se pudo buscar actualizaciones. Inténtalo más tarde.', + 'Version {version} is available': 'La versión {version} está disponible', + 'You have {version}.': 'Tienes la versión {version}.', + 'View release and download': 'Ver la versión y descargar', + 'You’re up to date': 'Estás al día', + 'Version {version} is the latest release.': 'La versión {version} es la más reciente.', + 'Couldn’t check for updates.': 'No se pudo buscar actualizaciones.', + 'Try again': 'Reintentar', + 'Checking guide storage location…': 'Comprobando la ubicación de almacenamiento de las guías…', + 'Choose folder…': 'Elegir carpeta…', + 'Use default': 'Usar la predeterminada', + 'Cancel pending move': 'Cancelar el traslado pendiente', + 'Default location.': 'Ubicación predeterminada.', + 'Default: {path}': 'Predeterminada: {path}', + 'Moving to {path} the next time StepForge starts.': 'Se trasladará a {path} la próxima vez que se inicie StepForge.', + 'Set by STEPFORGE_DATA_DIR for this session.': 'Definida por STEPFORGE_DATA_DIR para esta sesión.', + 'Could not read guide storage location.': 'No se pudo leer la ubicación de almacenamiento de las guías.', + 'Could not schedule the library move.': 'No se pudo programar el traslado de la biblioteca.', + 'Development build': 'Versión de desarrollo', + 'Release': 'Versión publicada', + 'Unavailable': 'No disponible', + 'System': 'Sistema', + 'Light': 'Claro', + 'Dark': 'Oscuro', + 'System default': 'Idioma del sistema', + 'Fullscreen': 'Pantalla completa', + 'Window': 'Ventana', + 'One per line, for example a name or project code': 'Una por línea, por ejemplo un nombre o un código de proyecto', + 'Words to always hide': 'Palabras que ocultar siempre', + 'Default focus amount': 'Nivel de enfoque predeterminado', + 'Reset': 'Restablecer', + 'Hotkey': 'Atajo de teclado', + 'Timed interval': 'Intervalo temporizado', + 'After 7 days': 'Después de 7 días', + 'After 30 days': 'Después de 30 días', + 'After 90 days': 'Después de 90 días', + 'Never': 'Nunca', + 'After {interval}': 'Después de {interval}', + 'Loading the setup commands…': 'Cargando los comandos de configuración…', + 'Run this in a terminal once.': 'Ejecuta esto una vez en una terminal.', + 'Claude Desktop, Cursor, and other apps': 'Claude Desktop, Cursor y otras aplicaciones', + 'Add this to the app’s MCP servers settings.': 'Añade esto a los servidores MCP en la configuración de la aplicación.', + 'Copied': 'Copiado', + 'Copy': 'Copiar', + 'Could not build the setup commands: {error}': 'No se pudieron generar los comandos de configuración: {error}', + 'Not tested yet. Vision-capable models can also inspect each step’s screenshot.': 'Aún no se ha probado. Los modelos con visión también pueden examinar la captura de pantalla de cada paso.', + 'Test connection': 'Probar conexión', + 'Testing…': 'Probando…', + 'Checking Ollama at the configured host…': 'Comprobando Ollama en el host configurado…', + 'Could not connect to Ollama.': 'No se pudo conectar con Ollama.', + 'Connected to {host} with {model}. It can inspect screenshots.': 'Conectado a {host} con {model}. Puede examinar capturas de pantalla.', + 'Connected to {host} with {model}. This model is text-only, so StepForge will use OCR and metadata only.': 'Conectado a {host} con {model}. Este modelo solo procesa texto, así que StepForge usará únicamente OCR y metadatos.', + 'Connected to {host}, but {model} isn’t installed. Installed models: {installed}.': 'Conectado a {host}, pero {model} no está instalado. Modelos instalados: {installed}.', + 'that model': 'ese modelo', + 'Connected to {host}, but it has no models yet. Run “ollama pull gemma3” first.': 'Conectado a {host}, pero aún no tiene modelos. Ejecuta primero «ollama pull gemma3».', + 'Placeholder name': 'Nombre del marcador de posición', + 'Content': 'Contenido', + 'Placeholder content': 'Contenido del marcador de posición', + 'Markdown or regular text': 'Markdown o texto normal', + 'Remove placeholder': 'Quitar marcador de posición', + 'Add placeholder': 'Añadir marcador de posición', + 'General': 'General', + 'Appearance, exports, where your guides and deleted guides are kept, and when guides are due for review.': 'Apariencia, exportaciones, dónde se guardan tus guías y las eliminadas, y cuándo deben revisarse las guías.', + 'Appearance': 'Apariencia', + 'Theme': 'Tema', + 'Match your system, or always use light or dark.': 'Seguir el sistema, o usar siempre claro u oscuro.', + 'Spellcheck': 'Corrector ortográfico', + 'Underline misspelled words while editing.': 'Subrayar las palabras mal escritas al editar.', + 'Exports': 'Exportaciones', + 'Open folder after export': 'Abrir la carpeta después de exportar', + 'Show the exported file in your file manager.': 'Mostrar el archivo exportado en el explorador de archivos.', + 'Preview step count': 'Pasos en la vista previa', + 'How many steps the export preview renders.': 'Cuántos pasos muestra la vista previa de exportación.', + 'Code theme': 'Tema de código', + 'Syntax colors for code blocks in PDF, Word, HTML and Confluence exports.': 'Colores de sintaxis de los bloques de código en las exportaciones PDF, Word, HTML y Confluence.', + 'Guide storage': 'Almacenamiento de guías', + 'Choose an empty folder. StepForge copies and verifies your library, then switches to it the next time it starts. The old library is kept as a backup.': 'Elige una carpeta vacía. StepForge copia y verifica tu biblioteca, y la usa la próxima vez que se inicie. La biblioteca anterior se conserva como copia de seguridad.', + 'Empty the trash': 'Vaciar la papelera', + 'Deleted guides are removed for good after this long.': 'Las guías eliminadas se borran definitivamente después de este tiempo.', + 'Guide reviews': 'Revisión de guías', + 'Flag guides for review': 'Marcar guías para revisión', + 'Guides that haven’t been saved or marked verified for this long show “May need review” in the library. Each guide can change this in Guide information.': 'Las guías que no se han guardado ni marcado como verificadas durante este tiempo muestran «Puede necesitar revisión» en la biblioteca. Cada guía puede cambiarlo en Información de la guía.', + 'Language': 'Idioma', + 'The language of StepForge’s menus, buttons and messages. Exported guides stay in English.': 'El idioma de los menús, botones y mensajes de StepForge. Las guías exportadas siguen en inglés.', + 'Branding': 'Imagen de marca', + 'Customize how exported guides appear.': 'Personaliza el aspecto de las guías exportadas.', + 'Capture': 'Captura', + 'How StepForge records steps.': 'Cómo graba StepForge los pasos.', + 'Recording': 'Grabación', + 'Default mode': 'Modo predeterminado', + 'Record the whole screen or the active window. For a region, use Capture → Capture region….': 'Grabar toda la pantalla o la ventana activa. Para una región, usa Capturar → Capturar región….', + 'Delay': 'Retraso', + 'Milliseconds to wait before each screenshot.': 'Milisegundos de espera antes de cada captura de pantalla.', + 'Click marker': 'Marcador de clic', + 'Draw a marker where you clicked.': 'Dibujar un marcador donde hiciste clic.', + 'Capture outside clicks': 'Capturar clics externos', + 'Record clicks in other apps while a capture session is running.': 'Grabar los clics en otras aplicaciones mientras hay una sesión de captura en curso.', + 'When clicks can’t be detected': 'Cuando no se pueden detectar los clics', + 'Capture with': 'Capturar con', + 'Some desktops don’t report clicks. Choose how steps are captured instead.': 'Algunos escritorios no informan de los clics. Elige cómo capturar los pasos en ese caso.', + 'Timer interval': 'Intervalo del temporizador', + 'Seconds between timed captures.': 'Segundos entre capturas temporizadas.', + 'Hotkeys': 'Atajos de teclado', + 'Take a step. Also used when clicks can’t be detected.': 'Capturar un paso. También se usa cuando no se pueden detectar los clics.', + 'Pause / resume': 'Pausar / reanudar', + 'Pause or resume the capture session.': 'Pausar o reanudar la sesión de captura.', + 'Editor': 'Editor', + 'Defaults for new steps and automatic backups.': 'Valores predeterminados de los pasos nuevos y copias de seguridad automáticas.', + 'New steps': 'Pasos nuevos', + 'Focus recorded clicks': 'Enfocar los clics grabados', + 'Zoom new screenshots toward where you clicked.': 'Acercar las capturas nuevas hacia donde hiciste clic.', + 'More context (1×) to closer focus (2×). Applies to new captures only.': 'Más contexto (1×) a enfoque más cercano (2×). Solo se aplica a las capturas nuevas.', + 'Focused view for new steps': 'Vista enfocada para pasos nuevos', + 'Open new steps in focused view.': 'Abrir los pasos nuevos en vista enfocada.', + 'Backups': 'Copias de seguridad', + 'Snapshots to keep': 'Instantáneas que conservar', + 'Older backup snapshots of each guide are removed. 0 keeps all of them.': 'Las instantáneas más antiguas de cada guía se eliminan. 0 las conserva todas.', + 'Privacy': 'Privacidad', + 'Blur private details in screenshots. Everything is checked on this computer.': 'Difumina datos privados en las capturas de pantalla. Todo se comprueba en este equipo.', + 'Private details': 'Datos privados', + 'Before a guide goes on the web, StepForge reads its screenshots and blurs email addresses, phone and card numbers, Social Security numbers, IP addresses, passwords, keys, and links with private tokens. You can review every blur and undo any that aren’t needed.': 'Antes de que una guía se publique en la web, StepForge lee sus capturas de pantalla y difumina direcciones de correo, números de teléfono y de tarjeta, números de la seguridad social, direcciones IP, contraseñas, claves y enlaces con tokens privados. Puedes revisar cada difuminado y deshacer los que no hagan falta.', + 'Blur while recording': 'Difuminar mientras se graba', + 'Check each new capture as soon as it’s taken, instead of waiting until you publish.': 'Comprobar cada captura nueva en cuanto se toma, en lugar de esperar a la publicación.', + 'Always hide these words': 'Ocultar siempre estas palabras', + 'Names, project codes, or anything else to blur wherever it appears. One per line.': 'Nombres, códigos de proyecto o cualquier otra cosa que difuminar dondequiera que aparezca. Una por línea.', + 'AI': 'IA', + 'Let AI write your guides: a model on your own computer through Ollama, or an AI agent such as Claude or Codex.': 'Deja que la IA escriba tus guías: un modelo en tu propio equipo mediante Ollama, o un agente de IA como Claude o Codex.', + 'Enable AI': 'Activar IA', + 'Turn on the AI ▾ menu in the guide toolbar: write a whole guide, a step, or one field.': 'Activa el menú IA ▾ en la barra de herramientas de la guía: escribir una guía entera, un paso o un campo.', + 'Auto-document captures': 'Documentar capturas automáticamente', + 'Write a title and description for each new capture as you record. Turn off to use AI only when you ask.': 'Escribir un título y una descripción para cada captura nueva mientras grabas. Desactívalo para usar la IA solo cuando la pidas.', + 'Let the model see screenshots': 'Permitir que el modelo vea las capturas de pantalla', + 'Models that can read images see each step’s screenshot, with blurred areas already hidden. Turn off to send text only.': 'Los modelos que leen imágenes ven la captura de cada paso, con las zonas difuminadas ya ocultas. Desactívalo para enviar solo texto.', + 'Ollama': 'Ollama', + 'Runs on your computer, so your screenshots and text stay here.': 'Se ejecuta en tu equipo, así que tus capturas y tu texto se quedan aquí.', + 'Host': 'Host', + 'Where Ollama is running.': 'Dónde se ejecuta Ollama.', + 'Model': 'Modelo', + 'Any installed model. Choose Test connection to list them.': 'Cualquier modelo instalado. Elige Probar conexión para verlos.', + 'AI guide': 'Guía de IA', + 'Recommended models': 'Modelos recomendados', + 'AI agents (Claude, Codex)': 'Agentes de IA (Claude, Codex)', + 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Aplicaciones de IA como Claude y Codex pueden conectarse a StepForge y escribir tus guías por ti. Pueden reescribir texto, crear subpasos, añadir marcadores de posición y dibujar en las capturas, pero no pueden reordenar pasos ni eliminar nada.', + 'Let AI agents edit your guides': 'Permitir que los agentes de IA editen tus guías', + 'Turn off to cut off every connected agent at once.': 'Desactívalo para desconectar a la vez a todos los agentes conectados.', + 'Agents can see screenshots': 'Los agentes pueden ver las capturas de pantalla', + 'Blurred areas are filled in first. Turn off to share step text only.': 'Las zonas difuminadas se rellenan antes. Desactívalo para compartir solo el texto de los pasos.', + 'Let AI agents edit brand profiles': 'Permitir que los agentes de IA editen los perfiles de marca', + 'Agents can make and change brand profiles: logo, colors, fonts and CSS, and choose the one exports use. They can’t delete a profile.': 'Los agentes pueden crear y cambiar perfiles de marca (logotipo, colores, fuentes y CSS) y elegir el que usan las exportaciones. No pueden eliminar perfiles.', + 'Accounts': 'Cuentas', + 'Sync guides with Google Drive, OneDrive, Dropbox or Nextcloud, share them on the web with GitHub, or publish them to Confluence or Wiki.js.': 'Sincroniza guías con Google Drive, OneDrive, Dropbox o Nextcloud, compártelas en la web con GitHub, o publícalas en Confluence o Wiki.js.', + 'Placeholders': 'Marcadores de posición', + 'Reusable text for every guide. Type [[name]] in a guide to insert it.': 'Texto reutilizable para todas las guías. Escribe [[nombre]] en una guía para insertarlo.', + 'About': 'Acerca de', + 'Local-first step-by-step guides.': 'Guías paso a paso, primero en local.', + 'Version': 'Versión', + 'License': 'Licencia', + 'Data folder': 'Carpeta de datos', + 'Updates': 'Actualizaciones', + 'Checks GitHub for a newer release. StepForge only connects when you press the button.': 'Busca una versión más reciente en GitHub. StepForge solo se conecta cuando pulsas el botón.', + 'Project on GitHub': 'Proyecto en GitHub', + 'Release notes': 'Notas de la versión', + 'Report an issue': 'Informar de un problema', + 'Settings sections': 'Secciones de configuración', + 'AI is off. These settings are kept for later.': 'La IA está desactivada. Esta configuración se conserva para más adelante.', + 'Default template': 'Plantilla predeterminada', + 'Choose an output folder': 'Elegir una carpeta de salida', + 'No branding': 'Sin imagen de marca', + 'JSON exports keep the raw data and aren’t branded.': 'Las exportaciones JSON conservan los datos sin procesar y no llevan imagen de marca.', + 'Change the default in Settings → Branding.': 'Cambia el valor predeterminado en Configuración → Imagen de marca.', + 'None': 'Ninguno', + 'Choose…': 'Elegir…', + 'This format has no adjustable options.': 'Este formato no tiene opciones ajustables.', + 'Keep it online for': 'Mantener en línea durante', + 'The guide is removed from the web after this.': 'La guía se retira de la web después de este tiempo.', + 'Published guides are public.': 'Las guías publicadas son públicas.', + 'Anyone with the link can open it, and anyone who looks at your GitHub repository can find it. Blur passwords and other private details before you export.': 'Cualquiera con el enlace puede abrirla, y cualquiera que mire tu repositorio de GitHub puede encontrarla. Difumina contraseñas y otros datos privados antes de exportar.', + 'Publish on the web': 'Publicar en la web', + 'After exporting, also put this guide on your GitHub Pages site and get a link to share.': 'Después de exportar, publicar también esta guía en tu sitio de GitHub Pages y obtener un enlace para compartir.', + 'Connect GitHub in Settings → Accounts → GitHub to publish guides on the web.': 'Conecta GitHub en Configuración → Cuentas → GitHub para publicar guías en la web.', + 'Export and publish': 'Exportar y publicar', + 'Publish to {site}…': 'Publicar en {site}…', + 'Publishing to {site} is easier.': 'Publicar en {site} es más fácil.', + 'Share → Publish to {site}… creates the page for you, uploads its screenshots and updates the same page when you publish again. Export only if you’d rather upload the files yourself.': 'Compartir → Publicar en {site}… crea la página por ti, sube sus capturas y actualiza la misma página cuando vuelves a publicar. Exporta solo si prefieres subir los archivos tú mismo.', + 'Save template': 'Guardar plantilla', + 'Template name': 'Nombre de la plantilla', + 'Save as template…': 'Guardar como plantilla…', + 'Manage…': 'Administrar…', + 'Format': 'Formato', + 'Template': 'Plantilla', + 'Output folder': 'Carpeta de salida', + 'Options': 'Opciones', + 'Preview…': 'Vista previa…', + 'Preview': 'Vista previa', + 'Exporting…': 'Exportando…', + 'No templates saved for this format yet.': 'Aún no hay plantillas guardadas para este formato.', + 'Rename template': 'Cambiar nombre de la plantilla', + 'Name': 'Nombre', + 'Rename': 'Cambiar nombre', + 'Duplicate': 'Duplicar', + 'Share…': 'Compartir…', + 'Import .sfglt…': 'Importar .sfglt…', + 'Templates are shareable as .sfglt files.': 'Las plantillas se pueden compartir como archivos .sfglt.', + 'Done': 'Listo', + 'Locked by {user}@{host}': 'Bloqueado por {user}@{host}', + 'another user': 'otro usuario', + 'another host': 'otro host', + 'No active conflict': 'Ningún conflicto activo', + 'Archive': 'Archivo', + 'Not linked': 'Sin vincular', + 'Opened': 'Abierto', + 'Unknown': 'Desconocido', + 'Last saved': 'Último guardado', + 'Lock': 'Bloqueo', + 'Another editor has the archive locked. You can force-save if you intend to overwrite it.': 'Otro editor tiene bloqueado el archivo. Puedes forzar el guardado si quieres sobrescribirlo.', + 'Linked Guide': 'Guía vinculada', + 'Show file': 'Mostrar archivo', + 'Force save': 'Forzar guardado', + 'Save now': 'Guardar ahora', + 'No snapshots yet. Automatic snapshots are created as you work; create one manually any time.': 'Aún no hay instantáneas. Se crean instantáneas automáticas mientras trabajas; puedes crear una manualmente cuando quieras.', + 'See what changed between this snapshot and the guide now': 'Ver qué cambió entre esta instantánea y la guía actual', + 'Comparing…': 'Comparando…', + 'Compare': 'Comparar', + 'Create snapshot': 'Crear instantánea', + 'Backups & snapshots': 'Copias de seguridad e instantáneas', + 'Compare shows what changed since a snapshot. Restores are undoable — the current state is snapshotted first.': 'Comparar muestra qué cambió desde una instantánea. Las restauraciones se pueden deshacer: antes se guarda una instantánea del estado actual.', + 'Value': 'Valor', + 'Remove': 'Quitar', + 'e.g. Windows 11 24H2': 'p. ej., Windows 11 24H2', + 'Use the default ({interval})': 'Usar el valor predeterminado ({interval})', + 'Use the default (off)': 'Usar el valor predeterminado (desactivado)', + 'After you save, “May need review” shows in the library on {date} unless the guide is saved or verified again first.': 'Después de guardar, «Puede necesitar revisión» aparecerá en la biblioteca el {date}, salvo que la guía se vuelva a guardar o verificar antes.', + 'This guide is never flagged for review.': 'Esta guía nunca se marca para revisión.', + 'Today': 'Hoy', + 'e.g. Jane Doe': 'p. ej., Ana García', + 'e.g. Alex Lee, Sam Patel': 'p. ej., Alex López, Sam Pérez', + 'e.g. Acme Corp': 'p. ej., Acme S.A.', + 'A short summary of this guide.': 'Un breve resumen de esta guía.', + 'Guide information': 'Información de la guía', + 'All fields are optional. They appear below the title on the guide and on the PDF cover page.': 'Todos los campos son opcionales. Aparecen debajo del título de la guía y en la portada del PDF.', + 'Author': 'Autor', + 'Co-authors': 'Coautores', + 'Organization': 'Organización', + 'Description': 'Descripción', + 'Shown on the first page of the PDF and at the top of other export formats.': 'Se muestra en la primera página del PDF y al principio de los demás formatos de exportación.', + 'Review': 'Revisión', + 'Software version': 'Versión del software', + 'Last verified': 'Última verificación', + 'Review again': 'Volver a revisar', + 'Include this guide in cloud sync': 'Incluir esta guía en la sincronización en la nube', + 'When off, this guide stays only on this device.': 'Si está desactivado, esta guía solo se queda en este dispositivo.', + 'Cloud snapshots': 'Instantáneas en la nube', + 'Remove cloud copies': 'Quitar copias en la nube', + 'Current cloud snapshot': 'Instantánea actual en la nube', + 'Previous snapshot {number}': 'Instantánea anterior {number}', + 'Current': 'Actual', + 'No cloud snapshots are available for this guide.': 'No hay instantáneas en la nube para esta guía.', + 'The current snapshot and up to two previous snapshots are retained.': 'Se conservan la instantánea actual y hasta dos anteriores.', + 'Capture & steps': 'Captura y pasos', + 'Save (writes linked archive when guide is linked)': 'Guardar (escribe el archivo vinculado si la guía está vinculada)', + 'Quick actions palette': 'Paleta de acciones rápidas', + 'Find and replace across the guide': 'Buscar y reemplazar en toda la guía', + 'Previous / next step': 'Paso anterior / siguiente', + 'Move step (and its substeps) up / down': 'Subir / bajar el paso (con sus subpasos)', + 'Delete current step': 'Eliminar el paso actual', + 'Undo / redo (including step moves, deleting steps and AI writes)': 'Deshacer / rehacer (incluidos movimientos y eliminaciones de pasos y textos de la IA)', + 'Paste annotation, or clipboard image as new step': 'Pegar anotación, o imagen del portapapeles como paso nuevo', + 'Canvas tools': 'Herramientas del lienzo', + 'Select · Rectangle · Oval · Line · Arrow · Text': 'Seleccionar · Rectángulo · Óvalo · Línea · Flecha · Texto', + 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop': 'Información emergente · Número · Difuminar · Resaltar · Lupa · Cursor · Recortar', + 'Copy selected annotation (in step-select mode, paste it onto many steps)': 'Copiar la anotación seleccionada (en modo de selección de pasos, pegarla en varios pasos)', + 'Delete selected annotation': 'Eliminar la anotación seleccionada', + 'Deselect annotation': 'Deseleccionar la anotación', + 'Nudge selection by 1 px / 10 px': 'Mover la selección 1 px / 10 px', + 'View': 'Ver', + 'Zoom in / out': 'Acercar / alejar', + 'Fit image to window': 'Ajustar la imagen a la ventana', + 'Compare with a snapshot': 'Comparar con una instantánea', + 'Next change': 'Cambio siguiente', + 'Previous change': 'Cambio anterior', + 'Mark the current change as viewed and go to the next one': 'Marcar el cambio actual como visto e ir al siguiente', + 'Close the comparison': 'Cerrar la comparación', + 'Keyboard shortcuts': 'Atajos de teclado', + 'Continue': 'Continuar', + 'StepForge will hide after you continue.': 'StepForge se ocultará cuando continúes.', + 'Before recording starts': 'Antes de empezar a grabar', + 'Recording tip': 'Consejo de grabación', + 'To stop, use StepForge REC in the GNOME top panel, or restore StepForge from the dock.': 'Para detener, usa StepForge REC en la barra superior de GNOME, o restaura StepForge desde el dock.', + 'When you want to pause or stop, use the red tray icon in the system tray.': 'Cuando quieras pausar o detener, usa el icono rojo de la bandeja del sistema.', + '{count} matches, {selected} selected, {blocked} cannot take this replacement.': { one: '{count} coincidencia, {selected} seleccionada, {blocked} no admite este reemplazo.', other: '{count} coincidencias, {selected} seleccionadas, {blocked} no admiten este reemplazo.' }, + '{count} matches, {selected} selected.': { one: '{count} coincidencia, {selected} seleccionada.', other: '{count} coincidencias, {selected} seleccionadas.' }, + '{count} days': { one: '{count} día', other: '{count} días' }, + 'email address': 'dirección de correo', + 'phone number': 'número de teléfono', + 'card number': 'número de tarjeta', + 'Social Security number': 'número de la seguridad social', + 'IP address': 'dirección IP', + 'password or key': 'contraseña o clave', + 'link with a private token': 'enlace con un token privado', + 'word you asked to hide': 'palabra que pediste ocultar', + 'Rectangle': 'Rectángulo', + 'Oval': 'Óvalo', + 'Line': 'Línea', + 'Arrow': 'Flecha', + 'Text': 'Texto', + 'Tooltip': 'Información emergente', + 'Number': 'Número', + 'Blur': 'Difuminar', + 'Highlight': 'Resaltar', + 'Magnify': 'Lupa', + 'Cursor': 'Cursor', + 'Text block': 'Bloque de texto', + 'Code block': 'Bloque de código', + 'Table': 'Tabla', + 'Stop writing': 'Dejar de escribir', + 'Turn on AI in Settings…': 'Activar la IA en Configuración…', + 'Write the whole guide': 'Escribir toda la guía', + 'Write the whole step': 'Escribir todo el paso', + 'Write the title': 'Escribir el título', + 'Write the description': 'Escribir la descripción', + 'Write the blocks': 'Escribir los bloques', + 'writing…': 'escribiendo…', + 'Stopping AI…': 'Deteniendo la IA…', + 'Turn on AI in Settings first.': 'Primero activa la IA en Configuración.', + 'AI could not write this step.': 'La IA no pudo escribir este paso.', + 'AI could not write the title.': 'La IA no pudo escribir el título.', + 'AI could not write the description.': 'La IA no pudo escribir la descripción.', + 'AI could not write the blocks.': 'La IA no pudo escribir los bloques.', + 'There is nothing for AI to write from yet. Capture some steps first.': 'Aún no hay nada a partir de lo que la IA pueda escribir. Captura primero algunos pasos.', + 'Then it writes the guide’s title and description. Text you already wrote is polished, not replaced with something new.': 'Después escribe el título y la descripción de la guía. El texto que ya escribiste se pule, no se reemplaza.', + 'A snapshot is saved first, so you can undo this from More → Backups & snapshots.': 'Primero se guarda una instantánea, así que puedes deshacerlo desde Más → Copias de seguridad e instantáneas.', + 'before AI': 'antes de la IA', + 'Could not save a snapshot first, so AI did not start: {error}': 'No se pudo guardar primero una instantánea, así que la IA no empezó: {error}', + 'AI could not write a step.': 'La IA no pudo escribir un paso.', + 'AI could not organize the guide.': 'La IA no pudo organizar la guía.', + 'AI could not write the guide title.': 'La IA no pudo escribir el título de la guía.', + 'Added {placeholders}.': 'Se añadió {placeholders}.', + 'Steps': 'Pasos', + 'Add': 'Añadir', + 'Import': 'Importar', + 'Select steps; Shift-click to select a range': 'Seleccionar pasos; Mayús+clic para seleccionar un rango', + 'Move step up': 'Subir paso', + 'Move step down': 'Bajar paso', + 'Fit': 'Ajustar', + 'Undo': 'Deshacer', + 'Redo': 'Rehacer', + 'Select an image step to edit annotations.': 'Selecciona un paso con imagen para editar las anotaciones.', + 'Step title': 'Título del paso', + 'Todo': 'Pendiente', + 'In progress': 'En curso', + 'Zoom': 'Zoom', + 'Pan X': 'Desplazamiento X', + 'Pan Y': 'Desplazamiento Y', + 'Exports crop to this view; the original image is never modified.': 'Las exportaciones se recortan a esta vista; la imagen original nunca se modifica.', + 'Annotations': 'Anotaciones', + 'Blocks': 'Bloques', + '+ Text block': '+ Bloque de texto', + '+ Code': '+ Código', + '+ Table': '+ Tabla', + 'Save changes. For guides linked to a shared archive, also writes the archive file.': 'Guardar cambios. En las guías vinculadas a un archivo compartido, también escribe el archivo.', + 'Snapshot': 'Instantánea', + 'Untitled step': 'Paso sin título', + 'Select a step first.': 'Selecciona primero un paso.', + 'Select a step to add blocks.': 'Selecciona un paso para añadir bloques.', + 'Remove block': 'Quitar bloque', + 'Move block up': 'Subir bloque', + 'Move block down': 'Bajar bloque', + 'Before title': 'Antes del título', + 'After title': 'Después del título', + 'Before image': 'Antes de la imagen', + 'After image': 'Después de la imagen', + 'Before description': 'Antes de la descripción', + 'After description': 'Después de la descripción', + 'Note': 'Nota', + 'Warning': 'Advertencia', + 'Important': 'Importante', + 'Tip': 'Consejo', + 'Block title': 'Título del bloque', + 'Block text': 'Texto del bloque', + 'Language (e.g. bash)': 'Lenguaje (p. ej., bash)', + 'Code': 'Código', + 'One row per line, cells separated by |': 'Una fila por línea, celdas separadas por |', + 'First line is the header row.': 'La primera línea es la fila de encabezado.', + 'Informational text, code, and table blocks can be reordered with drag handles or arrows.': 'Los bloques de texto, código y tabla se pueden reordenar con los tiradores o las flechas.', + 'Add substep': 'Añadir subpaso', + 'Make substep of…': 'Convertir en subpaso de…', + 'Duplicate step': 'Duplicar paso', + 'Choose image…': 'Elegir imagen…', + 'Retake screenshot': 'Volver a tomar la captura', + 'Full screen': 'Pantalla completa', + 'Region…': 'Región…', + 'Move up': 'Subir', + 'Move down': 'Bajar', + 'Move to…': 'Mover a…', + 'Delete step': 'Eliminar paso', + 'Select step {step}': 'Seleccionar el paso {step}', + 'No steps yet.': 'Aún no hay pasos.', + 'Add the copied annotation at the same position on each selected step': 'Añadir la anotación copiada en la misma posición de cada paso seleccionado', + 'linked': 'vinculada', + 'local': 'local', + 'No step selected.': 'Ningún paso seleccionado.', + 'Select a step to edit annotations.': 'Selecciona un paso para editar las anotaciones.', + 'No annotations yet. Pick a tool and drag on the canvas.': 'Aún no hay anotaciones. Elige una herramienta y arrástrala sobre el lienzo.', + 'Private detail: {kind}': 'Dato privado: {kind}', + 'added by StepForge': 'añadido por StepForge', + 'Select an annotation to edit its style.': 'Selecciona una anotación para editar su estilo.', + 'Bottom': 'Abajo', + 'Top': 'Arriba', + 'Left': 'Izquierda', + 'Right': 'Derecha', + 'Color': 'Color', + 'Stroke': 'Trazo', + 'Annotation text': 'Texto de la anotación', + 'Type': 'Tipo', + 'Fill': 'Relleno', + 'Stroke width': 'Grosor del trazo', + 'Font size': 'Tamaño de fuente', + 'Text color': 'Color del texto', + 'Radius': 'Radio', + 'Tail': 'Cola', + 'Copy this style to every other “{type}” annotation:': 'Copiar este estilo a todas las demás anotaciones «{type}»:', + 'Overwrite the style of every “{type}” annotation on this step with the style shown above.': 'Sobrescribir el estilo de todas las anotaciones «{type}» de este paso con el estilo de arriba.', + 'This step': 'Este paso', + 'Overwrite the style of every “{type}” annotation across all steps in this guide with the style shown above.': 'Sobrescribir el estilo de todas las anotaciones «{type}» de todos los pasos de esta guía con el estilo de arriba.', + 'Entire guide': 'Toda la guía', + 'Could not undo the move: {error}': 'No se pudo deshacer el movimiento: {error}', + 'Undo anyway': 'Deshacer de todos modos', + 'Could not undo every step: {error}': 'No se pudieron deshacer todos los pasos: {error}', + 'Nothing to undo.': 'No hay nada que deshacer.', + 'Nothing to redo.': 'No hay nada que rehacer.', + 'Save failed': 'Error al guardar', + 'Could not save this step — your changes are kept. Retrying…': 'No se pudo guardar este paso; tus cambios se conservan. Reintentando…', + 'Could not save guide details — your changes are kept. Retrying…': 'No se pudieron guardar los detalles de la guía; tus cambios se conservan. Reintentando…', + 'Saved and synced to linked archive.': 'Guardado y sincronizado con el archivo vinculado.', + 'Saved locally, but the linked archive is locked by another session.': 'Guardado localmente, pero otra sesión tiene bloqueado el archivo vinculado.', + 'Saved.': 'Guardado.', + 'Merged changes from another session.': 'Se combinaron cambios de otra sesión.', + 'Snapshot name': 'Nombre de la instantánea', + 'Snapshot created.': 'Instantánea creada.', + 'Add Step': 'Añadir paso', + 'Step added.': 'Paso añadido.', + 'Add Substep': 'Añadir subpaso', + 'Substep title': 'Título del subpaso', + 'Untitled substep': 'Subpaso sin título', + 'Substep added.': 'Subpaso añadido.', + 'A step cannot be made a substep of itself or one of its own substeps.': 'Un paso no puede ser subpaso de sí mismo ni de uno de sus propios subpasos.', + '“{title}” is already a substep of step {number}.': '«{title}» ya es un subpaso del paso {number}.', + '“{title}” is now a substep of step {number}.': '«{title}» ahora es un subpaso del paso {number}.', + 'Untitled step copy': 'Copia de paso sin título', + 'Step duplicated.': 'Paso duplicado.', + 'Stop the recording session before retaking a screenshot.': 'Detén la sesión de grabación antes de volver a tomar una captura.', + 'Screenshot retaken.': 'Captura de pantalla tomada de nuevo.', + 'Screenshot replaced.': 'Captura de pantalla reemplazada.', + 'Private details will be checked again before publishing.': 'Los datos privados se volverán a comprobar antes de publicar.', + 'Its annotations were kept, so check they still line up.': 'Sus anotaciones se conservaron; comprueba que sigan bien colocadas.', + 'Press Ctrl+Z to undo.': 'Pulsa Ctrl+Z para deshacer.', + 'Step deleted. Press Ctrl+Z to undo.': 'Paso eliminado. Pulsa Ctrl+Z para deshacer.', + 'Could not move the step: {error}': 'No se pudo mover el paso: {error}', + 'Top level': 'Nivel superior', + 'At the top': 'Al principio', + 'Move “{title}”': 'Mover «{title}»', + 'Its substeps move with it.': 'Sus subpasos se mueven con él.', + 'The guide changed while you were choosing, so the step was not moved. Try Move to… again.': 'La guía cambió mientras elegías, así que el paso no se movió. Vuelve a intentar Mover a….', + 'Step moved. Press Ctrl+Z to undo.': 'Paso movido. Pulsa Ctrl+Z para deshacer.', + 'A step can’t be dropped inside its own substeps.': 'Un paso no se puede soltar dentro de sus propios subpasos.', + 'Images imported.': 'Imágenes importadas.', + 'Captured.': 'Capturado.', + 'Capture full screen': 'Capturar pantalla completa', + 'Capture window': 'Capturar ventana', + 'Capture region…': 'Capturar región…', + 'Paste image as step': 'Pegar imagen como paso', + 'Import images…': 'Importar imágenes…', + 'Start capture session (hotkey)': 'Iniciar sesión de captura (atajo)', + 'Image pasted as a new step.': 'Imagen pegada como paso nuevo.', + 'Clipboard has no image.': 'El portapapeles no tiene ninguna imagen.', + 'Shared to {path}': 'Compartido en {path}', + 'Restore “{name}”? Current state is snapshotted first, so this is undoable.': '¿Restaurar «{name}»? Antes se guarda una instantánea del estado actual, así que se puede deshacer.', + 'Snapshot restored.': 'Instantánea restaurada.', + 'Could not compare with this snapshot: {error}': 'No se pudo comparar con esta instantánea: {error}', + 'Guide placeholders': 'Marcadores de posición de la guía', + 'Use [[Name]] in titles, descriptions, and blocks. Guide values override global ones.': 'Usa [[Nombre]] en títulos, descripciones y bloques. Los valores de la guía sustituyen a los globales.', + 'Placeholders saved.': 'Marcadores de posición guardados.', + 'Guide information saved.': 'Información de la guía guardada.', + 'Restore this cloud snapshot? Your current local guide is kept as a cloud snapshot first.': '¿Restaurar esta instantánea de la nube? Tu guía local actual se guarda antes como instantánea en la nube.', + 'Cloud snapshot restored.': 'Instantánea de la nube restaurada.', + 'Remove every cloud snapshot for this guide? This also turns off cloud sync for the guide. The local guide is kept.': '¿Quitar todas las instantáneas en la nube de esta guía? También se desactiva la sincronización en la nube de la guía. La guía local se conserva.', + 'Cloud copies removed.': 'Copias en la nube quitadas.', + 'Style applied to all “{type}” annotations in this step.': 'Estilo aplicado a todas las anotaciones «{type}» de este paso.', + 'Style applied to all “{type}” annotations in the guide.': 'Estilo aplicado a todas las anotaciones «{type}» de la guía.', + 'Capture paused.': 'Captura en pausa.', + 'Capture resumed.': 'Captura reanudada.', + 'Imported template for {format}.': 'Plantilla importada para {format}.', + 'Template shared as .sfglt.': 'Plantilla compartida como .sfglt.', + 'Preview opened (first steps only).': 'Vista previa abierta (solo los primeros pasos).', + 'This guide': 'Esta guía', + 'Linked archive saved.': 'Archivo vinculado guardado.', + 'Could not save linked archive.': 'No se pudo guardar el archivo vinculado.', + 'Linked archive force-saved.': 'Archivo vinculado guardado a la fuerza.', + 'Export the current guide': 'Exportar la guía actual', + 'Linked guide': 'Guía vinculada', + 'Show linked archive details': 'Mostrar detalles del archivo vinculado', + 'Start capture session': 'Iniciar sesión de captura', + 'Enable hotkey capture for this guide': 'Activar la captura con atajo para esta guía', + 'Could not save every step: {error}': 'No se pudieron guardar todos los pasos: {error}', + '{count} skipped.': '{count} omitidos.', + 'Nothing was replaced.': 'No se reemplazó nada.', + 'No steps changed.': 'Ningún paso cambió.', + 'Stopped early: {error}': 'Se detuvo antes de tiempo: {error}', + 'Crop the working image to the selected area?': '¿Recortar la imagen de trabajo al área seleccionada?', + 'Image cropped.': 'Imagen recortada.', + 'Edit tooltip': 'Editar información emergente', + 'Edit text': 'Editar texto', + 'Annotation copied.': 'Anotación copiada.', + 'AI will write the title, description and blocks for {count} steps, make substeps where steps belong together, and add placeholders for values that repeat.': { one: 'La IA escribirá el título, la descripción y los bloques de {count} paso.', other: 'La IA escribirá el título, la descripción y los bloques de {count} pasos, creará subpasos donde los pasos vayan juntos y añadirá marcadores de posición para los valores que se repiten.' }, + 'Made {count} substeps.': { one: 'Se creó {count} subpaso.', other: 'Se crearon {count} subpasos.' }, + 'Stopped. AI wrote {written} of {count} steps.': { one: 'Detenido. La IA escribió {written} de {count} paso.', other: 'Detenido. La IA escribió {written} de {count} pasos.' }, + 'AI wrote {written} of {count} steps.': { one: 'La IA escribió {written} de {count} paso.', other: 'La IA escribió {written} de {count} pasos.' }, + 'AI wrote the whole guide ({count} steps).': { one: 'La IA escribió toda la guía ({count} paso).', other: 'La IA escribió toda la guía ({count} pasos).' }, + '{count} steps': { one: '{count} paso', other: '{count} pasos' }, + 'Paste annotation to {count} steps': { one: 'Pegar la anotación en {count} paso', other: 'Pegar la anotación en {count} pasos' }, + '{count} steps were changed since this edit, perhaps by AI or another app. Undo anyway and overwrite those changes?': { one: 'Un paso cambió después de esta edición, quizá por la IA o por otra aplicación. ¿Deshacer de todos modos y sobrescribir esos cambios?', other: '{count} pasos cambiaron después de esta edición, quizá por la IA o por otra aplicación. ¿Deshacer de todos modos y sobrescribir esos cambios?' }, + '{count} conflicts were settled in favor of the most recent edit.': { one: 'Se resolvió {count} conflicto a favor de la edición más reciente.', other: 'Se resolvieron {count} conflictos a favor de la edición más reciente.' }, + 'Replaced {applied} in {count} steps.': { one: '{applied} reemplazos en {count} paso.', other: '{applied} reemplazos en {count} pasos.' }, + 'Annotation pasted onto {count} steps.': { one: 'Anotación pegada en {count} paso.', other: 'Anotación pegada en {count} pasos.' }, + 'Skipped {count} steps (already there, no image, or a different image shape): {steps}.': { one: 'Se omitió {count} paso (ya estaba, sin imagen o con otra forma de imagen): {steps}.', other: 'Se omitieron {count} pasos (ya estaban, sin imagen o con otra forma de imagen): {steps}.' }, + 'Close (Esc)': 'Cerrar (Esc)', + 'Confirm': 'Confirmar', + 'Nothing else to choose': 'No hay nada más que elegir', + '{count} years': { one: '{count} año', other: '{count} años' }, + '{count} months': { one: '{count} mes', other: '{count} meses' }, + 'Guide library': 'Biblioteca de guías', + 'Cloud sync status': 'Estado de la sincronización en la nube', + 'Search guides… (Ctrl+/ for quick actions)': 'Buscar guías… (Ctrl+/ para acciones rápidas)', + 'Beta': 'Beta', }); diff --git a/core/locales/fr.js b/core/locales/fr.js index ccb65395..4f8c1253 100644 --- a/core/locales/fr.js +++ b/core/locales/fr.js @@ -5,4 +5,736 @@ if (typeof module !== 'undefined' && module.exports) module.exports = messages; if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['fr'] = messages; })(typeof window !== 'undefined' ? window : null, { + 'Import images as steps': 'Importer des images comme étapes', + 'Choose a screenshot for this step': 'Choisir une capture d’écran pour cette étape', + 'Choose guide storage folder': 'Choisir le dossier de stockage des guides', + 'Share guide as file': 'Partager le guide comme fichier', + 'Open guide archive': 'Ouvrir une archive de guide', + 'Choose output folder': 'Choisir le dossier de sortie', + 'Choose an image': 'Choisir une image', + 'Choose a font file': 'Choisir un fichier de police', + 'Choose a CSS file': 'Choisir un fichier CSS', + 'Choose a certificate': 'Choisir un certificat', + '{host} asks for a certificate to sign you in.': '{host} demande un certificat pour vous connecter.', + 'Choose the one from your smart card that you use to sign in to websites. StepForge remembers your choice for this site.': 'Choisissez celui de votre carte à puce que vous utilisez pour vous connecter aux sites web. StepForge mémorise votre choix pour ce site.', + '{subject} (issued by {issuer})': '{subject} (émis par {issuer})', + 'Cancel': 'Annuler', + 'just now': 'à l’instant', + '{count} min ago': 'il y a {count} min', + '{count} hr ago': 'il y a {count} h', + 'yesterday': 'hier', + 'Last verified {date}': 'Dernière vérification le {date}', + 'Last saved {date}': 'Dernier enregistrement le {date}', + '{since}, over {interval} ago. Open the guide to check it still matches the software, or right-click → Mark as verified.': '{since}, il y a plus de {interval}. Ouvrez le guide pour vérifier qu’il correspond toujours au logiciel, ou faites un clic droit → Marquer comme vérifié.', + 'Deleted for good within a day': 'Supprimé définitivement dans moins d’un jour', + 'An AI agent updated your brand profiles.': 'Un agent IA a mis à jour vos profils de marque.', + 'An AI agent updated this guide.': 'Un agent IA a mis à jour ce guide.', + 'Documentation generated.': 'Documentation générée.', + 'Capture, annotate, and export step-by-step guides.': 'Capturez, annotez et exportez des guides pas à pas.', + 'New Capture': 'Nouvelle capture', + 'Start a guide and capture your screen': 'Créez un guide et capturez votre écran', + 'Existing Workspace': 'Espace de travail existant', + 'Browse your guide library': 'Parcourir votre bibliothèque de guides', + 'Settings': 'Paramètres', + 'Theme, capture, and export options': 'Thème, capture et options d’exportation', + 'Recent guides': 'Guides récents', + 'Untitled capture': 'Capture sans titre', + 'synced': 'synchronisé', + 'syncing…': 'synchronisation…', + 'pending': 'en attente', + 'needs attention': 'action requise', + '{service} settings': 'Paramètres de {service}', + 'Recording - stopped': 'Enregistrement - arrêté', + 'New recording': 'Nouvel enregistrement', + 'waiting for screen sharing': 'en attente du partage d’écran', + 'ready': 'prêt', + 'paused': 'en pause', + 'on click': 'au clic', + 'every {seconds}s': 'toutes les {seconds} s', + 'hotkey only': 'raccourci uniquement', + 'StepForge tucks away and starts capturing': 'StepForge se masque et commence la capture', + 'Start recording': 'Démarrer l’enregistrement', + 'Stop recording': 'Arrêter l’enregistrement', + 'Capture session — {trigger}': 'Session de capture — {trigger}', + 'Recording - {trigger}': 'Enregistrement - {trigger}', + 'Full screen (window capture unavailable)': 'Plein écran (capture de fenêtre indisponible)', + 'Library': 'Bibliothèque', + 'Capture a screenshot step': 'Capturer une étape', + 'Capture ▾': 'Capturer ▾', + 'Save': 'Enregistrer', + 'Export': 'Exporter', + 'Share this guide as a file or on the web': 'Partager ce guide comme fichier ou sur le web', + 'Save as a .sfgz file…': 'Enregistrer comme fichier .sfgz…', + 'Publish to the web…': 'Publier sur le web…', + 'Publish to Confluence…': 'Publier sur Confluence…', + 'Publish to Wiki.js…': 'Publier sur Wiki.js…', + 'Share ▾': 'Partager ▾', + 'AI is writing. Click to stop.': 'L’IA rédige. Cliquez pour arrêter.', + 'Let AI write this guide': 'Laisser l’IA rédiger ce guide', + 'AI ▾': 'IA ▾', + 'Rename guide…': 'Renommer le guide…', + 'Guide information…': 'Informations du guide…', + 'Guide placeholders…': 'Espaces réservés du guide…', + 'Backups & snapshots…': 'Sauvegardes et instantanés…', + 'Find private details…': 'Rechercher les données privées…', + 'Linked guide…': 'Guide lié…', + 'Linked guide (not linked)': 'Guide lié (non lié)', + 'Keyboard shortcuts…': 'Raccourcis clavier…', + 'Quick actions (Ctrl+/)': 'Actions rapides (Ctrl+/)', + 'Settings…': 'Paramètres…', + 'More ▾': 'Plus ▾', + 'Results for “{query}”': 'Résultats pour « {query} »', + 'All guides': 'Tous les guides', + 'Favorites': 'Favoris', + 'Folders': 'Dossiers', + 'New folder': 'Nouveau dossier', + 'No folders yet': 'Aucun dossier', + 'Trash': 'Corbeille', + 'Select': 'Sélectionner', + 'Empty trash': 'Vider la corbeille', + 'Import ▾': 'Importer ▾', + 'New guide': 'Nouveau guide', + 'Import a copy of a guide (.sfgz)…': 'Importer une copie d’un guide (.sfgz)…', + 'Open a linked guide (.sfgz)…': 'Ouvrir un guide lié (.sfgz)…', + 'Rename folder': 'Renommer le dossier', + 'Delete folder': 'Supprimer le dossier', + 'Search': 'Rechercher', + 'No results for this query.': 'Aucun résultat pour cette recherche.', + 'No guides yet': 'Aucun guide pour l’instant', + 'Create a guide and start capturing steps, or import one someone shared with you.': 'Créez un guide et commencez à capturer des étapes, ou importez-en un qu’on vous a partagé.', + 'No favorites': 'Aucun favori', + 'Star a guide to keep it here.': 'Ajoutez un guide aux favoris pour le retrouver ici.', + 'This folder is empty': 'Ce dossier est vide', + 'Right-click a guide, or select guides, to move them here.': 'Faites un clic droit sur un guide, ou sélectionnez des guides, pour les déplacer ici.', + 'Trash is empty': 'La corbeille est vide', + 'Deleted guides stay here until you empty the trash.': 'Les guides supprimés restent ici jusqu’à ce que vous vidiez la corbeille.', + 'Restore': 'Restaurer', + 'Deleted {when}': 'Supprimé {when}', + 'Deselect': 'Désélectionner', + 'Open guide · Ctrl-click to select': 'Ouvrir le guide · Ctrl+clic pour sélectionner', + 'Remove from favorites': 'Retirer des favoris', + 'Add to favorites': 'Ajouter aux favoris', + 'Untitled guide': 'Guide sans titre', + 'Saved to a linked .sfgz file': 'Enregistré dans un fichier .sfgz lié', + 'Linked': 'Lié', + 'Open on another device': 'Ouvert sur un autre appareil', + 'Locked': 'Verrouillé', + 'May need review': 'À revoir', + 'Step': 'Étape', + 'Guide': 'Guide', + 'Move to {folder}': 'Déplacer vers {folder}', + 'Move to no folder': 'Retirer du dossier', + 'Open guide': 'Ouvrir le guide', + 'Unfavorite': 'Retirer des favoris', + 'Favorite': 'Ajouter aux favoris', + 'Mark as verified': 'Marquer comme vérifié', + 'Duplicate guide': 'Dupliquer le guide', + 'Delete guide': 'Supprimer le guide', + 'Delete forever': 'Supprimer définitivement', + 'Selection actions': 'Actions sur la sélection', + '{count} of {total} selected': '{count} sur {total} sélectionnés', + 'Click guides to select them': 'Cliquez sur des guides pour les sélectionner', + 'Clear': 'Effacer', + 'Select all': 'Tout sélectionner', + 'Move to ▾': 'Déplacer vers ▾', + 'Record that these guides still match the software today': 'Indiquer que ces guides correspondent toujours au logiciel actuel', + 'Delete': 'Supprimer', + 'No folder': 'Aucun dossier', + 'New Guide': 'Nouveau guide', + 'Title': 'Titre', + 'Folder name': 'Nom du dossier', + 'Delete the folder “{folder}”? Guides stay in the library.': 'Supprimer le dossier « {folder} » ? Les guides restent dans la bibliothèque.', + 'Marked as verified today.': 'Marqué comme vérifié aujourd’hui.', + '{count} guides marked as verified today.': '{count} guides marqués comme vérifiés aujourd’hui.', + 'Delete “{title}”? It moves to Trash. If it syncs to the cloud, it is also removed from your other devices and one cloud recovery snapshot is retained.': 'Supprimer « {title} » ? Il est déplacé dans la corbeille. S’il est synchronisé dans le cloud, il est aussi retiré de vos autres appareils et un instantané de récupération est conservé dans le cloud.', + 'Permanently empty the trash?': 'Vider définitivement la corbeille ?', + 'Rename guide': 'Renommer le guide', + 'Create a blank guide': 'Créer un guide vide', + 'Import archive': 'Importer une archive', + 'Open a .sfgz guide archive': 'Ouvrir une archive de guide .sfgz', + 'Open linked archive': 'Ouvrir une archive liée', + 'Import a linked guide from .sfgz': 'Importer un guide lié depuis un .sfgz', + 'Open application settings': 'Ouvrir les paramètres de l’application', + 'Refresh library': 'Actualiser la bibliothèque', + 'Reload guides and folders': 'Recharger les guides et les dossiers', + '{count} days ago': { one: 'il y a {count} jour', other: 'il y a {count} jours' }, + 'Deleted for good in {count} days': { one: 'Supprimé définitivement dans {count} jour', other: 'Supprimé définitivement dans {count} jours' }, + 'Blurred {count} possible private details in this step.': { one: '{count} donnée privée possible floutée dans cette étape.', other: '{count} données privées possibles floutées dans cette étape.' }, + '{count} deleted guides': { one: '{count} guide supprimé', other: '{count} guides supprimés' }, + '{count} guides': { one: '{count} guide', other: '{count} guides' }, + 'Deleted guides stay here for {count} days, then are deleted for good. Change this in Settings → General.': { one: 'Les guides supprimés restent ici {count} jour, puis sont supprimés définitivement. Modifiez cela dans Paramètres → Général.', other: 'Les guides supprimés restent ici {count} jours, puis sont supprimés définitivement. Modifiez cela dans Paramètres → Général.' }, + 'Permanently delete {count} items? This cannot be undone.': { one: 'Supprimer définitivement {count} élément ? Cette action est irréversible.', other: 'Supprimer définitivement {count} éléments ? Cette action est irréversible.' }, + 'Delete {count} guides? They’ll move to Trash. Guides that sync to the cloud are also removed from your other devices, with one cloud recovery snapshot retained.': { one: 'Supprimer {count} guide ? Il est déplacé dans la corbeille. S’il est synchronisé dans le cloud, il est aussi retiré de vos autres appareils, et un instantané de récupération est conservé dans le cloud.', other: 'Supprimer {count} guides ? Ils seront déplacés dans la corbeille. Les guides synchronisés dans le cloud sont aussi retirés de vos autres appareils, et un instantané de récupération est conservé dans le cloud.' }, + 'Plain (no highlighting)': 'Brut (sans coloration)', + 'Clear shortcut': 'Effacer le raccourci', + 'Click, then press a key combination': 'Cliquez, puis appuyez sur une combinaison de touches', + 'Press a key combination…': 'Appuyez sur une combinaison de touches…', + 'Click to set shortcut': 'Cliquez pour définir le raccourci', + 'Move step': 'Déplacer l’étape', + '{place} (current place)': '{place} (emplacement actuel)', + 'Place': 'Emplacement', + 'Level': 'Niveau', + 'Move': 'Déplacer', + 'OK': 'OK', + 'Search guides, steps, and commands': 'Rechercher des guides, des étapes et des commandes', + 'Type to search, arrows to move, Enter to open.': 'Tapez pour rechercher, flèches pour naviguer, Entrée pour ouvrir.', + 'No matches.': 'Aucune correspondance.', + 'Quick Actions': 'Actions rapides', + 'Close': 'Fermer', + 'Find': 'Rechercher', + 'Replace with': 'Remplacer par', + 'Replace': 'Remplacer', + 'Type something to find.': 'Tapez un texte à rechercher.', + 'Searching…': 'Recherche…', + 'Replace {count}': 'Remplacer {count}', + 'Step {number}': 'Étape {number}', + 'Showing the first {count} matches. Narrow the search to see the rest.': 'Affichage des {count} premières correspondances. Affinez la recherche pour voir les autres.', + 'Could not replace: {error}': 'Impossible de remplacer : {error}', + 'Find and replace': 'Rechercher et remplacer', + 'Searches step titles and descriptions, text, code and table blocks, and annotation labels. Screenshots are not changed. Ctrl+Z undoes the whole replacement.': 'Recherche dans les titres et descriptions des étapes, les blocs de texte, de code et de tableau, et les libellés d’annotation. Les captures d’écran ne sont pas modifiées. Ctrl+Z annule tout le remplacement.', + 'Check for updates': 'Rechercher des mises à jour', + 'Update now': 'Mettre à jour', + 'Starting…': 'Démarrage…', + 'Your latest changes couldn’t be saved, so the update didn’t start.': 'Vos dernières modifications n’ont pas pu être enregistrées, la mise à jour n’a donc pas démarré.', + 'Couldn’t start the update. Try again, or download the new installer.': 'Impossible de lancer la mise à jour. Réessayez ou téléchargez le nouveau programme d’installation.', + 'Closing StepForge…': 'Fermeture de StepForge…', + 'An administrator window will install the update and reopen StepForge.': 'Une fenêtre administrateur va installer la mise à jour et rouvrir StepForge.', + 'Couldn’t start the update.': 'Impossible de lancer la mise à jour.', + 'Open Microsoft Store': 'Ouvrir le Microsoft Store', + 'Couldn’t open the Microsoft Store. Open it from the Start menu, then choose Library → Get updates.': 'Impossible d’ouvrir le Microsoft Store. Ouvrez-le depuis le menu Démarrer, puis choisissez Bibliothèque → Obtenir les mises à jour.', + 'Checking…': 'Vérification…', + 'Couldn’t check for updates. Try again later.': 'Impossible de rechercher des mises à jour. Réessayez plus tard.', + 'Version {version} is available': 'La version {version} est disponible', + 'You have {version}.': 'Vous avez la version {version}.', + 'View release and download': 'Voir la version et télécharger', + 'You’re up to date': 'Vous êtes à jour', + 'Version {version} is the latest release.': 'La version {version} est la plus récente.', + 'Couldn’t check for updates.': 'Impossible de rechercher des mises à jour.', + 'Try again': 'Réessayer', + 'Checking guide storage location…': 'Vérification de l’emplacement de stockage des guides…', + 'Choose folder…': 'Choisir un dossier…', + 'Use default': 'Utiliser l’emplacement par défaut', + 'Cancel pending move': 'Annuler le déplacement prévu', + 'Default location.': 'Emplacement par défaut.', + 'Default: {path}': 'Par défaut : {path}', + 'Moving to {path} the next time StepForge starts.': 'Déplacement vers {path} au prochain démarrage de StepForge.', + 'Set by STEPFORGE_DATA_DIR for this session.': 'Défini par STEPFORGE_DATA_DIR pour cette session.', + 'Could not read guide storage location.': 'Impossible de lire l’emplacement de stockage des guides.', + 'Could not schedule the library move.': 'Impossible de planifier le déplacement de la bibliothèque.', + 'Development build': 'Version de développement', + 'Release': 'Version publiée', + 'Unavailable': 'Indisponible', + 'System': 'Système', + 'Light': 'Clair', + 'Dark': 'Sombre', + 'System default': 'Langue du système', + 'Fullscreen': 'Plein écran', + 'Window': 'Fenêtre', + 'One per line, for example a name or project code': 'Un par ligne, par exemple un nom ou un code de projet', + 'Words to always hide': 'Mots à toujours masquer', + 'Default focus amount': 'Niveau de zoom par défaut', + 'Reset': 'Réinitialiser', + 'Hotkey': 'Raccourci clavier', + 'Timed interval': 'Intervalle minuté', + 'After 7 days': 'Après 7 jours', + 'After 30 days': 'Après 30 jours', + 'After 90 days': 'Après 90 jours', + 'Never': 'Jamais', + 'After {interval}': 'Après {interval}', + 'Loading the setup commands…': 'Chargement des commandes de configuration…', + 'Run this in a terminal once.': 'Exécutez ceci une fois dans un terminal.', + 'Claude Desktop, Cursor, and other apps': 'Claude Desktop, Cursor et autres applications', + 'Add this to the app’s MCP servers settings.': 'Ajoutez ceci aux serveurs MCP dans les paramètres de l’application.', + 'Copied': 'Copié', + 'Copy': 'Copier', + 'Could not build the setup commands: {error}': 'Impossible de générer les commandes de configuration : {error}', + 'Not tested yet. Vision-capable models can also inspect each step’s screenshot.': 'Pas encore testé. Les modèles avec vision peuvent aussi examiner la capture d’écran de chaque étape.', + 'Test connection': 'Tester la connexion', + 'Testing…': 'Test…', + 'Checking Ollama at the configured host…': 'Vérification d’Ollama sur l’hôte configuré…', + 'Could not connect to Ollama.': 'Impossible de se connecter à Ollama.', + 'Connected to {host} with {model}. It can inspect screenshots.': 'Connecté à {host} avec {model}. Il peut examiner les captures d’écran.', + 'Connected to {host} with {model}. This model is text-only, so StepForge will use OCR and metadata only.': 'Connecté à {host} avec {model}. Ce modèle ne traite que le texte, StepForge utilisera donc uniquement l’OCR et les métadonnées.', + 'Connected to {host}, but {model} isn’t installed. Installed models: {installed}.': 'Connecté à {host}, mais {model} n’est pas installé. Modèles installés : {installed}.', + 'that model': 'ce modèle', + 'Connected to {host}, but it has no models yet. Run “ollama pull gemma3” first.': 'Connecté à {host}, mais aucun modèle n’est encore installé. Exécutez d’abord « ollama pull gemma3 ».', + 'Placeholder name': 'Nom de l’espace réservé', + 'Content': 'Contenu', + 'Placeholder content': 'Contenu de l’espace réservé', + 'Markdown or regular text': 'Markdown ou texte simple', + 'Remove placeholder': 'Supprimer l’espace réservé', + 'Add placeholder': 'Ajouter un espace réservé', + 'General': 'Général', + 'Appearance, exports, where your guides and deleted guides are kept, and when guides are due for review.': 'Apparence, exportations, emplacement de vos guides et des guides supprimés, et date de révision des guides.', + 'Appearance': 'Apparence', + 'Theme': 'Thème', + 'Match your system, or always use light or dark.': 'Suivre le système, ou toujours utiliser le mode clair ou sombre.', + 'Spellcheck': 'Correcteur orthographique', + 'Underline misspelled words while editing.': 'Souligner les fautes d’orthographe pendant la saisie.', + 'Exports': 'Exportations', + 'Open folder after export': 'Ouvrir le dossier après l’exportation', + 'Show the exported file in your file manager.': 'Afficher le fichier exporté dans votre gestionnaire de fichiers.', + 'Preview step count': 'Étapes dans l’aperçu', + 'How many steps the export preview renders.': 'Nombre d’étapes affichées dans l’aperçu d’exportation.', + 'Code theme': 'Thème du code', + 'Syntax colors for code blocks in PDF, Word, HTML and Confluence exports.': 'Couleurs de syntaxe des blocs de code dans les exportations PDF, Word, HTML et Confluence.', + 'Guide storage': 'Stockage des guides', + 'Choose an empty folder. StepForge copies and verifies your library, then switches to it the next time it starts. The old library is kept as a backup.': 'Choisissez un dossier vide. StepForge copie et vérifie votre bibliothèque, puis l’utilise au prochain démarrage. L’ancienne bibliothèque est conservée comme sauvegarde.', + 'Empty the trash': 'Vider la corbeille', + 'Deleted guides are removed for good after this long.': 'Les guides supprimés sont effacés définitivement après ce délai.', + 'Guide reviews': 'Révision des guides', + 'Flag guides for review': 'Signaler les guides à revoir', + 'Guides that haven’t been saved or marked verified for this long show “May need review” in the library. Each guide can change this in Guide information.': 'Les guides qui n’ont pas été enregistrés ou marqués comme vérifiés depuis ce délai affichent « À revoir » dans la bibliothèque. Chaque guide peut modifier cela dans Informations du guide.', + 'Language': 'Langue', + 'The language of StepForge’s menus, buttons and messages. Exported guides stay in English.': 'La langue des menus, boutons et messages de StepForge. Les guides exportés restent en anglais.', + 'Branding': 'Image de marque', + 'Customize how exported guides appear.': 'Personnalisez l’apparence des guides exportés.', + 'Capture': 'Capture', + 'How StepForge records steps.': 'Comment StepForge enregistre les étapes.', + 'Recording': 'Enregistrement', + 'Default mode': 'Mode par défaut', + 'Record the whole screen or the active window. For a region, use Capture → Capture region….': 'Enregistrer tout l’écran ou la fenêtre active. Pour une zone, utilisez Capturer → Capturer une zone….', + 'Delay': 'Délai', + 'Milliseconds to wait before each screenshot.': 'Millisecondes d’attente avant chaque capture d’écran.', + 'Click marker': 'Marqueur de clic', + 'Draw a marker where you clicked.': 'Dessiner un marqueur à l’endroit du clic.', + 'Capture outside clicks': 'Capturer les clics extérieurs', + 'Record clicks in other apps while a capture session is running.': 'Enregistrer les clics dans d’autres applications pendant une session de capture.', + 'When clicks can’t be detected': 'Quand les clics ne peuvent pas être détectés', + 'Capture with': 'Capturer avec', + 'Some desktops don’t report clicks. Choose how steps are captured instead.': 'Certains bureaux ne signalent pas les clics. Choisissez comment capturer les étapes dans ce cas.', + 'Timer interval': 'Intervalle du minuteur', + 'Seconds between timed captures.': 'Secondes entre deux captures minutées.', + 'Hotkeys': 'Raccourcis clavier', + 'Take a step. Also used when clicks can’t be detected.': 'Capturer une étape. Sert aussi quand les clics ne peuvent pas être détectés.', + 'Pause / resume': 'Pause / reprise', + 'Pause or resume the capture session.': 'Mettre en pause ou reprendre la session de capture.', + 'Editor': 'Éditeur', + 'Defaults for new steps and automatic backups.': 'Valeurs par défaut des nouvelles étapes et sauvegardes automatiques.', + 'New steps': 'Nouvelles étapes', + 'Focus recorded clicks': 'Zoomer sur les clics enregistrés', + 'Zoom new screenshots toward where you clicked.': 'Zoomer les nouvelles captures vers l’endroit du clic.', + 'More context (1×) to closer focus (2×). Applies to new captures only.': 'Plus de contexte (1×) à zoom plus serré (2×). S’applique uniquement aux nouvelles captures.', + 'Focused view for new steps': 'Vue zoomée pour les nouvelles étapes', + 'Open new steps in focused view.': 'Ouvrir les nouvelles étapes en vue zoomée.', + 'Backups': 'Sauvegardes', + 'Snapshots to keep': 'Instantanés à conserver', + 'Older backup snapshots of each guide are removed. 0 keeps all of them.': 'Les anciens instantanés de chaque guide sont supprimés. 0 les conserve tous.', + 'Privacy': 'Confidentialité', + 'Blur private details in screenshots. Everything is checked on this computer.': 'Flouter les données privées des captures d’écran. Tout est vérifié sur cet ordinateur.', + 'Private details': 'Données privées', + 'Before a guide goes on the web, StepForge reads its screenshots and blurs email addresses, phone and card numbers, Social Security numbers, IP addresses, passwords, keys, and links with private tokens. You can review every blur and undo any that aren’t needed.': 'Avant qu’un guide soit publié sur le web, StepForge lit ses captures d’écran et floute les adresses e-mail, les numéros de téléphone et de carte, les numéros de sécurité sociale, les adresses IP, les mots de passe, les clés et les liens contenant des jetons privés. Vous pouvez revoir chaque flou et annuler ceux qui sont inutiles.', + 'Blur while recording': 'Flouter pendant l’enregistrement', + 'Check each new capture as soon as it’s taken, instead of waiting until you publish.': 'Vérifier chaque nouvelle capture dès qu’elle est prise, sans attendre la publication.', + 'Always hide these words': 'Toujours masquer ces mots', + 'Names, project codes, or anything else to blur wherever it appears. One per line.': 'Noms, codes de projet ou tout autre texte à flouter partout où il apparaît. Un par ligne.', + 'AI': 'IA', + 'Let AI write your guides: a model on your own computer through Ollama, or an AI agent such as Claude or Codex.': 'Laissez l’IA rédiger vos guides : un modèle sur votre ordinateur via Ollama, ou un agent IA comme Claude ou Codex.', + 'Enable AI': 'Activer l’IA', + 'Turn on the AI ▾ menu in the guide toolbar: write a whole guide, a step, or one field.': 'Active le menu IA ▾ dans la barre d’outils du guide : rédiger tout un guide, une étape ou un champ.', + 'Auto-document captures': 'Documenter automatiquement les captures', + 'Write a title and description for each new capture as you record. Turn off to use AI only when you ask.': 'Rédiger un titre et une description pour chaque nouvelle capture pendant l’enregistrement. Désactivez pour n’utiliser l’IA que sur demande.', + 'Let the model see screenshots': 'Laisser le modèle voir les captures d’écran', + 'Models that can read images see each step’s screenshot, with blurred areas already hidden. Turn off to send text only.': 'Les modèles capables de lire les images voient la capture de chaque étape, avec les zones floutées déjà masquées. Désactivez pour n’envoyer que du texte.', + 'Ollama': 'Ollama', + 'Runs on your computer, so your screenshots and text stay here.': 'S’exécute sur votre ordinateur : vos captures d’écran et vos textes restent ici.', + 'Host': 'Hôte', + 'Where Ollama is running.': 'Où Ollama s’exécute.', + 'Model': 'Modèle', + 'Any installed model. Choose Test connection to list them.': 'N’importe quel modèle installé. Choisissez Tester la connexion pour les lister.', + 'AI guide': 'Guide de l’IA', + 'Recommended models': 'Modèles recommandés', + 'AI agents (Claude, Codex)': 'Agents IA (Claude, Codex)', + 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Les applications d’IA comme Claude et Codex peuvent se connecter à StepForge et rédiger vos guides pour vous. Elles peuvent réécrire du texte, créer des sous-étapes, ajouter des espaces réservés et dessiner sur les captures, mais ne peuvent ni réorganiser les étapes ni rien supprimer.', + 'Let AI agents edit your guides': 'Autoriser les agents IA à modifier vos guides', + 'Turn off to cut off every connected agent at once.': 'Désactivez pour couper d’un coup tous les agents connectés.', + 'Agents can see screenshots': 'Les agents peuvent voir les captures d’écran', + 'Blurred areas are filled in first. Turn off to share step text only.': 'Les zones floutées sont d’abord remplies. Désactivez pour ne partager que le texte des étapes.', + 'Let AI agents edit brand profiles': 'Autoriser les agents IA à modifier les profils de marque', + 'Agents can make and change brand profiles: logo, colors, fonts and CSS, and choose the one exports use. They can’t delete a profile.': 'Les agents peuvent créer et modifier des profils de marque (logo, couleurs, polices et CSS) et choisir celui utilisé par les exportations. Ils ne peuvent pas supprimer de profil.', + 'Accounts': 'Comptes', + 'Sync guides with Google Drive, OneDrive, Dropbox or Nextcloud, share them on the web with GitHub, or publish them to Confluence or Wiki.js.': 'Synchronisez vos guides avec Google Drive, OneDrive, Dropbox ou Nextcloud, partagez-les sur le web avec GitHub, ou publiez-les sur Confluence ou Wiki.js.', + 'Placeholders': 'Espaces réservés', + 'Reusable text for every guide. Type [[name]] in a guide to insert it.': 'Texte réutilisable pour tous les guides. Tapez [[nom]] dans un guide pour l’insérer.', + 'About': 'À propos', + 'Local-first step-by-step guides.': 'Des guides pas à pas, en local d’abord.', + 'Version': 'Version', + 'License': 'Licence', + 'Data folder': 'Dossier de données', + 'Updates': 'Mises à jour', + 'Checks GitHub for a newer release. StepForge only connects when you press the button.': 'Recherche une nouvelle version sur GitHub. StepForge ne se connecte que lorsque vous appuyez sur le bouton.', + 'Project on GitHub': 'Projet sur GitHub', + 'Release notes': 'Notes de version', + 'Report an issue': 'Signaler un problème', + 'Settings sections': 'Sections des paramètres', + 'AI is off. These settings are kept for later.': 'L’IA est désactivée. Ces paramètres sont conservés pour plus tard.', + 'Default template': 'Modèle par défaut', + 'Choose an output folder': 'Choisir un dossier de sortie', + 'No branding': 'Sans image de marque', + 'JSON exports keep the raw data and aren’t branded.': 'Les exportations JSON conservent les données brutes et n’ont pas d’image de marque.', + 'Change the default in Settings → Branding.': 'Modifiez la valeur par défaut dans Paramètres → Image de marque.', + 'None': 'Aucun', + 'Choose…': 'Choisir…', + 'This format has no adjustable options.': 'Ce format n’a pas d’options réglables.', + 'Keep it online for': 'Garder en ligne pendant', + 'The guide is removed from the web after this.': 'Le guide est retiré du web après ce délai.', + 'Published guides are public.': 'Les guides publiés sont publics.', + 'Anyone with the link can open it, and anyone who looks at your GitHub repository can find it. Blur passwords and other private details before you export.': 'Toute personne disposant du lien peut l’ouvrir, et toute personne qui consulte votre dépôt GitHub peut le trouver. Floutez les mots de passe et autres données privées avant d’exporter.', + 'Publish on the web': 'Publier sur le web', + 'After exporting, also put this guide on your GitHub Pages site and get a link to share.': 'Après l’exportation, mettre aussi ce guide sur votre site GitHub Pages et obtenir un lien à partager.', + 'Connect GitHub in Settings → Accounts → GitHub to publish guides on the web.': 'Connectez GitHub dans Paramètres → Comptes → GitHub pour publier des guides sur le web.', + 'Export and publish': 'Exporter et publier', + 'Publish to {site}…': 'Publier sur {site}…', + 'Publishing to {site} is easier.': 'Publier sur {site} est plus simple.', + 'Share → Publish to {site}… creates the page for you, uploads its screenshots and updates the same page when you publish again. Export only if you’d rather upload the files yourself.': 'Partager → Publier sur {site}… crée la page pour vous, envoie ses captures d’écran et met à jour la même page lorsque vous republiez. N’exportez que si vous préférez envoyer les fichiers vous-même.', + 'Save template': 'Enregistrer le modèle', + 'Template name': 'Nom du modèle', + 'Save as template…': 'Enregistrer comme modèle…', + 'Manage…': 'Gérer…', + 'Format': 'Format', + 'Template': 'Modèle', + 'Output folder': 'Dossier de sortie', + 'Options': 'Options', + 'Preview…': 'Aperçu…', + 'Preview': 'Aperçu', + 'Exporting…': 'Exportation…', + 'No templates saved for this format yet.': 'Aucun modèle enregistré pour ce format.', + 'Rename template': 'Renommer le modèle', + 'Name': 'Nom', + 'Rename': 'Renommer', + 'Duplicate': 'Dupliquer', + 'Share…': 'Partager…', + 'Import .sfglt…': 'Importer un .sfglt…', + 'Templates are shareable as .sfglt files.': 'Les modèles peuvent être partagés sous forme de fichiers .sfglt.', + 'Done': 'Terminé', + 'Locked by {user}@{host}': 'Verrouillé par {user}@{host}', + 'another user': 'un autre utilisateur', + 'another host': 'un autre hôte', + 'No active conflict': 'Aucun conflit actif', + 'Archive': 'Archive', + 'Not linked': 'Non lié', + 'Opened': 'Ouvert le', + 'Unknown': 'Inconnu', + 'Last saved': 'Dernier enregistrement', + 'Lock': 'Verrou', + 'Another editor has the archive locked. You can force-save if you intend to overwrite it.': 'Un autre éditeur a verrouillé l’archive. Vous pouvez forcer l’enregistrement si vous voulez l’écraser.', + 'Linked Guide': 'Guide lié', + 'Show file': 'Afficher le fichier', + 'Force save': 'Forcer l’enregistrement', + 'Save now': 'Enregistrer maintenant', + 'No snapshots yet. Automatic snapshots are created as you work; create one manually any time.': 'Aucun instantané pour l’instant. Des instantanés automatiques sont créés pendant que vous travaillez ; vous pouvez en créer un manuellement à tout moment.', + 'See what changed between this snapshot and the guide now': 'Voir ce qui a changé entre cet instantané et le guide actuel', + 'Comparing…': 'Comparaison…', + 'Compare': 'Comparer', + 'Create snapshot': 'Créer un instantané', + 'Backups & snapshots': 'Sauvegardes et instantanés', + 'Compare shows what changed since a snapshot. Restores are undoable — the current state is snapshotted first.': 'Comparer montre ce qui a changé depuis un instantané. Les restaurations sont réversibles : l’état actuel est d’abord enregistré dans un instantané.', + 'Value': 'Valeur', + 'Remove': 'Supprimer', + 'e.g. Windows 11 24H2': 'p. ex. Windows 11 24H2', + 'Use the default ({interval})': 'Utiliser la valeur par défaut ({interval})', + 'Use the default (off)': 'Utiliser la valeur par défaut (désactivé)', + 'After you save, “May need review” shows in the library on {date} unless the guide is saved or verified again first.': 'Après l’enregistrement, « À revoir » s’affichera dans la bibliothèque le {date}, sauf si le guide est de nouveau enregistré ou vérifié avant.', + 'This guide is never flagged for review.': 'Ce guide n’est jamais signalé pour révision.', + 'Today': 'Aujourd’hui', + 'e.g. Jane Doe': 'p. ex. Marie Dupont', + 'e.g. Alex Lee, Sam Patel': 'p. ex. Alex Martin, Sam Petit', + 'e.g. Acme Corp': 'p. ex. Acme SA', + 'A short summary of this guide.': 'Un court résumé de ce guide.', + 'Guide information': 'Informations du guide', + 'All fields are optional. They appear below the title on the guide and on the PDF cover page.': 'Tous les champs sont facultatifs. Ils apparaissent sous le titre du guide et sur la page de couverture du PDF.', + 'Author': 'Auteur', + 'Co-authors': 'Coauteurs', + 'Organization': 'Organisation', + 'Description': 'Description', + 'Shown on the first page of the PDF and at the top of other export formats.': 'Affiché sur la première page du PDF et en haut des autres formats d’exportation.', + 'Review': 'Révision', + 'Software version': 'Version du logiciel', + 'Last verified': 'Dernière vérification', + 'Review again': 'Revoir à nouveau', + 'Include this guide in cloud sync': 'Inclure ce guide dans la synchronisation cloud', + 'When off, this guide stays only on this device.': 'Si désactivé, ce guide reste uniquement sur cet appareil.', + 'Cloud snapshots': 'Instantanés cloud', + 'Remove cloud copies': 'Supprimer les copies cloud', + 'Current cloud snapshot': 'Instantané cloud actuel', + 'Previous snapshot {number}': 'Instantané précédent {number}', + 'Current': 'Actuel', + 'No cloud snapshots are available for this guide.': 'Aucun instantané cloud n’est disponible pour ce guide.', + 'The current snapshot and up to two previous snapshots are retained.': 'L’instantané actuel et jusqu’à deux instantanés précédents sont conservés.', + 'Capture & steps': 'Capture et étapes', + 'Save (writes linked archive when guide is linked)': 'Enregistrer (écrit l’archive liée si le guide est lié)', + 'Quick actions palette': 'Palette d’actions rapides', + 'Find and replace across the guide': 'Rechercher et remplacer dans tout le guide', + 'Previous / next step': 'Étape précédente / suivante', + 'Move step (and its substeps) up / down': 'Monter / descendre l’étape (avec ses sous-étapes)', + 'Delete current step': 'Supprimer l’étape actuelle', + 'Undo / redo (including step moves, deleting steps and AI writes)': 'Annuler / rétablir (y compris les déplacements, suppressions d’étapes et rédactions de l’IA)', + 'Paste annotation, or clipboard image as new step': 'Coller une annotation, ou l’image du presse-papiers comme nouvelle étape', + 'Canvas tools': 'Outils du canevas', + 'Select · Rectangle · Oval · Line · Arrow · Text': 'Sélection · Rectangle · Ovale · Ligne · Flèche · Texte', + 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop': 'Info-bulle · Numéro · Flou · Surligneur · Loupe · Curseur · Rogner', + 'Copy selected annotation (in step-select mode, paste it onto many steps)': 'Copier l’annotation sélectionnée (en mode sélection d’étapes, la coller sur plusieurs étapes)', + 'Delete selected annotation': 'Supprimer l’annotation sélectionnée', + 'Deselect annotation': 'Désélectionner l’annotation', + 'Nudge selection by 1 px / 10 px': 'Déplacer la sélection de 1 px / 10 px', + 'View': 'Affichage', + 'Zoom in / out': 'Zoom avant / arrière', + 'Fit image to window': 'Ajuster l’image à la fenêtre', + 'Compare with a snapshot': 'Comparer avec un instantané', + 'Next change': 'Modification suivante', + 'Previous change': 'Modification précédente', + 'Mark the current change as viewed and go to the next one': 'Marquer la modification actuelle comme vue et passer à la suivante', + 'Close the comparison': 'Fermer la comparaison', + 'Keyboard shortcuts': 'Raccourcis clavier', + 'Continue': 'Continuer', + 'StepForge will hide after you continue.': 'StepForge se masquera quand vous continuerez.', + 'Before recording starts': 'Avant le début de l’enregistrement', + 'Recording tip': 'Conseil d’enregistrement', + 'To stop, use StepForge REC in the GNOME top panel, or restore StepForge from the dock.': 'Pour arrêter, utilisez StepForge REC dans la barre supérieure de GNOME, ou restaurez StepForge depuis le dock.', + 'When you want to pause or stop, use the red tray icon in the system tray.': 'Pour mettre en pause ou arrêter, utilisez l’icône rouge dans la zone de notification.', + '{count} matches, {selected} selected, {blocked} cannot take this replacement.': { one: '{count} correspondance, {selected} sélectionnée, {blocked} ne peut pas recevoir ce remplacement.', other: '{count} correspondances, {selected} sélectionnées, {blocked} ne peuvent pas recevoir ce remplacement.' }, + '{count} matches, {selected} selected.': { one: '{count} correspondance, {selected} sélectionnée.', other: '{count} correspondances, {selected} sélectionnées.' }, + '{count} days': { one: '{count} jour', other: '{count} jours' }, + 'email address': 'adresse e-mail', + 'phone number': 'numéro de téléphone', + 'card number': 'numéro de carte', + 'Social Security number': 'numéro de sécurité sociale', + 'IP address': 'adresse IP', + 'password or key': 'mot de passe ou clé', + 'link with a private token': 'lien avec un jeton privé', + 'word you asked to hide': 'mot que vous avez demandé de masquer', + 'Rectangle': 'Rectangle', + 'Oval': 'Ovale', + 'Line': 'Ligne', + 'Arrow': 'Flèche', + 'Text': 'Texte', + 'Tooltip': 'Info-bulle', + 'Number': 'Numéro', + 'Blur': 'Flou', + 'Highlight': 'Surligneur', + 'Magnify': 'Loupe', + 'Cursor': 'Curseur', + 'Text block': 'Bloc de texte', + 'Code block': 'Bloc de code', + 'Table': 'Tableau', + 'Stop writing': 'Arrêter la rédaction', + 'Turn on AI in Settings…': 'Activer l’IA dans les paramètres…', + 'Write the whole guide': 'Rédiger tout le guide', + 'Write the whole step': 'Rédiger toute l’étape', + 'Write the title': 'Rédiger le titre', + 'Write the description': 'Rédiger la description', + 'Write the blocks': 'Rédiger les blocs', + 'writing…': 'rédaction…', + 'Stopping AI…': 'Arrêt de l’IA…', + 'Turn on AI in Settings first.': 'Activez d’abord l’IA dans les paramètres.', + 'AI could not write this step.': 'L’IA n’a pas pu rédiger cette étape.', + 'AI could not write the title.': 'L’IA n’a pas pu rédiger le titre.', + 'AI could not write the description.': 'L’IA n’a pas pu rédiger la description.', + 'AI could not write the blocks.': 'L’IA n’a pas pu rédiger les blocs.', + 'There is nothing for AI to write from yet. Capture some steps first.': 'L’IA n’a encore rien à partir de quoi rédiger. Capturez d’abord quelques étapes.', + 'Then it writes the guide’s title and description. Text you already wrote is polished, not replaced with something new.': 'Elle rédige ensuite le titre et la description du guide. Le texte que vous avez déjà écrit est peaufiné, pas remplacé.', + 'A snapshot is saved first, so you can undo this from More → Backups & snapshots.': 'Un instantané est d’abord enregistré : vous pouvez annuler depuis Plus → Sauvegardes et instantanés.', + 'before AI': 'avant l’IA', + 'Could not save a snapshot first, so AI did not start: {error}': 'Impossible d’enregistrer d’abord un instantané, l’IA n’a donc pas démarré : {error}', + 'AI could not write a step.': 'L’IA n’a pas pu rédiger une étape.', + 'AI could not organize the guide.': 'L’IA n’a pas pu organiser le guide.', + 'AI could not write the guide title.': 'L’IA n’a pas pu rédiger le titre du guide.', + 'Added {placeholders}.': 'Ajouté : {placeholders}.', + 'Steps': 'Étapes', + 'Add': 'Ajouter', + 'Import': 'Importer', + 'Select steps; Shift-click to select a range': 'Sélectionner des étapes ; Maj+clic pour sélectionner une plage', + 'Move step up': 'Monter l’étape', + 'Move step down': 'Descendre l’étape', + 'Fit': 'Ajuster', + 'Undo': 'Annuler', + 'Redo': 'Rétablir', + 'Select an image step to edit annotations.': 'Sélectionnez une étape avec image pour modifier les annotations.', + 'Step title': 'Titre de l’étape', + 'Todo': 'À faire', + 'In progress': 'En cours', + 'Zoom': 'Zoom', + 'Pan X': 'Décalage X', + 'Pan Y': 'Décalage Y', + 'Exports crop to this view; the original image is never modified.': 'Les exportations sont rognées sur cette vue ; l’image d’origine n’est jamais modifiée.', + 'Annotations': 'Annotations', + 'Blocks': 'Blocs', + '+ Text block': '+ Bloc de texte', + '+ Code': '+ Code', + '+ Table': '+ Tableau', + 'Save changes. For guides linked to a shared archive, also writes the archive file.': 'Enregistrer les modifications. Pour les guides liés à une archive partagée, écrit aussi le fichier d’archive.', + 'Snapshot': 'Instantané', + 'Untitled step': 'Étape sans titre', + 'Select a step first.': 'Sélectionnez d’abord une étape.', + 'Select a step to add blocks.': 'Sélectionnez une étape pour ajouter des blocs.', + 'Remove block': 'Supprimer le bloc', + 'Move block up': 'Monter le bloc', + 'Move block down': 'Descendre le bloc', + 'Before title': 'Avant le titre', + 'After title': 'Après le titre', + 'Before image': 'Avant l’image', + 'After image': 'Après l’image', + 'Before description': 'Avant la description', + 'After description': 'Après la description', + 'Note': 'Remarque', + 'Warning': 'Avertissement', + 'Important': 'Important', + 'Tip': 'Astuce', + 'Block title': 'Titre du bloc', + 'Block text': 'Texte du bloc', + 'Language (e.g. bash)': 'Langage (p. ex. bash)', + 'Code': 'Code', + 'One row per line, cells separated by |': 'Une ligne par rangée, cellules séparées par |', + 'First line is the header row.': 'La première ligne est la ligne d’en-tête.', + 'Informational text, code, and table blocks can be reordered with drag handles or arrows.': 'Les blocs de texte, de code et de tableau peuvent être réordonnés avec les poignées ou les flèches.', + 'Add substep': 'Ajouter une sous-étape', + 'Make substep of…': 'Faire une sous-étape de…', + 'Duplicate step': 'Dupliquer l’étape', + 'Choose image…': 'Choisir une image…', + 'Retake screenshot': 'Reprendre la capture d’écran', + 'Full screen': 'Plein écran', + 'Region…': 'Zone…', + 'Move up': 'Monter', + 'Move down': 'Descendre', + 'Move to…': 'Déplacer vers…', + 'Delete step': 'Supprimer l’étape', + 'Select step {step}': 'Sélectionner l’étape {step}', + 'No steps yet.': 'Aucune étape pour l’instant.', + 'Add the copied annotation at the same position on each selected step': 'Ajouter l’annotation copiée à la même position sur chaque étape sélectionnée', + 'linked': 'lié', + 'local': 'local', + 'No step selected.': 'Aucune étape sélectionnée.', + 'Select a step to edit annotations.': 'Sélectionnez une étape pour modifier les annotations.', + 'No annotations yet. Pick a tool and drag on the canvas.': 'Aucune annotation. Choisissez un outil et faites-le glisser sur le canevas.', + 'Private detail: {kind}': 'Donnée privée : {kind}', + 'added by StepForge': 'ajoutée par StepForge', + 'Select an annotation to edit its style.': 'Sélectionnez une annotation pour modifier son style.', + 'Bottom': 'Bas', + 'Top': 'Haut', + 'Left': 'Gauche', + 'Right': 'Droite', + 'Color': 'Couleur', + 'Stroke': 'Contour', + 'Annotation text': 'Texte de l’annotation', + 'Type': 'Type', + 'Fill': 'Remplissage', + 'Stroke width': 'Épaisseur du contour', + 'Font size': 'Taille de police', + 'Text color': 'Couleur du texte', + 'Radius': 'Rayon', + 'Tail': 'Pointe', + 'Copy this style to every other “{type}” annotation:': 'Copier ce style sur toutes les autres annotations « {type} » :', + 'Overwrite the style of every “{type}” annotation on this step with the style shown above.': 'Remplacer le style de toutes les annotations « {type} » de cette étape par le style ci-dessus.', + 'This step': 'Cette étape', + 'Overwrite the style of every “{type}” annotation across all steps in this guide with the style shown above.': 'Remplacer le style de toutes les annotations « {type} » de toutes les étapes de ce guide par le style ci-dessus.', + 'Entire guide': 'Tout le guide', + 'Could not undo the move: {error}': 'Impossible d’annuler le déplacement : {error}', + 'Undo anyway': 'Annuler quand même', + 'Could not undo every step: {error}': 'Impossible d’annuler toutes les étapes : {error}', + 'Nothing to undo.': 'Rien à annuler.', + 'Nothing to redo.': 'Rien à rétablir.', + 'Save failed': 'Échec de l’enregistrement', + 'Could not save this step — your changes are kept. Retrying…': 'Impossible d’enregistrer cette étape — vos modifications sont conservées. Nouvelle tentative…', + 'Could not save guide details — your changes are kept. Retrying…': 'Impossible d’enregistrer les détails du guide — vos modifications sont conservées. Nouvelle tentative…', + 'Saved and synced to linked archive.': 'Enregistré et synchronisé avec l’archive liée.', + 'Saved locally, but the linked archive is locked by another session.': 'Enregistré localement, mais l’archive liée est verrouillée par une autre session.', + 'Saved.': 'Enregistré.', + 'Merged changes from another session.': 'Modifications d’une autre session fusionnées.', + 'Snapshot name': 'Nom de l’instantané', + 'Snapshot created.': 'Instantané créé.', + 'Add Step': 'Ajouter une étape', + 'Step added.': 'Étape ajoutée.', + 'Add Substep': 'Ajouter une sous-étape', + 'Substep title': 'Titre de la sous-étape', + 'Untitled substep': 'Sous-étape sans titre', + 'Substep added.': 'Sous-étape ajoutée.', + 'A step cannot be made a substep of itself or one of its own substeps.': 'Une étape ne peut pas devenir une sous-étape d’elle-même ni de l’une de ses propres sous-étapes.', + '“{title}” is already a substep of step {number}.': '« {title} » est déjà une sous-étape de l’étape {number}.', + '“{title}” is now a substep of step {number}.': '« {title} » est maintenant une sous-étape de l’étape {number}.', + 'Untitled step copy': 'Copie d’étape sans titre', + 'Step duplicated.': 'Étape dupliquée.', + 'Stop the recording session before retaking a screenshot.': 'Arrêtez la session d’enregistrement avant de reprendre une capture d’écran.', + 'Screenshot retaken.': 'Capture d’écran reprise.', + 'Screenshot replaced.': 'Capture d’écran remplacée.', + 'Private details will be checked again before publishing.': 'Les données privées seront de nouveau vérifiées avant la publication.', + 'Its annotations were kept, so check they still line up.': 'Ses annotations ont été conservées : vérifiez qu’elles sont toujours bien placées.', + 'Press Ctrl+Z to undo.': 'Appuyez sur Ctrl+Z pour annuler.', + 'Step deleted. Press Ctrl+Z to undo.': 'Étape supprimée. Appuyez sur Ctrl+Z pour annuler.', + 'Could not move the step: {error}': 'Impossible de déplacer l’étape : {error}', + 'Top level': 'Niveau supérieur', + 'At the top': 'Tout en haut', + 'Move “{title}”': 'Déplacer « {title} »', + 'Its substeps move with it.': 'Ses sous-étapes sont déplacées avec elle.', + 'The guide changed while you were choosing, so the step was not moved. Try Move to… again.': 'Le guide a changé pendant votre choix, l’étape n’a donc pas été déplacée. Réessayez Déplacer vers….', + 'Step moved. Press Ctrl+Z to undo.': 'Étape déplacée. Appuyez sur Ctrl+Z pour annuler.', + 'A step can’t be dropped inside its own substeps.': 'Une étape ne peut pas être déposée dans ses propres sous-étapes.', + 'Images imported.': 'Images importées.', + 'Captured.': 'Capturé.', + 'Capture full screen': 'Capturer le plein écran', + 'Capture window': 'Capturer la fenêtre', + 'Capture region…': 'Capturer une zone…', + 'Paste image as step': 'Coller l’image comme étape', + 'Import images…': 'Importer des images…', + 'Start capture session (hotkey)': 'Démarrer une session de capture (raccourci)', + 'Image pasted as a new step.': 'Image collée comme nouvelle étape.', + 'Clipboard has no image.': 'Le presse-papiers ne contient pas d’image.', + 'Shared to {path}': 'Partagé vers {path}', + 'Restore “{name}”? Current state is snapshotted first, so this is undoable.': 'Restaurer « {name} » ? L’état actuel est d’abord enregistré dans un instantané, l’opération est donc réversible.', + 'Snapshot restored.': 'Instantané restauré.', + 'Could not compare with this snapshot: {error}': 'Impossible de comparer avec cet instantané : {error}', + 'Guide placeholders': 'Espaces réservés du guide', + 'Use [[Name]] in titles, descriptions, and blocks. Guide values override global ones.': 'Utilisez [[Nom]] dans les titres, descriptions et blocs. Les valeurs du guide remplacent les valeurs globales.', + 'Placeholders saved.': 'Espaces réservés enregistrés.', + 'Guide information saved.': 'Informations du guide enregistrées.', + 'Restore this cloud snapshot? Your current local guide is kept as a cloud snapshot first.': 'Restaurer cet instantané cloud ? Votre guide local actuel est d’abord conservé comme instantané cloud.', + 'Cloud snapshot restored.': 'Instantané cloud restauré.', + 'Remove every cloud snapshot for this guide? This also turns off cloud sync for the guide. The local guide is kept.': 'Supprimer tous les instantanés cloud de ce guide ? Cela désactive aussi la synchronisation cloud du guide. Le guide local est conservé.', + 'Cloud copies removed.': 'Copies cloud supprimées.', + 'Style applied to all “{type}” annotations in this step.': 'Style appliqué à toutes les annotations « {type} » de cette étape.', + 'Style applied to all “{type}” annotations in the guide.': 'Style appliqué à toutes les annotations « {type} » du guide.', + 'Capture paused.': 'Capture en pause.', + 'Capture resumed.': 'Capture reprise.', + 'Imported template for {format}.': 'Modèle importé pour {format}.', + 'Template shared as .sfglt.': 'Modèle partagé en .sfglt.', + 'Preview opened (first steps only).': 'Aperçu ouvert (premières étapes uniquement).', + 'This guide': 'Ce guide', + 'Linked archive saved.': 'Archive liée enregistrée.', + 'Could not save linked archive.': 'Impossible d’enregistrer l’archive liée.', + 'Linked archive force-saved.': 'Archive liée enregistrée de force.', + 'Export the current guide': 'Exporter le guide actuel', + 'Linked guide': 'Guide lié', + 'Show linked archive details': 'Afficher les détails de l’archive liée', + 'Start capture session': 'Démarrer une session de capture', + 'Enable hotkey capture for this guide': 'Activer la capture par raccourci pour ce guide', + 'Could not save every step: {error}': 'Impossible d’enregistrer toutes les étapes : {error}', + '{count} skipped.': '{count} ignorés.', + 'Nothing was replaced.': 'Rien n’a été remplacé.', + 'No steps changed.': 'Aucune étape modifiée.', + 'Stopped early: {error}': 'Arrêt anticipé : {error}', + 'Crop the working image to the selected area?': 'Rogner l’image de travail sur la zone sélectionnée ?', + 'Image cropped.': 'Image rognée.', + 'Edit tooltip': 'Modifier l’info-bulle', + 'Edit text': 'Modifier le texte', + 'Annotation copied.': 'Annotation copiée.', + 'AI will write the title, description and blocks for {count} steps, make substeps where steps belong together, and add placeholders for values that repeat.': { one: 'L’IA va rédiger le titre, la description et les blocs de {count} étape.', other: 'L’IA va rédiger le titre, la description et les blocs de {count} étapes, créer des sous-étapes quand des étapes vont ensemble et ajouter des espaces réservés pour les valeurs répétées.' }, + 'Made {count} substeps.': { one: '{count} sous-étape créée.', other: '{count} sous-étapes créées.' }, + 'Stopped. AI wrote {written} of {count} steps.': { one: 'Arrêté. L’IA a rédigé {written} étape sur {count}.', other: 'Arrêté. L’IA a rédigé {written} étapes sur {count}.' }, + 'AI wrote {written} of {count} steps.': { one: 'L’IA a rédigé {written} étape sur {count}.', other: 'L’IA a rédigé {written} étapes sur {count}.' }, + 'AI wrote the whole guide ({count} steps).': { one: 'L’IA a rédigé tout le guide ({count} étape).', other: 'L’IA a rédigé tout le guide ({count} étapes).' }, + '{count} steps': { one: '{count} étape', other: '{count} étapes' }, + 'Paste annotation to {count} steps': { one: 'Coller l’annotation sur {count} étape', other: 'Coller l’annotation sur {count} étapes' }, + '{count} steps were changed since this edit, perhaps by AI or another app. Undo anyway and overwrite those changes?': { one: 'Une étape a été modifiée depuis cette action, peut-être par l’IA ou une autre application. Annuler quand même et écraser ces modifications ?', other: '{count} étapes ont été modifiées depuis cette action, peut-être par l’IA ou une autre application. Annuler quand même et écraser ces modifications ?' }, + '{count} conflicts were settled in favor of the most recent edit.': { one: '{count} conflit a été résolu en faveur de la modification la plus récente.', other: '{count} conflits ont été résolus en faveur de la modification la plus récente.' }, + 'Replaced {applied} in {count} steps.': { one: '{applied} remplacement(s) dans {count} étape.', other: '{applied} remplacement(s) dans {count} étapes.' }, + 'Annotation pasted onto {count} steps.': { one: 'Annotation collée sur {count} étape.', other: 'Annotation collée sur {count} étapes.' }, + 'Skipped {count} steps (already there, no image, or a different image shape): {steps}.': { one: '{count} étape ignorée (déjà présente, sans image ou de forme d’image différente) : {steps}.', other: '{count} étapes ignorées (déjà présentes, sans image ou de forme d’image différente) : {steps}.' }, + 'Close (Esc)': 'Fermer (Échap)', + 'Confirm': 'Confirmer', + 'Nothing else to choose': 'Rien d’autre à choisir', + '{count} years': { one: '{count} an', other: '{count} ans' }, + '{count} months': { one: '{count} mois', other: '{count} mois' }, + 'Guide library': 'Bibliothèque de guides', + 'Cloud sync status': 'État de la synchronisation cloud', + 'Search guides… (Ctrl+/ for quick actions)': 'Rechercher des guides… (Ctrl+/ pour les actions rapides)', + 'Beta': 'Bêta', }); diff --git a/core/locales/ja.js b/core/locales/ja.js index 79c016cf..96012e47 100644 --- a/core/locales/ja.js +++ b/core/locales/ja.js @@ -5,4 +5,736 @@ if (typeof module !== 'undefined' && module.exports) module.exports = messages; if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['ja'] = messages; })(typeof window !== 'undefined' ? window : null, { + 'Import images as steps': '画像をステップとして読み込む', + 'Choose a screenshot for this step': 'このステップのスクリーンショットを選択', + 'Choose guide storage folder': 'ガイドの保存フォルダーを選択', + 'Share guide as file': 'ガイドをファイルとして共有', + 'Open guide archive': 'ガイドのアーカイブを開く', + 'Choose output folder': '出力フォルダーを選択', + 'Choose an image': '画像を選択', + 'Choose a font file': 'フォントファイルを選択', + 'Choose a CSS file': 'CSS ファイルを選択', + 'Choose a certificate': '証明書を選択', + '{host} asks for a certificate to sign you in.': '{host} がサインイン用の証明書を求めています。', + 'Choose the one from your smart card that you use to sign in to websites. StepForge remembers your choice for this site.': 'Web サイトへのサインインに使っているスマートカードの証明書を選択してください。StepForge はこのサイトでの選択を記憶します。', + '{subject} (issued by {issuer})': '{subject}(発行者: {issuer})', + 'Cancel': 'キャンセル', + 'just now': 'たった今', + '{count} min ago': '{count} 分前', + '{count} hr ago': '{count} 時間前', + 'yesterday': '昨日', + 'Last verified {date}': '最終確認日 {date}', + 'Last saved {date}': '最終保存日 {date}', + '{since}, over {interval} ago. Open the guide to check it still matches the software, or right-click → Mark as verified.': '{since}、{interval}以上経過しています。ガイドを開いてソフトウェアと一致しているか確認するか、右クリック → 確認済みにする を選んでください。', + 'Deleted for good within a day': '1 日以内に完全に削除されます', + 'An AI agent updated your brand profiles.': 'AI エージェントがブランドプロファイルを更新しました。', + 'An AI agent updated this guide.': 'AI エージェントがこのガイドを更新しました。', + 'Documentation generated.': 'ドキュメントを生成しました。', + 'Capture, annotate, and export step-by-step guides.': 'ステップごとのガイドをキャプチャし、注釈を付けてエクスポートします。', + 'New Capture': '新しいキャプチャ', + 'Start a guide and capture your screen': 'ガイドを作成して画面をキャプチャ', + 'Existing Workspace': '既存のワークスペース', + 'Browse your guide library': 'ガイドライブラリを表示', + 'Settings': '設定', + 'Theme, capture, and export options': 'テーマ、キャプチャ、エクスポートのオプション', + 'Recent guides': '最近のガイド', + 'Untitled capture': '無題のキャプチャ', + 'synced': '同期済み', + 'syncing…': '同期中…', + 'pending': '保留中', + 'needs attention': '要確認', + '{service} settings': '{service} の設定', + 'Recording - stopped': '録画 - 停止中', + 'New recording': '新しい録画', + 'waiting for screen sharing': '画面共有を待っています', + 'ready': '準備完了', + 'paused': '一時停止中', + 'on click': 'クリック時', + 'every {seconds}s': '{seconds} 秒ごと', + 'hotkey only': 'ホットキーのみ', + 'StepForge tucks away and starts capturing': 'StepForge が隠れてキャプチャを開始します', + 'Start recording': '録画を開始', + 'Stop recording': '録画を停止', + 'Capture session — {trigger}': 'キャプチャセッション — {trigger}', + 'Recording - {trigger}': '録画 - {trigger}', + 'Full screen (window capture unavailable)': '全画面(ウィンドウのキャプチャは利用できません)', + 'Library': 'ライブラリ', + 'Capture a screenshot step': 'スクリーンショットのステップをキャプチャ', + 'Capture ▾': 'キャプチャ ▾', + 'Save': '保存', + 'Export': 'エクスポート', + 'Share this guide as a file or on the web': 'このガイドをファイルまたは Web で共有', + 'Save as a .sfgz file…': '.sfgz ファイルとして保存…', + 'Publish to the web…': 'Web に公開…', + 'Publish to Confluence…': 'Confluence に公開…', + 'Publish to Wiki.js…': 'Wiki.js に公開…', + 'Share ▾': '共有 ▾', + 'AI is writing. Click to stop.': 'AI が作成中です。クリックすると停止します。', + 'Let AI write this guide': 'このガイドを AI に作成させる', + 'AI ▾': 'AI ▾', + 'Rename guide…': 'ガイド名を変更…', + 'Guide information…': 'ガイド情報…', + 'Guide placeholders…': 'ガイドのプレースホルダー…', + 'Backups & snapshots…': 'バックアップとスナップショット…', + 'Find private details…': '個人情報を検索…', + 'Linked guide…': 'リンクされたガイド…', + 'Linked guide (not linked)': 'リンクされたガイド(未リンク)', + 'Keyboard shortcuts…': 'キーボードショートカット…', + 'Quick actions (Ctrl+/)': 'クイックアクション (Ctrl+/)', + 'Settings…': '設定…', + 'More ▾': 'その他 ▾', + 'Results for “{query}”': '「{query}」の検索結果', + 'All guides': 'すべてのガイド', + 'Favorites': 'お気に入り', + 'Folders': 'フォルダー', + 'New folder': '新しいフォルダー', + 'No folders yet': 'フォルダーはまだありません', + 'Trash': 'ごみ箱', + 'Select': '選択', + 'Empty trash': 'ごみ箱を空にする', + 'Import ▾': 'インポート ▾', + 'New guide': '新しいガイド', + 'Import a copy of a guide (.sfgz)…': 'ガイドのコピーをインポート (.sfgz)…', + 'Open a linked guide (.sfgz)…': 'リンクされたガイドを開く (.sfgz)…', + 'Rename folder': 'フォルダー名を変更', + 'Delete folder': 'フォルダーを削除', + 'Search': '検索', + 'No results for this query.': 'この検索に一致する結果はありません。', + 'No guides yet': 'ガイドはまだありません', + 'Create a guide and start capturing steps, or import one someone shared with you.': 'ガイドを作成してステップのキャプチャを始めるか、共有されたガイドをインポートしてください。', + 'No favorites': 'お気に入りはありません', + 'Star a guide to keep it here.': 'ガイドに星を付けるとここに表示されます。', + 'This folder is empty': 'このフォルダーは空です', + 'Right-click a guide, or select guides, to move them here.': 'ガイドを右クリックするか、ガイドを選択して、ここに移動します。', + 'Trash is empty': 'ごみ箱は空です', + 'Deleted guides stay here until you empty the trash.': '削除したガイドは、ごみ箱を空にするまでここに残ります。', + 'Restore': '復元', + 'Deleted {when}': '{when}に削除', + 'Deselect': '選択を解除', + 'Open guide · Ctrl-click to select': 'ガイドを開く · Ctrl+クリックで選択', + 'Remove from favorites': 'お気に入りから削除', + 'Add to favorites': 'お気に入りに追加', + 'Untitled guide': '無題のガイド', + 'Saved to a linked .sfgz file': 'リンクされた .sfgz ファイルに保存されています', + 'Linked': 'リンク済み', + 'Open on another device': '別のデバイスで開かれています', + 'Locked': 'ロック中', + 'May need review': '見直しが必要な可能性', + 'Step': 'ステップ', + 'Guide': 'ガイド', + 'Move to {folder}': '{folder} に移動', + 'Move to no folder': 'フォルダーから外す', + 'Open guide': 'ガイドを開く', + 'Unfavorite': 'お気に入りから削除', + 'Favorite': 'お気に入りに追加', + 'Mark as verified': '確認済みにする', + 'Duplicate guide': 'ガイドを複製', + 'Delete guide': 'ガイドを削除', + 'Delete forever': '完全に削除', + 'Selection actions': '選択項目の操作', + '{count} of {total} selected': '{total} 件中 {count} 件を選択', + 'Click guides to select them': 'ガイドをクリックして選択', + 'Clear': 'クリア', + 'Select all': 'すべて選択', + 'Move to ▾': '移動先 ▾', + 'Record that these guides still match the software today': 'これらのガイドが現在のソフトウェアと一致していることを記録します', + 'Delete': '削除', + 'No folder': 'フォルダーなし', + 'New Guide': '新しいガイド', + 'Title': 'タイトル', + 'Folder name': 'フォルダー名', + 'Delete the folder “{folder}”? Guides stay in the library.': 'フォルダー「{folder}」を削除しますか?ガイドはライブラリに残ります。', + 'Marked as verified today.': '今日、確認済みにしました。', + '{count} guides marked as verified today.': '{count} 件のガイドを今日、確認済みにしました。', + 'Delete “{title}”? It moves to Trash. If it syncs to the cloud, it is also removed from your other devices and one cloud recovery snapshot is retained.': '「{title}」を削除しますか?ごみ箱に移動します。クラウドと同期している場合は他のデバイスからも削除され、クラウドに復元用のスナップショットが 1 つ残ります。', + 'Permanently empty the trash?': 'ごみ箱を完全に空にしますか?', + 'Rename guide': 'ガイド名を変更', + 'Create a blank guide': '空のガイドを作成', + 'Import archive': 'アーカイブをインポート', + 'Open a .sfgz guide archive': '.sfgz ガイドアーカイブを開く', + 'Open linked archive': 'リンクされたアーカイブを開く', + 'Import a linked guide from .sfgz': '.sfgz からリンクされたガイドをインポート', + 'Open application settings': 'アプリの設定を開く', + 'Refresh library': 'ライブラリを更新', + 'Reload guides and folders': 'ガイドとフォルダーを再読み込み', + '{count} days ago': { other: '{count} 日前' }, + 'Deleted for good in {count} days': { other: '{count} 日後に完全に削除されます' }, + 'Blurred {count} possible private details in this step.': { other: 'このステップで個人情報の可能性がある箇所を {count} 件ぼかしました。' }, + '{count} deleted guides': { other: '削除したガイド {count} 件' }, + '{count} guides': { other: 'ガイド {count} 件' }, + 'Deleted guides stay here for {count} days, then are deleted for good. Change this in Settings → General.': { other: '削除したガイドはここに {count} 日間残り、その後完全に削除されます。設定 → 一般 で変更できます。' }, + 'Permanently delete {count} items? This cannot be undone.': { other: '{count} 件の項目を完全に削除しますか?この操作は元に戻せません。' }, + 'Delete {count} guides? They’ll move to Trash. Guides that sync to the cloud are also removed from your other devices, with one cloud recovery snapshot retained.': { other: '{count} 件のガイドを削除しますか?ごみ箱に移動します。クラウドと同期しているガイドは他のデバイスからも削除され、クラウドに復元用のスナップショットが 1 つ残ります。' }, + 'Plain (no highlighting)': 'プレーン(ハイライトなし)', + 'Clear shortcut': 'ショートカットをクリア', + 'Click, then press a key combination': 'クリックしてからキーの組み合わせを押してください', + 'Press a key combination…': 'キーの組み合わせを押してください…', + 'Click to set shortcut': 'クリックしてショートカットを設定', + 'Move step': 'ステップを移動', + '{place} (current place)': '{place}(現在の位置)', + 'Place': '位置', + 'Level': '階層', + 'Move': '移動', + 'OK': 'OK', + 'Search guides, steps, and commands': 'ガイド、ステップ、コマンドを検索', + 'Type to search, arrows to move, Enter to open.': '入力して検索、矢印キーで移動、Enter で開きます。', + 'No matches.': '一致するものはありません。', + 'Quick Actions': 'クイックアクション', + 'Close': '閉じる', + 'Find': '検索', + 'Replace with': '置換後', + 'Replace': '置換', + 'Type something to find.': '検索する文字列を入力してください。', + 'Searching…': '検索中…', + 'Replace {count}': '{count} 件を置換', + 'Step {number}': 'ステップ {number}', + 'Showing the first {count} matches. Narrow the search to see the rest.': '最初の {count} 件の一致を表示しています。残りを表示するには検索を絞り込んでください。', + 'Could not replace: {error}': '置換できませんでした: {error}', + 'Find and replace': '検索と置換', + 'Searches step titles and descriptions, text, code and table blocks, and annotation labels. Screenshots are not changed. Ctrl+Z undoes the whole replacement.': 'ステップのタイトルと説明、テキスト・コード・表のブロック、注釈のラベルを検索します。スクリーンショットは変更されません。Ctrl+Z で置換全体を元に戻せます。', + 'Check for updates': '更新を確認', + 'Update now': '今すぐ更新', + 'Starting…': '開始しています…', + 'Your latest changes couldn’t be saved, so the update didn’t start.': '最新の変更を保存できなかったため、更新は開始されませんでした。', + 'Couldn’t start the update. Try again, or download the new installer.': '更新を開始できませんでした。もう一度試すか、新しいインストーラーをダウンロードしてください。', + 'Closing StepForge…': 'StepForge を終了しています…', + 'An administrator window will install the update and reopen StepForge.': '管理者ウィンドウで更新がインストールされ、StepForge が再び開きます。', + 'Couldn’t start the update.': '更新を開始できませんでした。', + 'Open Microsoft Store': 'Microsoft Store を開く', + 'Couldn’t open the Microsoft Store. Open it from the Start menu, then choose Library → Get updates.': 'Microsoft Store を開けませんでした。スタートメニューから開き、ライブラリ → 更新プログラムを取得する を選んでください。', + 'Checking…': '確認しています…', + 'Couldn’t check for updates. Try again later.': '更新を確認できませんでした。後でもう一度お試しください。', + 'Version {version} is available': 'バージョン {version} が利用可能です', + 'You have {version}.': '現在のバージョンは {version} です。', + 'View release and download': 'リリースを表示してダウンロード', + 'You’re up to date': '最新の状態です', + 'Version {version} is the latest release.': 'バージョン {version} が最新のリリースです。', + 'Couldn’t check for updates.': '更新を確認できませんでした。', + 'Try again': '再試行', + 'Checking guide storage location…': 'ガイドの保存場所を確認しています…', + 'Choose folder…': 'フォルダーを選択…', + 'Use default': '既定の場所を使用', + 'Cancel pending move': '予定している移動をキャンセル', + 'Default location.': '既定の場所です。', + 'Default: {path}': '既定: {path}', + 'Moving to {path} the next time StepForge starts.': '次回 StepForge の起動時に {path} へ移動します。', + 'Set by STEPFORGE_DATA_DIR for this session.': 'このセッションでは STEPFORGE_DATA_DIR で設定されています。', + 'Could not read guide storage location.': 'ガイドの保存場所を読み取れませんでした。', + 'Could not schedule the library move.': 'ライブラリの移動を予約できませんでした。', + 'Development build': '開発ビルド', + 'Release': 'リリース', + 'Unavailable': '利用不可', + 'System': 'システム', + 'Light': 'ライト', + 'Dark': 'ダーク', + 'System default': 'システムの既定', + 'Fullscreen': '全画面', + 'Window': 'ウィンドウ', + 'One per line, for example a name or project code': '1 行に 1 つ。例: 名前やプロジェクトコード', + 'Words to always hide': '常に隠す語句', + 'Default focus amount': '既定のフォーカス量', + 'Reset': 'リセット', + 'Hotkey': 'ホットキー', + 'Timed interval': '一定間隔', + 'After 7 days': '7 日後', + 'After 30 days': '30 日後', + 'After 90 days': '90 日後', + 'Never': 'しない', + 'After {interval}': '{interval}後', + 'Loading the setup commands…': 'セットアップ用のコマンドを読み込んでいます…', + 'Run this in a terminal once.': 'ターミナルで 1 回実行してください。', + 'Claude Desktop, Cursor, and other apps': 'Claude Desktop、Cursor、その他のアプリ', + 'Add this to the app’s MCP servers settings.': 'アプリの MCP サーバー設定にこれを追加してください。', + 'Copied': 'コピーしました', + 'Copy': 'コピー', + 'Could not build the setup commands: {error}': 'セットアップ用のコマンドを作成できませんでした: {error}', + 'Not tested yet. Vision-capable models can also inspect each step’s screenshot.': 'まだテストしていません。画像を扱えるモデルは各ステップのスクリーンショットも確認できます。', + 'Test connection': '接続をテスト', + 'Testing…': 'テスト中…', + 'Checking Ollama at the configured host…': '設定されたホストの Ollama を確認しています…', + 'Could not connect to Ollama.': 'Ollama に接続できませんでした。', + 'Connected to {host} with {model}. It can inspect screenshots.': '{host} に {model} で接続しました。スクリーンショットを確認できます。', + 'Connected to {host} with {model}. This model is text-only, so StepForge will use OCR and metadata only.': '{host} に {model} で接続しました。このモデルはテキストのみに対応しているため、StepForge は OCR とメタデータだけを使います。', + 'Connected to {host}, but {model} isn’t installed. Installed models: {installed}.': '{host} に接続しましたが、{model} はインストールされていません。インストール済みのモデル: {installed}。', + 'that model': 'そのモデル', + 'Connected to {host}, but it has no models yet. Run “ollama pull gemma3” first.': '{host} に接続しましたが、モデルがまだありません。先に「ollama pull gemma3」を実行してください。', + 'Placeholder name': 'プレースホルダー名', + 'Content': '内容', + 'Placeholder content': 'プレースホルダーの内容', + 'Markdown or regular text': 'Markdown または通常のテキスト', + 'Remove placeholder': 'プレースホルダーを削除', + 'Add placeholder': 'プレースホルダーを追加', + 'General': '一般', + 'Appearance, exports, where your guides and deleted guides are kept, and when guides are due for review.': '外観、エクスポート、ガイドと削除したガイドの保存場所、ガイドの見直し時期。', + 'Appearance': '外観', + 'Theme': 'テーマ', + 'Match your system, or always use light or dark.': 'システムに合わせるか、常にライトまたはダークを使います。', + 'Spellcheck': 'スペルチェック', + 'Underline misspelled words while editing.': '編集中にスペルミスに下線を引きます。', + 'Exports': 'エクスポート', + 'Open folder after export': 'エクスポート後にフォルダーを開く', + 'Show the exported file in your file manager.': 'エクスポートしたファイルをファイルマネージャーで表示します。', + 'Preview step count': 'プレビューのステップ数', + 'How many steps the export preview renders.': 'エクスポートのプレビューに表示するステップ数。', + 'Code theme': 'コードのテーマ', + 'Syntax colors for code blocks in PDF, Word, HTML and Confluence exports.': 'PDF、Word、HTML、Confluence へのエクスポートでのコードブロックの構文の色。', + 'Guide storage': 'ガイドの保存場所', + 'Choose an empty folder. StepForge copies and verifies your library, then switches to it the next time it starts. The old library is kept as a backup.': '空のフォルダーを選んでください。StepForge はライブラリをコピーして検証し、次回の起動時にそのフォルダーに切り替えます。以前のライブラリはバックアップとして残ります。', + 'Empty the trash': 'ごみ箱を空にする', + 'Deleted guides are removed for good after this long.': '削除したガイドはこの期間が過ぎると完全に削除されます。', + 'Guide reviews': 'ガイドの見直し', + 'Flag guides for review': '見直しが必要なガイドに印を付ける', + 'Guides that haven’t been saved or marked verified for this long show “May need review” in the library. Each guide can change this in Guide information.': 'この期間、保存も確認もされていないガイドには、ライブラリで「見直しが必要な可能性」と表示されます。ガイドごとにガイド情報で変更できます。', + 'Language': '言語', + 'The language of StepForge’s menus, buttons and messages. Exported guides stay in English.': 'StepForge のメニュー、ボタン、メッセージの言語です。エクスポートしたガイドは英語のままです。', + 'Branding': 'ブランディング', + 'Customize how exported guides appear.': 'エクスポートしたガイドの見た目をカスタマイズします。', + 'Capture': 'キャプチャ', + 'How StepForge records steps.': 'StepForge がステップを記録する方法。', + 'Recording': '録画', + 'Default mode': '既定のモード', + 'Record the whole screen or the active window. For a region, use Capture → Capture region….': '画面全体またはアクティブなウィンドウを録画します。範囲を指定するには キャプチャ → 範囲をキャプチャ… を使います。', + 'Delay': '遅延', + 'Milliseconds to wait before each screenshot.': '各スクリーンショットの前に待つミリ秒数。', + 'Click marker': 'クリックマーカー', + 'Draw a marker where you clicked.': 'クリックした場所にマーカーを描きます。', + 'Capture outside clicks': '外部のクリックをキャプチャ', + 'Record clicks in other apps while a capture session is running.': 'キャプチャセッション中に他のアプリでのクリックを記録します。', + 'When clicks can’t be detected': 'クリックを検出できない場合', + 'Capture with': 'キャプチャ方法', + 'Some desktops don’t report clicks. Choose how steps are captured instead.': '一部のデスクトップはクリックを通知しません。その場合のステップのキャプチャ方法を選んでください。', + 'Timer interval': 'タイマーの間隔', + 'Seconds between timed captures.': '一定間隔でのキャプチャの間隔(秒)。', + 'Hotkeys': 'ホットキー', + 'Take a step. Also used when clicks can’t be detected.': 'ステップを 1 つキャプチャします。クリックを検出できない場合にも使います。', + 'Pause / resume': '一時停止 / 再開', + 'Pause or resume the capture session.': 'キャプチャセッションを一時停止または再開します。', + 'Editor': 'エディター', + 'Defaults for new steps and automatic backups.': '新しいステップの既定値と自動バックアップ。', + 'New steps': '新しいステップ', + 'Focus recorded clicks': '記録したクリックにフォーカス', + 'Zoom new screenshots toward where you clicked.': '新しいスクリーンショットをクリックした場所に向けて拡大します。', + 'More context (1×) to closer focus (2×). Applies to new captures only.': '広い範囲 (1×) から近いフォーカス (2×) まで。新しいキャプチャにのみ適用されます。', + 'Focused view for new steps': '新しいステップをフォーカス表示で開く', + 'Open new steps in focused view.': '新しいステップをフォーカス表示で開きます。', + 'Backups': 'バックアップ', + 'Snapshots to keep': '保持するスナップショット数', + 'Older backup snapshots of each guide are removed. 0 keeps all of them.': '各ガイドの古いバックアップスナップショットは削除されます。0 ですべて保持します。', + 'Privacy': 'プライバシー', + 'Blur private details in screenshots. Everything is checked on this computer.': 'スクリーンショット内の個人情報をぼかします。すべてこのコンピューター上で確認されます。', + 'Private details': '個人情報', + 'Before a guide goes on the web, StepForge reads its screenshots and blurs email addresses, phone and card numbers, Social Security numbers, IP addresses, passwords, keys, and links with private tokens. You can review every blur and undo any that aren’t needed.': 'ガイドを Web に公開する前に、StepForge はスクリーンショットを読み取り、メールアドレス、電話番号やカード番号、社会保障番号、IP アドレス、パスワード、キー、個人用トークンを含むリンクをぼかします。ぼかしはすべて確認でき、不要なものは元に戻せます。', + 'Blur while recording': '録画中にぼかす', + 'Check each new capture as soon as it’s taken, instead of waiting until you publish.': '公開時まで待たずに、キャプチャのたびにすぐ確認します。', + 'Always hide these words': '常に隠す語句', + 'Names, project codes, or anything else to blur wherever it appears. One per line.': '名前、プロジェクトコードなど、どこに現れてもぼかしたいもの。1 行に 1 つ。', + 'AI': 'AI', + 'Let AI write your guides: a model on your own computer through Ollama, or an AI agent such as Claude or Codex.': 'AI にガイドを書かせます。Ollama を使って自分のコンピューターで動くモデルか、Claude や Codex などの AI エージェントを使えます。', + 'Enable AI': 'AI を有効にする', + 'Turn on the AI ▾ menu in the guide toolbar: write a whole guide, a step, or one field.': 'ガイドのツールバーに AI ▾ メニューを表示します。ガイド全体、ステップ、または 1 つの項目を作成できます。', + 'Auto-document captures': 'キャプチャを自動で文書化', + 'Write a title and description for each new capture as you record. Turn off to use AI only when you ask.': '録画中に新しいキャプチャごとにタイトルと説明を作成します。オフにすると、依頼したときだけ AI を使います。', + 'Let the model see screenshots': 'モデルにスクリーンショットを見せる', + 'Models that can read images see each step’s screenshot, with blurred areas already hidden. Turn off to send text only.': '画像を読めるモデルは各ステップのスクリーンショットを参照します。ぼかした部分はあらかじめ隠されます。オフにするとテキストだけを送ります。', + 'Ollama': 'Ollama', + 'Runs on your computer, so your screenshots and text stay here.': '自分のコンピューターで動くため、スクリーンショットとテキストは外部に出ません。', + 'Host': 'ホスト', + 'Where Ollama is running.': 'Ollama が動いている場所。', + 'Model': 'モデル', + 'Any installed model. Choose Test connection to list them.': 'インストール済みの任意のモデル。接続をテスト を選ぶと一覧が表示されます。', + 'AI guide': 'AI ガイド', + 'Recommended models': 'おすすめのモデル', + 'AI agents (Claude, Codex)': 'AI エージェント(Claude、Codex)', + 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Claude や Codex などの AI アプリは StepForge に接続して、ガイドを代わりに作成できます。テキストの書き直し、サブステップの作成、プレースホルダーの追加、スクリーンショットへの描画はできますが、ステップの並べ替えや削除はできません。', + 'Let AI agents edit your guides': 'AI エージェントにガイドの編集を許可', + 'Turn off to cut off every connected agent at once.': 'オフにすると、接続しているすべてのエージェントを一度に切断します。', + 'Agents can see screenshots': 'エージェントがスクリーンショットを参照できる', + 'Blurred areas are filled in first. Turn off to share step text only.': 'ぼかした部分は先に塗りつぶされます。オフにするとステップのテキストだけを共有します。', + 'Let AI agents edit brand profiles': 'AI エージェントにブランドプロファイルの編集を許可', + 'Agents can make and change brand profiles: logo, colors, fonts and CSS, and choose the one exports use. They can’t delete a profile.': 'エージェントはブランドプロファイル(ロゴ、色、フォント、CSS)を作成・変更し、エクスポートで使うものを選べます。プロファイルは削除できません。', + 'Accounts': 'アカウント', + 'Sync guides with Google Drive, OneDrive, Dropbox or Nextcloud, share them on the web with GitHub, or publish them to Confluence or Wiki.js.': 'Google Drive、OneDrive、Dropbox、Nextcloud とガイドを同期したり、GitHub で Web に共有したり、Confluence や Wiki.js に公開したりします。', + 'Placeholders': 'プレースホルダー', + 'Reusable text for every guide. Type [[name]] in a guide to insert it.': 'すべてのガイドで使える再利用可能なテキストです。ガイドに [[名前]] と入力すると挿入されます。', + 'About': 'バージョン情報', + 'Local-first step-by-step guides.': 'ローカルファーストのステップごとのガイド。', + 'Version': 'バージョン', + 'License': 'ライセンス', + 'Data folder': 'データフォルダー', + 'Updates': '更新', + 'Checks GitHub for a newer release. StepForge only connects when you press the button.': 'GitHub で新しいリリースを確認します。StepForge はボタンを押したときだけ接続します。', + 'Project on GitHub': 'GitHub のプロジェクト', + 'Release notes': 'リリースノート', + 'Report an issue': '問題を報告', + 'Settings sections': '設定のセクション', + 'AI is off. These settings are kept for later.': 'AI はオフです。これらの設定は後で使えるように保持されます。', + 'Default template': '既定のテンプレート', + 'Choose an output folder': '出力フォルダーを選択', + 'No branding': 'ブランディングなし', + 'JSON exports keep the raw data and aren’t branded.': 'JSON エクスポートは元のデータを保持し、ブランディングは適用されません。', + 'Change the default in Settings → Branding.': '既定値は 設定 → ブランディング で変更できます。', + 'None': 'なし', + 'Choose…': '選択…', + 'This format has no adjustable options.': 'この形式には調整できるオプションがありません。', + 'Keep it online for': '公開期間', + 'The guide is removed from the web after this.': 'この期間が過ぎると、ガイドは Web から削除されます。', + 'Published guides are public.': '公開したガイドは誰でも見られます。', + 'Anyone with the link can open it, and anyone who looks at your GitHub repository can find it. Blur passwords and other private details before you export.': 'リンクを知っている人は誰でも開くことができ、GitHub リポジトリを見た人は誰でも見つけられます。エクスポートする前にパスワードなどの個人情報をぼかしてください。', + 'Publish on the web': 'Web に公開', + 'After exporting, also put this guide on your GitHub Pages site and get a link to share.': 'エクスポート後、このガイドを GitHub Pages サイトにも公開し、共有用のリンクを取得します。', + 'Connect GitHub in Settings → Accounts → GitHub to publish guides on the web.': 'ガイドを Web に公開するには、設定 → アカウント → GitHub で GitHub に接続してください。', + 'Export and publish': 'エクスポートして公開', + 'Publish to {site}…': '{site} に公開…', + 'Publishing to {site} is easier.': '{site} に公開するほうが簡単です。', + 'Share → Publish to {site}… creates the page for you, uploads its screenshots and updates the same page when you publish again. Export only if you’d rather upload the files yourself.': '共有 → {site} に公開… を使うと、ページが作成され、スクリーンショットがアップロードされ、再公開時には同じページが更新されます。ファイルを自分でアップロードしたい場合のみエクスポートしてください。', + 'Save template': 'テンプレートを保存', + 'Template name': 'テンプレート名', + 'Save as template…': 'テンプレートとして保存…', + 'Manage…': '管理…', + 'Format': '形式', + 'Template': 'テンプレート', + 'Output folder': '出力フォルダー', + 'Options': 'オプション', + 'Preview…': 'プレビュー…', + 'Preview': 'プレビュー', + 'Exporting…': 'エクスポート中…', + 'No templates saved for this format yet.': 'この形式で保存されたテンプレートはまだありません。', + 'Rename template': 'テンプレート名を変更', + 'Name': '名前', + 'Rename': '名前を変更', + 'Duplicate': '複製', + 'Share…': '共有…', + 'Import .sfglt…': '.sfglt をインポート…', + 'Templates are shareable as .sfglt files.': 'テンプレートは .sfglt ファイルとして共有できます。', + 'Done': '完了', + 'Locked by {user}@{host}': '{user}@{host} がロック中', + 'another user': '別のユーザー', + 'another host': '別のホスト', + 'No active conflict': '競合はありません', + 'Archive': 'アーカイブ', + 'Not linked': '未リンク', + 'Opened': '開いた日時', + 'Unknown': '不明', + 'Last saved': '最終保存', + 'Lock': 'ロック', + 'Another editor has the archive locked. You can force-save if you intend to overwrite it.': '別のエディターがアーカイブをロックしています。上書きする場合は強制保存できます。', + 'Linked Guide': 'リンクされたガイド', + 'Show file': 'ファイルを表示', + 'Force save': '強制保存', + 'Save now': '今すぐ保存', + 'No snapshots yet. Automatic snapshots are created as you work; create one manually any time.': 'スナップショットはまだありません。作業中に自動でスナップショットが作成されます。手動でもいつでも作成できます。', + 'See what changed between this snapshot and the guide now': 'このスナップショットと現在のガイドの違いを表示', + 'Comparing…': '比較中…', + 'Compare': '比較', + 'Create snapshot': 'スナップショットを作成', + 'Backups & snapshots': 'バックアップとスナップショット', + 'Compare shows what changed since a snapshot. Restores are undoable — the current state is snapshotted first.': '比較では、スナップショット以降の変更が表示されます。復元は元に戻せます。現在の状態が先にスナップショットとして保存されます。', + 'Value': '値', + 'Remove': '削除', + 'e.g. Windows 11 24H2': '例: Windows 11 24H2', + 'Use the default ({interval})': '既定値を使用({interval})', + 'Use the default (off)': '既定値を使用(オフ)', + 'After you save, “May need review” shows in the library on {date} unless the guide is saved or verified again first.': '保存後、その前にガイドが再び保存または確認されない限り、{date} にライブラリで「見直しが必要な可能性」と表示されます。', + 'This guide is never flagged for review.': 'このガイドに見直しの印が付くことはありません。', + 'Today': '今日', + 'e.g. Jane Doe': '例: 山田 太郎', + 'e.g. Alex Lee, Sam Patel': '例: 佐藤 花子、鈴木 一郎', + 'e.g. Acme Corp': '例: 株式会社サンプル', + 'A short summary of this guide.': 'このガイドの短い概要。', + 'Guide information': 'ガイド情報', + 'All fields are optional. They appear below the title on the guide and on the PDF cover page.': 'すべての項目は省略可能です。ガイドのタイトルの下と PDF の表紙に表示されます。', + 'Author': '作成者', + 'Co-authors': '共同作成者', + 'Organization': '組織', + 'Description': '説明', + 'Shown on the first page of the PDF and at the top of other export formats.': 'PDF の 1 ページ目と、その他のエクスポート形式の冒頭に表示されます。', + 'Review': '見直し', + 'Software version': 'ソフトウェアのバージョン', + 'Last verified': '最終確認日', + 'Review again': '次回の見直し', + 'Include this guide in cloud sync': 'このガイドをクラウド同期に含める', + 'When off, this guide stays only on this device.': 'オフにすると、このガイドはこのデバイスにのみ保存されます。', + 'Cloud snapshots': 'クラウドのスナップショット', + 'Remove cloud copies': 'クラウドのコピーを削除', + 'Current cloud snapshot': '現在のクラウドのスナップショット', + 'Previous snapshot {number}': '以前のスナップショット {number}', + 'Current': '現在', + 'No cloud snapshots are available for this guide.': 'このガイドに利用できるクラウドのスナップショットはありません。', + 'The current snapshot and up to two previous snapshots are retained.': '現在のスナップショットと、以前のスナップショット最大 2 つが保持されます。', + 'Capture & steps': 'キャプチャとステップ', + 'Save (writes linked archive when guide is linked)': '保存(ガイドがリンクされている場合はリンクされたアーカイブにも書き込み)', + 'Quick actions palette': 'クイックアクションパレット', + 'Find and replace across the guide': 'ガイド全体で検索と置換', + 'Previous / next step': '前 / 次のステップ', + 'Move step (and its substeps) up / down': 'ステップ(とそのサブステップ)を上 / 下に移動', + 'Delete current step': '現在のステップを削除', + 'Undo / redo (including step moves, deleting steps and AI writes)': '元に戻す / やり直す(ステップの移動や削除、AI による作成を含む)', + 'Paste annotation, or clipboard image as new step': '注釈を貼り付け、またはクリップボードの画像を新しいステップとして貼り付け', + 'Canvas tools': 'キャンバスツール', + 'Select · Rectangle · Oval · Line · Arrow · Text': '選択 · 四角形 · 楕円 · 直線 · 矢印 · テキスト', + 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop': '吹き出し · 番号 · ぼかし · ハイライト · 拡大鏡 · カーソル · トリミング', + 'Copy selected annotation (in step-select mode, paste it onto many steps)': '選択した注釈をコピー(ステップ選択モードでは複数のステップに貼り付け)', + 'Delete selected annotation': '選択した注釈を削除', + 'Deselect annotation': '注釈の選択を解除', + 'Nudge selection by 1 px / 10 px': '選択範囲を 1 px / 10 px 移動', + 'View': '表示', + 'Zoom in / out': '拡大 / 縮小', + 'Fit image to window': '画像をウィンドウに合わせる', + 'Compare with a snapshot': 'スナップショットと比較', + 'Next change': '次の変更', + 'Previous change': '前の変更', + 'Mark the current change as viewed and go to the next one': '現在の変更を確認済みにして次へ進む', + 'Close the comparison': '比較を閉じる', + 'Keyboard shortcuts': 'キーボードショートカット', + 'Continue': '続行', + 'StepForge will hide after you continue.': '続行すると StepForge は隠れます。', + 'Before recording starts': '録画を開始する前に', + 'Recording tip': '録画のヒント', + 'To stop, use StepForge REC in the GNOME top panel, or restore StepForge from the dock.': '停止するには、GNOME のトップバーにある StepForge REC を使うか、Dock から StepForge を元に戻してください。', + 'When you want to pause or stop, use the red tray icon in the system tray.': '一時停止または停止するときは、システムトレイの赤いアイコンを使ってください。', + '{count} matches, {selected} selected, {blocked} cannot take this replacement.': { other: '{count} 件一致、{selected} 件選択、{blocked} 件はこの置換を適用できません。' }, + '{count} matches, {selected} selected.': { other: '{count} 件一致、{selected} 件選択。' }, + '{count} days': { other: '{count} 日' }, + 'email address': 'メールアドレス', + 'phone number': '電話番号', + 'card number': 'カード番号', + 'Social Security number': '社会保障番号', + 'IP address': 'IP アドレス', + 'password or key': 'パスワードまたはキー', + 'link with a private token': '個人用トークンを含むリンク', + 'word you asked to hide': '隠すよう指定した語句', + 'Rectangle': '四角形', + 'Oval': '楕円', + 'Line': '直線', + 'Arrow': '矢印', + 'Text': 'テキスト', + 'Tooltip': '吹き出し', + 'Number': '番号', + 'Blur': 'ぼかし', + 'Highlight': 'ハイライト', + 'Magnify': '拡大鏡', + 'Cursor': 'カーソル', + 'Text block': 'テキストブロック', + 'Code block': 'コードブロック', + 'Table': '表', + 'Stop writing': '作成を停止', + 'Turn on AI in Settings…': '設定で AI を有効にする…', + 'Write the whole guide': 'ガイド全体を作成', + 'Write the whole step': 'ステップ全体を作成', + 'Write the title': 'タイトルを作成', + 'Write the description': '説明を作成', + 'Write the blocks': 'ブロックを作成', + 'writing…': '作成中…', + 'Stopping AI…': 'AI を停止しています…', + 'Turn on AI in Settings first.': '先に設定で AI を有効にしてください。', + 'AI could not write this step.': 'AI はこのステップを作成できませんでした。', + 'AI could not write the title.': 'AI はタイトルを作成できませんでした。', + 'AI could not write the description.': 'AI は説明を作成できませんでした。', + 'AI could not write the blocks.': 'AI はブロックを作成できませんでした。', + 'There is nothing for AI to write from yet. Capture some steps first.': 'AI が作成の元にできるものがまだありません。先にいくつかステップをキャプチャしてください。', + 'Then it writes the guide’s title and description. Text you already wrote is polished, not replaced with something new.': '続いてガイドのタイトルと説明を作成します。すでに書いたテキストは置き換えられず、整えられます。', + 'A snapshot is saved first, so you can undo this from More → Backups & snapshots.': '先にスナップショットが保存されるため、その他 → バックアップとスナップショット から元に戻せます。', + 'before AI': 'AI の実行前', + 'Could not save a snapshot first, so AI did not start: {error}': '先にスナップショットを保存できなかったため、AI は開始しませんでした: {error}', + 'AI could not write a step.': 'AI はステップを作成できませんでした。', + 'AI could not organize the guide.': 'AI はガイドを整理できませんでした。', + 'AI could not write the guide title.': 'AI はガイドのタイトルを作成できませんでした。', + 'Added {placeholders}.': '{placeholders} を追加しました。', + 'Steps': 'ステップ', + 'Add': '追加', + 'Import': 'インポート', + 'Select steps; Shift-click to select a range': 'ステップを選択。Shift+クリックで範囲を選択', + 'Move step up': 'ステップを上へ', + 'Move step down': 'ステップを下へ', + 'Fit': '全体表示', + 'Undo': '元に戻す', + 'Redo': 'やり直す', + 'Select an image step to edit annotations.': '注釈を編集するには、画像のあるステップを選択してください。', + 'Step title': 'ステップのタイトル', + 'Todo': '未着手', + 'In progress': '作業中', + 'Zoom': 'ズーム', + 'Pan X': '横位置', + 'Pan Y': '縦位置', + 'Exports crop to this view; the original image is never modified.': 'エクスポートはこの表示範囲でトリミングされます。元の画像は変更されません。', + 'Annotations': '注釈', + 'Blocks': 'ブロック', + '+ Text block': '+ テキストブロック', + '+ Code': '+ コード', + '+ Table': '+ 表', + 'Save changes. For guides linked to a shared archive, also writes the archive file.': '変更を保存します。共有アーカイブにリンクされたガイドでは、アーカイブファイルにも書き込みます。', + 'Snapshot': 'スナップショット', + 'Untitled step': '無題のステップ', + 'Select a step first.': '先にステップを選択してください。', + 'Select a step to add blocks.': 'ブロックを追加するステップを選択してください。', + 'Remove block': 'ブロックを削除', + 'Move block up': 'ブロックを上へ', + 'Move block down': 'ブロックを下へ', + 'Before title': 'タイトルの前', + 'After title': 'タイトルの後', + 'Before image': '画像の前', + 'After image': '画像の後', + 'Before description': '説明の前', + 'After description': '説明の後', + 'Note': '注記', + 'Warning': '警告', + 'Important': '重要', + 'Tip': 'ヒント', + 'Block title': 'ブロックのタイトル', + 'Block text': 'ブロックのテキスト', + 'Language (e.g. bash)': '言語(例: bash)', + 'Code': 'コード', + 'One row per line, cells separated by |': '1 行に 1 行分、セルは | で区切ります', + 'First line is the header row.': '最初の行は見出し行です。', + 'Informational text, code, and table blocks can be reordered with drag handles or arrows.': 'テキスト、コード、表のブロックはハンドルや矢印で並べ替えられます。', + 'Add substep': 'サブステップを追加', + 'Make substep of…': '次のサブステップにする…', + 'Duplicate step': 'ステップを複製', + 'Choose image…': '画像を選択…', + 'Retake screenshot': 'スクリーンショットを撮り直す', + 'Full screen': '全画面', + 'Region…': '範囲…', + 'Move up': '上へ移動', + 'Move down': '下へ移動', + 'Move to…': '移動先…', + 'Delete step': 'ステップを削除', + 'Select step {step}': 'ステップ {step} を選択', + 'No steps yet.': 'ステップはまだありません。', + 'Add the copied annotation at the same position on each selected step': 'コピーした注釈を、選択した各ステップの同じ位置に追加', + 'linked': 'リンク済み', + 'local': 'ローカル', + 'No step selected.': 'ステップが選択されていません。', + 'Select a step to edit annotations.': '注釈を編集するステップを選択してください。', + 'No annotations yet. Pick a tool and drag on the canvas.': '注釈はまだありません。ツールを選んでキャンバス上でドラッグしてください。', + 'Private detail: {kind}': '個人情報: {kind}', + 'added by StepForge': 'StepForge が追加', + 'Select an annotation to edit its style.': 'スタイルを編集する注釈を選択してください。', + 'Bottom': '下', + 'Top': '上', + 'Left': '左', + 'Right': '右', + 'Color': '色', + 'Stroke': '線', + 'Annotation text': '注釈のテキスト', + 'Type': '種類', + 'Fill': '塗りつぶし', + 'Stroke width': '線の太さ', + 'Font size': 'フォントサイズ', + 'Text color': '文字色', + 'Radius': '角の丸み', + 'Tail': 'しっぽ', + 'Copy this style to every other “{type}” annotation:': 'このスタイルを他のすべての「{type}」注釈にコピー:', + 'Overwrite the style of every “{type}” annotation on this step with the style shown above.': 'このステップのすべての「{type}」注釈のスタイルを、上に表示されたスタイルで上書きします。', + 'This step': 'このステップ', + 'Overwrite the style of every “{type}” annotation across all steps in this guide with the style shown above.': 'このガイドの全ステップのすべての「{type}」注釈のスタイルを、上に表示されたスタイルで上書きします。', + 'Entire guide': 'ガイド全体', + 'Could not undo the move: {error}': '移動を元に戻せませんでした: {error}', + 'Undo anyway': 'それでも元に戻す', + 'Could not undo every step: {error}': 'すべてのステップを元に戻せませんでした: {error}', + 'Nothing to undo.': '元に戻す操作はありません。', + 'Nothing to redo.': 'やり直す操作はありません。', + 'Save failed': '保存に失敗しました', + 'Could not save this step — your changes are kept. Retrying…': 'このステップを保存できませんでした。変更は保持されています。再試行しています…', + 'Could not save guide details — your changes are kept. Retrying…': 'ガイドの詳細を保存できませんでした。変更は保持されています。再試行しています…', + 'Saved and synced to linked archive.': '保存し、リンクされたアーカイブと同期しました。', + 'Saved locally, but the linked archive is locked by another session.': 'ローカルに保存しましたが、リンクされたアーカイブは別のセッションがロックしています。', + 'Saved.': '保存しました。', + 'Merged changes from another session.': '別のセッションの変更を統合しました。', + 'Snapshot name': 'スナップショット名', + 'Snapshot created.': 'スナップショットを作成しました。', + 'Add Step': 'ステップを追加', + 'Step added.': 'ステップを追加しました。', + 'Add Substep': 'サブステップを追加', + 'Substep title': 'サブステップのタイトル', + 'Untitled substep': '無題のサブステップ', + 'Substep added.': 'サブステップを追加しました。', + 'A step cannot be made a substep of itself or one of its own substeps.': 'ステップを、それ自身やそのサブステップのサブステップにすることはできません。', + '“{title}” is already a substep of step {number}.': '「{title}」はすでにステップ {number} のサブステップです。', + '“{title}” is now a substep of step {number}.': '「{title}」をステップ {number} のサブステップにしました。', + 'Untitled step copy': '無題のステップのコピー', + 'Step duplicated.': 'ステップを複製しました。', + 'Stop the recording session before retaking a screenshot.': 'スクリーンショットを撮り直す前に録画セッションを停止してください。', + 'Screenshot retaken.': 'スクリーンショットを撮り直しました。', + 'Screenshot replaced.': 'スクリーンショットを置き換えました。', + 'Private details will be checked again before publishing.': '公開前に個人情報が再確認されます。', + 'Its annotations were kept, so check they still line up.': '注釈は保持されています。位置がずれていないか確認してください。', + 'Press Ctrl+Z to undo.': 'Ctrl+Z で元に戻せます。', + 'Step deleted. Press Ctrl+Z to undo.': 'ステップを削除しました。Ctrl+Z で元に戻せます。', + 'Could not move the step: {error}': 'ステップを移動できませんでした: {error}', + 'Top level': '最上位', + 'At the top': '先頭', + 'Move “{title}”': '「{title}」を移動', + 'Its substeps move with it.': 'サブステップも一緒に移動します。', + 'The guide changed while you were choosing, so the step was not moved. Try Move to… again.': '選択中にガイドが変更されたため、ステップは移動されませんでした。もう一度 移動先… をお試しください。', + 'Step moved. Press Ctrl+Z to undo.': 'ステップを移動しました。Ctrl+Z で元に戻せます。', + 'A step can’t be dropped inside its own substeps.': 'ステップを自分のサブステップの中にドロップすることはできません。', + 'Images imported.': '画像を読み込みました。', + 'Captured.': 'キャプチャしました。', + 'Capture full screen': '全画面をキャプチャ', + 'Capture window': 'ウィンドウをキャプチャ', + 'Capture region…': '範囲をキャプチャ…', + 'Paste image as step': '画像をステップとして貼り付け', + 'Import images…': '画像を読み込む…', + 'Start capture session (hotkey)': 'キャプチャセッションを開始(ホットキー)', + 'Image pasted as a new step.': '画像を新しいステップとして貼り付けました。', + 'Clipboard has no image.': 'クリップボードに画像がありません。', + 'Shared to {path}': '{path} に共有しました', + 'Restore “{name}”? Current state is snapshotted first, so this is undoable.': '「{name}」を復元しますか?現在の状態が先にスナップショットとして保存されるため、元に戻せます。', + 'Snapshot restored.': 'スナップショットを復元しました。', + 'Could not compare with this snapshot: {error}': 'このスナップショットと比較できませんでした: {error}', + 'Guide placeholders': 'ガイドのプレースホルダー', + 'Use [[Name]] in titles, descriptions, and blocks. Guide values override global ones.': 'タイトル、説明、ブロックで [[名前]] を使います。ガイドの値はグローバルの値より優先されます。', + 'Placeholders saved.': 'プレースホルダーを保存しました。', + 'Guide information saved.': 'ガイド情報を保存しました。', + 'Restore this cloud snapshot? Your current local guide is kept as a cloud snapshot first.': 'このクラウドのスナップショットを復元しますか?現在のローカルのガイドは先にクラウドのスナップショットとして保存されます。', + 'Cloud snapshot restored.': 'クラウドのスナップショットを復元しました。', + 'Remove every cloud snapshot for this guide? This also turns off cloud sync for the guide. The local guide is kept.': 'このガイドのクラウドのスナップショットをすべて削除しますか?このガイドのクラウド同期もオフになります。ローカルのガイドは残ります。', + 'Cloud copies removed.': 'クラウドのコピーを削除しました。', + 'Style applied to all “{type}” annotations in this step.': 'このステップのすべての「{type}」注釈にスタイルを適用しました。', + 'Style applied to all “{type}” annotations in the guide.': 'ガイド内のすべての「{type}」注釈にスタイルを適用しました。', + 'Capture paused.': 'キャプチャを一時停止しました。', + 'Capture resumed.': 'キャプチャを再開しました。', + 'Imported template for {format}.': '{format} のテンプレートをインポートしました。', + 'Template shared as .sfglt.': 'テンプレートを .sfglt として共有しました。', + 'Preview opened (first steps only).': 'プレビューを開きました(最初のステップのみ)。', + 'This guide': 'このガイド', + 'Linked archive saved.': 'リンクされたアーカイブを保存しました。', + 'Could not save linked archive.': 'リンクされたアーカイブを保存できませんでした。', + 'Linked archive force-saved.': 'リンクされたアーカイブを強制保存しました。', + 'Export the current guide': '現在のガイドをエクスポート', + 'Linked guide': 'リンクされたガイド', + 'Show linked archive details': 'リンクされたアーカイブの詳細を表示', + 'Start capture session': 'キャプチャセッションを開始', + 'Enable hotkey capture for this guide': 'このガイドでホットキーによるキャプチャを有効にする', + 'Could not save every step: {error}': 'すべてのステップを保存できませんでした: {error}', + '{count} skipped.': '{count} 件をスキップしました。', + 'Nothing was replaced.': '何も置換されませんでした。', + 'No steps changed.': '変更されたステップはありません。', + 'Stopped early: {error}': '途中で停止しました: {error}', + 'Crop the working image to the selected area?': '作業用の画像を選択範囲でトリミングしますか?', + 'Image cropped.': '画像をトリミングしました。', + 'Edit tooltip': '吹き出しを編集', + 'Edit text': 'テキストを編集', + 'Annotation copied.': '注釈をコピーしました。', + 'AI will write the title, description and blocks for {count} steps, make substeps where steps belong together, and add placeholders for values that repeat.': { other: 'AI が {count} 個のステップのタイトル、説明、ブロックを作成し、関連するステップをサブステップにまとめ、繰り返し出てくる値にプレースホルダーを追加します。' }, + 'Made {count} substeps.': { other: 'サブステップを {count} 個作成しました。' }, + 'Stopped. AI wrote {written} of {count} steps.': { other: '停止しました。AI は {count} 個中 {written} 個のステップを作成しました。' }, + 'AI wrote {written} of {count} steps.': { other: 'AI は {count} 個中 {written} 個のステップを作成しました。' }, + 'AI wrote the whole guide ({count} steps).': { other: 'AI がガイド全体({count} ステップ)を作成しました。' }, + '{count} steps': { other: '{count} ステップ' }, + 'Paste annotation to {count} steps': { other: '注釈を {count} 個のステップに貼り付け' }, + '{count} steps were changed since this edit, perhaps by AI or another app. Undo anyway and overwrite those changes?': { other: 'この編集の後に {count} 個のステップが AI や別のアプリによって変更された可能性があります。それでも元に戻して、それらの変更を上書きしますか?' }, + '{count} conflicts were settled in favor of the most recent edit.': { other: '{count} 件の競合を最新の編集を優先して解決しました。' }, + 'Replaced {applied} in {count} steps.': { other: '{count} 個のステップで {applied} 件置換しました。' }, + 'Annotation pasted onto {count} steps.': { other: '注釈を {count} 個のステップに貼り付けました。' }, + 'Skipped {count} steps (already there, no image, or a different image shape): {steps}.': { other: '{count} 個のステップをスキップしました(すでにある、画像がない、または画像の形が異なる): {steps}。' }, + 'Close (Esc)': '閉じる (Esc)', + 'Confirm': '確認', + 'Nothing else to choose': 'ほかに選べるものはありません', + '{count} years': { other: '{count} 年' }, + '{count} months': { other: '{count} か月' }, + 'Guide library': 'ガイドライブラリ', + 'Cloud sync status': 'クラウド同期の状態', + 'Search guides… (Ctrl+/ for quick actions)': 'ガイドを検索… (Ctrl+/ でクイックアクション)', + 'Beta': 'ベータ', }); diff --git a/core/locales/ru.js b/core/locales/ru.js index fcd4032a..dbe9cb52 100644 --- a/core/locales/ru.js +++ b/core/locales/ru.js @@ -5,4 +5,736 @@ if (typeof module !== 'undefined' && module.exports) module.exports = messages; if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['ru'] = messages; })(typeof window !== 'undefined' ? window : null, { + 'Import images as steps': 'Импортировать изображения как шаги', + 'Choose a screenshot for this step': 'Выберите снимок экрана для этого шага', + 'Choose guide storage folder': 'Выберите папку для хранения руководств', + 'Share guide as file': 'Поделиться руководством в виде файла', + 'Open guide archive': 'Открыть архив руководства', + 'Choose output folder': 'Выберите папку для экспорта', + 'Choose an image': 'Выберите изображение', + 'Choose a font file': 'Выберите файл шрифта', + 'Choose a CSS file': 'Выберите файл CSS', + 'Choose a certificate': 'Выберите сертификат', + '{host} asks for a certificate to sign you in.': '{host} запрашивает сертификат для входа.', + 'Choose the one from your smart card that you use to sign in to websites. StepForge remembers your choice for this site.': 'Выберите сертификат со смарт-карты, которым вы входите на веб-сайты. StepForge запомнит ваш выбор для этого сайта.', + '{subject} (issued by {issuer})': '{subject} (выдан: {issuer})', + 'Cancel': 'Отмена', + 'just now': 'только что', + '{count} min ago': '{count} мин назад', + '{count} hr ago': '{count} ч назад', + 'yesterday': 'вчера', + 'Last verified {date}': 'Последняя проверка: {date}', + 'Last saved {date}': 'Последнее сохранение: {date}', + '{since}, over {interval} ago. Open the guide to check it still matches the software, or right-click → Mark as verified.': '{since}; прошло больше, чем {interval}. Откройте руководство и убедитесь, что оно соответствует программе, или щёлкните правой кнопкой → Отметить как проверенное.', + 'Deleted for good within a day': 'Будет удалено навсегда менее чем через день', + 'An AI agent updated your brand profiles.': 'ИИ-агент обновил ваши профили бренда.', + 'An AI agent updated this guide.': 'ИИ-агент обновил это руководство.', + 'Documentation generated.': 'Документация создана.', + 'Capture, annotate, and export step-by-step guides.': 'Записывайте, размечайте и экспортируйте пошаговые руководства.', + 'New Capture': 'Новая запись', + 'Start a guide and capture your screen': 'Создайте руководство и запишите экран', + 'Existing Workspace': 'Существующая библиотека', + 'Browse your guide library': 'Просмотреть библиотеку руководств', + 'Settings': 'Настройки', + 'Theme, capture, and export options': 'Тема, запись и параметры экспорта', + 'Recent guides': 'Недавние руководства', + 'Untitled capture': 'Запись без названия', + 'synced': 'синхронизировано', + 'syncing…': 'синхронизация…', + 'pending': 'ожидает', + 'needs attention': 'требует внимания', + '{service} settings': 'Настройки {service}', + 'Recording - stopped': 'Запись — остановлена', + 'New recording': 'Новая запись', + 'waiting for screen sharing': 'ожидание доступа к экрану', + 'ready': 'готово', + 'paused': 'пауза', + 'on click': 'по щелчку', + 'every {seconds}s': 'каждые {seconds} с', + 'hotkey only': 'только по сочетанию клавиш', + 'StepForge tucks away and starts capturing': 'StepForge скроется и начнёт запись', + 'Start recording': 'Начать запись', + 'Stop recording': 'Остановить запись', + 'Capture session — {trigger}': 'Сеанс записи — {trigger}', + 'Recording - {trigger}': 'Запись — {trigger}', + 'Full screen (window capture unavailable)': 'Весь экран (запись окна недоступна)', + 'Library': 'Библиотека', + 'Capture a screenshot step': 'Записать шаг со снимком экрана', + 'Capture ▾': 'Запись ▾', + 'Save': 'Сохранить', + 'Export': 'Экспорт', + 'Share this guide as a file or on the web': 'Поделиться руководством в виде файла или в интернете', + 'Save as a .sfgz file…': 'Сохранить как файл .sfgz…', + 'Publish to the web…': 'Опубликовать в интернете…', + 'Publish to Confluence…': 'Опубликовать в Confluence…', + 'Publish to Wiki.js…': 'Опубликовать в Wiki.js…', + 'Share ▾': 'Поделиться ▾', + 'AI is writing. Click to stop.': 'ИИ пишет. Щёлкните, чтобы остановить.', + 'Let AI write this guide': 'Поручить ИИ написать это руководство', + 'AI ▾': 'ИИ ▾', + 'Rename guide…': 'Переименовать руководство…', + 'Guide information…': 'Сведения о руководстве…', + 'Guide placeholders…': 'Заполнители руководства…', + 'Backups & snapshots…': 'Резервные копии и снимки…', + 'Find private details…': 'Найти личные данные…', + 'Linked guide…': 'Связанное руководство…', + 'Linked guide (not linked)': 'Связанное руководство (не связано)', + 'Keyboard shortcuts…': 'Сочетания клавиш…', + 'Quick actions (Ctrl+/)': 'Быстрые действия (Ctrl+/)', + 'Settings…': 'Настройки…', + 'More ▾': 'Ещё ▾', + 'Results for “{query}”': 'Результаты по запросу «{query}»', + 'All guides': 'Все руководства', + 'Favorites': 'Избранное', + 'Folders': 'Папки', + 'New folder': 'Новая папка', + 'No folders yet': 'Папок пока нет', + 'Trash': 'Корзина', + 'Select': 'Выбрать', + 'Empty trash': 'Очистить корзину', + 'Import ▾': 'Импорт ▾', + 'New guide': 'Новое руководство', + 'Import a copy of a guide (.sfgz)…': 'Импортировать копию руководства (.sfgz)…', + 'Open a linked guide (.sfgz)…': 'Открыть связанное руководство (.sfgz)…', + 'Rename folder': 'Переименовать папку', + 'Delete folder': 'Удалить папку', + 'Search': 'Поиск', + 'No results for this query.': 'По этому запросу ничего не найдено.', + 'No guides yet': 'Руководств пока нет', + 'Create a guide and start capturing steps, or import one someone shared with you.': 'Создайте руководство и начните записывать шаги или импортируйте руководство, которым с вами поделились.', + 'No favorites': 'Нет избранного', + 'Star a guide to keep it here.': 'Отметьте руководство звёздочкой, чтобы оно появилось здесь.', + 'This folder is empty': 'Эта папка пуста', + 'Right-click a guide, or select guides, to move them here.': 'Щёлкните руководство правой кнопкой или выберите несколько, чтобы переместить их сюда.', + 'Trash is empty': 'Корзина пуста', + 'Deleted guides stay here until you empty the trash.': 'Удалённые руководства хранятся здесь, пока вы не очистите корзину.', + 'Restore': 'Восстановить', + 'Deleted {when}': 'Удалено {when}', + 'Deselect': 'Снять выбор', + 'Open guide · Ctrl-click to select': 'Открыть руководство · Ctrl+щелчок для выбора', + 'Remove from favorites': 'Убрать из избранного', + 'Add to favorites': 'Добавить в избранное', + 'Untitled guide': 'Руководство без названия', + 'Saved to a linked .sfgz file': 'Сохраняется в связанный файл .sfgz', + 'Linked': 'Связано', + 'Open on another device': 'Открыто на другом устройстве', + 'Locked': 'Заблокировано', + 'May need review': 'Требует проверки', + 'Step': 'Шаг', + 'Guide': 'Руководство', + 'Move to {folder}': 'Переместить в {folder}', + 'Move to no folder': 'Убрать из папки', + 'Open guide': 'Открыть руководство', + 'Unfavorite': 'Убрать из избранного', + 'Favorite': 'В избранное', + 'Mark as verified': 'Отметить как проверенное', + 'Duplicate guide': 'Дублировать руководство', + 'Delete guide': 'Удалить руководство', + 'Delete forever': 'Удалить навсегда', + 'Selection actions': 'Действия с выбранным', + '{count} of {total} selected': 'Выбрано {count} из {total}', + 'Click guides to select them': 'Щёлкайте руководства, чтобы выбрать их', + 'Clear': 'Сбросить', + 'Select all': 'Выбрать все', + 'Move to ▾': 'Переместить в ▾', + 'Record that these guides still match the software today': 'Отметить, что эти руководства по-прежнему соответствуют программе', + 'Delete': 'Удалить', + 'No folder': 'Без папки', + 'New Guide': 'Новое руководство', + 'Title': 'Название', + 'Folder name': 'Название папки', + 'Delete the folder “{folder}”? Guides stay in the library.': 'Удалить папку «{folder}»? Руководства останутся в библиотеке.', + 'Marked as verified today.': 'Отмечено как проверенное сегодня.', + '{count} guides marked as verified today.': 'Руководств, отмеченных как проверенные сегодня: {count}.', + 'Delete “{title}”? It moves to Trash. If it syncs to the cloud, it is also removed from your other devices and one cloud recovery snapshot is retained.': 'Удалить «{title}»? Оно будет перемещено в корзину. Если оно синхронизируется с облаком, оно также будет удалено с других ваших устройств, а в облаке сохранится один снимок для восстановления.', + 'Permanently empty the trash?': 'Очистить корзину навсегда?', + 'Rename guide': 'Переименовать руководство', + 'Create a blank guide': 'Создать пустое руководство', + 'Import archive': 'Импортировать архив', + 'Open a .sfgz guide archive': 'Открыть архив руководства .sfgz', + 'Open linked archive': 'Открыть связанный архив', + 'Import a linked guide from .sfgz': 'Импортировать связанное руководство из .sfgz', + 'Open application settings': 'Открыть настройки приложения', + 'Refresh library': 'Обновить библиотеку', + 'Reload guides and folders': 'Перезагрузить руководства и папки', + '{count} days ago': { one: '{count} день назад', few: '{count} дня назад', many: '{count} дней назад', other: '{count} дней назад' }, + 'Deleted for good in {count} days': { one: 'Будет удалено навсегда через {count} день', few: 'Будет удалено навсегда через {count} дня', many: 'Будет удалено навсегда через {count} дней', other: 'Будет удалено навсегда через {count} дней' }, + 'Blurred {count} possible private details in this step.': { one: 'Размыт {count} фрагмент с возможными личными данными в этом шаге.', few: 'Размыто {count} фрагмента с возможными личными данными в этом шаге.', many: 'Размыто {count} фрагментов с возможными личными данными в этом шаге.', other: 'Размыто {count} фрагментов с возможными личными данными в этом шаге.' }, + '{count} deleted guides': { one: '{count} удалённое руководство', few: '{count} удалённых руководства', many: '{count} удалённых руководств', other: '{count} удалённых руководств' }, + '{count} guides': { one: '{count} руководство', few: '{count} руководства', many: '{count} руководств', other: '{count} руководств' }, + 'Deleted guides stay here for {count} days, then are deleted for good. Change this in Settings → General.': { one: 'Удалённые руководства хранятся здесь {count} день, затем удаляются навсегда. Это можно изменить в разделе Настройки → Общие.', few: 'Удалённые руководства хранятся здесь {count} дня, затем удаляются навсегда. Это можно изменить в разделе Настройки → Общие.', many: 'Удалённые руководства хранятся здесь {count} дней, затем удаляются навсегда. Это можно изменить в разделе Настройки → Общие.', other: 'Удалённые руководства хранятся здесь {count} дней, затем удаляются навсегда. Это можно изменить в разделе Настройки → Общие.' }, + 'Permanently delete {count} items? This cannot be undone.': { one: 'Удалить навсегда {count} элемент? Это действие нельзя отменить.', few: 'Удалить навсегда {count} элемента? Это действие нельзя отменить.', many: 'Удалить навсегда {count} элементов? Это действие нельзя отменить.', other: 'Удалить навсегда {count} элементов? Это действие нельзя отменить.' }, + 'Delete {count} guides? They’ll move to Trash. Guides that sync to the cloud are also removed from your other devices, with one cloud recovery snapshot retained.': { one: 'Удалить {count} руководство? Оно будет перемещено в корзину. Руководства, синхронизируемые с облаком, также удаляются с других ваших устройств, а в облаке сохраняется один снимок для восстановления.', few: 'Удалить {count} руководства? Они будут перемещены в корзину. Руководства, синхронизируемые с облаком, также удаляются с других ваших устройств, а в облаке сохраняется один снимок для восстановления.', many: 'Удалить {count} руководств? Они будут перемещены в корзину. Руководства, синхронизируемые с облаком, также удаляются с других ваших устройств, а в облаке сохраняется один снимок для восстановления.', other: 'Удалить {count} руководств? Они будут перемещены в корзину. Руководства, синхронизируемые с облаком, также удаляются с других ваших устройств, а в облаке сохраняется один снимок для восстановления.' }, + 'Plain (no highlighting)': 'Обычный текст (без подсветки)', + 'Clear shortcut': 'Сбросить сочетание', + 'Click, then press a key combination': 'Щёлкните и нажмите сочетание клавиш', + 'Press a key combination…': 'Нажмите сочетание клавиш…', + 'Click to set shortcut': 'Щёлкните, чтобы задать сочетание', + 'Move step': 'Переместить шаг', + '{place} (current place)': '{place} (текущее место)', + 'Place': 'Место', + 'Level': 'Уровень', + 'Move': 'Переместить', + 'OK': 'ОК', + 'Search guides, steps, and commands': 'Поиск руководств, шагов и команд', + 'Type to search, arrows to move, Enter to open.': 'Начните вводить для поиска, стрелки — для перехода, Enter — чтобы открыть.', + 'No matches.': 'Совпадений нет.', + 'Quick Actions': 'Быстрые действия', + 'Close': 'Закрыть', + 'Find': 'Найти', + 'Replace with': 'Заменить на', + 'Replace': 'Заменить', + 'Type something to find.': 'Введите текст для поиска.', + 'Searching…': 'Поиск…', + 'Replace {count}': 'Заменить ({count})', + 'Step {number}': 'Шаг {number}', + 'Showing the first {count} matches. Narrow the search to see the rest.': 'Показаны первые {count} совпадений. Уточните запрос, чтобы увидеть остальные.', + 'Could not replace: {error}': 'Не удалось заменить: {error}', + 'Find and replace': 'Найти и заменить', + 'Searches step titles and descriptions, text, code and table blocks, and annotation labels. Screenshots are not changed. Ctrl+Z undoes the whole replacement.': 'Поиск по названиям и описаниям шагов, текстовым, кодовым и табличным блокам и подписям аннотаций. Снимки экрана не изменяются. Ctrl+Z отменяет всю замену.', + 'Check for updates': 'Проверить обновления', + 'Update now': 'Обновить сейчас', + 'Starting…': 'Запуск…', + 'Your latest changes couldn’t be saved, so the update didn’t start.': 'Не удалось сохранить последние изменения, поэтому обновление не началось.', + 'Couldn’t start the update. Try again, or download the new installer.': 'Не удалось запустить обновление. Повторите попытку или скачайте новый установщик.', + 'Closing StepForge…': 'Закрытие StepForge…', + 'An administrator window will install the update and reopen StepForge.': 'В окне администратора будет установлено обновление, после чего StepForge откроется снова.', + 'Couldn’t start the update.': 'Не удалось запустить обновление.', + 'Open Microsoft Store': 'Открыть Microsoft Store', + 'Couldn’t open the Microsoft Store. Open it from the Start menu, then choose Library → Get updates.': 'Не удалось открыть Microsoft Store. Откройте его из меню «Пуск» и выберите Библиотека → Получить обновления.', + 'Checking…': 'Проверка…', + 'Couldn’t check for updates. Try again later.': 'Не удалось проверить обновления. Повторите попытку позже.', + 'Version {version} is available': 'Доступна версия {version}', + 'You have {version}.': 'У вас версия {version}.', + 'View release and download': 'Открыть выпуск и скачать', + 'You’re up to date': 'У вас последняя версия', + 'Version {version} is the latest release.': 'Версия {version} — последний выпуск.', + 'Couldn’t check for updates.': 'Не удалось проверить обновления.', + 'Try again': 'Повторить', + 'Checking guide storage location…': 'Проверка расположения руководств…', + 'Choose folder…': 'Выбрать папку…', + 'Use default': 'По умолчанию', + 'Cancel pending move': 'Отменить запланированный перенос', + 'Default location.': 'Расположение по умолчанию.', + 'Default: {path}': 'По умолчанию: {path}', + 'Moving to {path} the next time StepForge starts.': 'Перенос в {path} при следующем запуске StepForge.', + 'Set by STEPFORGE_DATA_DIR for this session.': 'Задано переменной STEPFORGE_DATA_DIR для этого сеанса.', + 'Could not read guide storage location.': 'Не удалось определить расположение руководств.', + 'Could not schedule the library move.': 'Не удалось запланировать перенос библиотеки.', + 'Development build': 'Сборка для разработки', + 'Release': 'Выпуск', + 'Unavailable': 'Недоступно', + 'System': 'Как в системе', + 'Light': 'Светлая', + 'Dark': 'Тёмная', + 'System default': 'Как в системе', + 'Fullscreen': 'Весь экран', + 'Window': 'Окно', + 'One per line, for example a name or project code': 'По одному в строке, например имя или код проекта', + 'Words to always hide': 'Слова, которые всегда скрывать', + 'Default focus amount': 'Степень приближения по умолчанию', + 'Reset': 'Сбросить', + 'Hotkey': 'Сочетание клавиш', + 'Timed interval': 'По таймеру', + 'After 7 days': 'Через 7 дней', + 'After 30 days': 'Через 30 дней', + 'After 90 days': 'Через 90 дней', + 'Never': 'Никогда', + 'After {interval}': 'Через {interval}', + 'Loading the setup commands…': 'Загрузка команд настройки…', + 'Run this in a terminal once.': 'Выполните это один раз в терминале.', + 'Claude Desktop, Cursor, and other apps': 'Claude Desktop, Cursor и другие приложения', + 'Add this to the app’s MCP servers settings.': 'Добавьте это в настройки MCP-серверов приложения.', + 'Copied': 'Скопировано', + 'Copy': 'Копировать', + 'Could not build the setup commands: {error}': 'Не удалось подготовить команды настройки: {error}', + 'Not tested yet. Vision-capable models can also inspect each step’s screenshot.': 'Ещё не проверено. Модели с поддержкой изображений также могут просматривать снимок экрана каждого шага.', + 'Test connection': 'Проверить подключение', + 'Testing…': 'Проверка…', + 'Checking Ollama at the configured host…': 'Проверка Ollama на указанном узле…', + 'Could not connect to Ollama.': 'Не удалось подключиться к Ollama.', + 'Connected to {host} with {model}. It can inspect screenshots.': 'Подключено к {host}, модель {model}. Она может просматривать снимки экрана.', + 'Connected to {host} with {model}. This model is text-only, so StepForge will use OCR and metadata only.': 'Подключено к {host}, модель {model}. Эта модель работает только с текстом, поэтому StepForge будет использовать только OCR и метаданные.', + 'Connected to {host}, but {model} isn’t installed. Installed models: {installed}.': 'Подключено к {host}, но модель {model} не установлена. Установленные модели: {installed}.', + 'that model': 'эта модель', + 'Connected to {host}, but it has no models yet. Run “ollama pull gemma3” first.': 'Подключено к {host}, но моделей пока нет. Сначала выполните «ollama pull gemma3».', + 'Placeholder name': 'Имя заполнителя', + 'Content': 'Содержимое', + 'Placeholder content': 'Содержимое заполнителя', + 'Markdown or regular text': 'Markdown или обычный текст', + 'Remove placeholder': 'Удалить заполнитель', + 'Add placeholder': 'Добавить заполнитель', + 'General': 'Общие', + 'Appearance, exports, where your guides and deleted guides are kept, and when guides are due for review.': 'Оформление, экспорт, где хранятся руководства и удалённые руководства и когда руководства нужно проверять.', + 'Appearance': 'Оформление', + 'Theme': 'Тема', + 'Match your system, or always use light or dark.': 'Как в системе, или всегда светлая либо тёмная.', + 'Spellcheck': 'Проверка орфографии', + 'Underline misspelled words while editing.': 'Подчёркивать слова с ошибками при редактировании.', + 'Exports': 'Экспорт', + 'Open folder after export': 'Открывать папку после экспорта', + 'Show the exported file in your file manager.': 'Показывать экспортированный файл в файловом менеджере.', + 'Preview step count': 'Шагов в предпросмотре', + 'How many steps the export preview renders.': 'Сколько шагов показывает предпросмотр экспорта.', + 'Code theme': 'Тема кода', + 'Syntax colors for code blocks in PDF, Word, HTML and Confluence exports.': 'Цвета подсветки синтаксиса в блоках кода при экспорте в PDF, Word, HTML и Confluence.', + 'Guide storage': 'Хранение руководств', + 'Choose an empty folder. StepForge copies and verifies your library, then switches to it the next time it starts. The old library is kept as a backup.': 'Выберите пустую папку. StepForge скопирует и проверит библиотеку, а при следующем запуске переключится на неё. Старая библиотека сохранится как резервная копия.', + 'Empty the trash': 'Очищать корзину', + 'Deleted guides are removed for good after this long.': 'Удалённые руководства удаляются навсегда по истечении этого срока.', + 'Guide reviews': 'Проверка руководств', + 'Flag guides for review': 'Помечать руководства для проверки', + 'Guides that haven’t been saved or marked verified for this long show “May need review” in the library. Each guide can change this in Guide information.': 'Руководства, которые не сохранялись и не отмечались как проверенные в течение этого срока, помечаются в библиотеке как «Требует проверки». Для каждого руководства это можно изменить в сведениях о руководстве.', + 'Language': 'Язык', + 'The language of StepForge’s menus, buttons and messages. Exported guides stay in English.': 'Язык меню, кнопок и сообщений StepForge. Экспортированные руководства остаются на английском.', + 'Branding': 'Фирменный стиль', + 'Customize how exported guides appear.': 'Настройте внешний вид экспортированных руководств.', + 'Capture': 'Запись', + 'How StepForge records steps.': 'Как StepForge записывает шаги.', + 'Recording': 'Запись', + 'Default mode': 'Режим по умолчанию', + 'Record the whole screen or the active window. For a region, use Capture → Capture region….': 'Записывать весь экран или активное окно. Для области используйте Запись → Записать область….', + 'Delay': 'Задержка', + 'Milliseconds to wait before each screenshot.': 'Сколько миллисекунд ждать перед каждым снимком экрана.', + 'Click marker': 'Метка щелчка', + 'Draw a marker where you clicked.': 'Рисовать метку в месте щелчка.', + 'Capture outside clicks': 'Записывать щелчки вне приложения', + 'Record clicks in other apps while a capture session is running.': 'Записывать щелчки в других приложениях во время сеанса записи.', + 'When clicks can’t be detected': 'Если щелчки не определяются', + 'Capture with': 'Записывать с помощью', + 'Some desktops don’t report clicks. Choose how steps are captured instead.': 'Некоторые рабочие столы не сообщают о щелчках. Выберите, как в этом случае записывать шаги.', + 'Timer interval': 'Интервал таймера', + 'Seconds between timed captures.': 'Секунд между записями по таймеру.', + 'Hotkeys': 'Сочетания клавиш', + 'Take a step. Also used when clicks can’t be detected.': 'Записать шаг. Также используется, если щелчки не определяются.', + 'Pause / resume': 'Пауза / продолжение', + 'Pause or resume the capture session.': 'Приостановить или продолжить сеанс записи.', + 'Editor': 'Редактор', + 'Defaults for new steps and automatic backups.': 'Параметры новых шагов и автоматические резервные копии.', + 'New steps': 'Новые шаги', + 'Focus recorded clicks': 'Приближать к месту щелчка', + 'Zoom new screenshots toward where you clicked.': 'Приближать новые снимки экрана к месту щелчка.', + 'More context (1×) to closer focus (2×). Applies to new captures only.': 'От большего контекста (1×) к сильному приближению (2×). Только для новых записей.', + 'Focused view for new steps': 'Приближённый вид для новых шагов', + 'Open new steps in focused view.': 'Открывать новые шаги в приближённом виде.', + 'Backups': 'Резервные копии', + 'Snapshots to keep': 'Сколько снимков хранить', + 'Older backup snapshots of each guide are removed. 0 keeps all of them.': 'Более старые снимки каждого руководства удаляются. 0 — хранить все.', + 'Privacy': 'Конфиденциальность', + 'Blur private details in screenshots. Everything is checked on this computer.': 'Размывайте личные данные на снимках экрана. Всё проверяется на этом компьютере.', + 'Private details': 'Личные данные', + 'Before a guide goes on the web, StepForge reads its screenshots and blurs email addresses, phone and card numbers, Social Security numbers, IP addresses, passwords, keys, and links with private tokens. You can review every blur and undo any that aren’t needed.': 'Перед публикацией руководства в интернете StepForge читает его снимки экрана и размывает адреса электронной почты, номера телефонов и карт, номера социального страхования, IP-адреса, пароли, ключи и ссылки с личными токенами. Вы можете просмотреть каждое размытие и отменить ненужные.', + 'Blur while recording': 'Размывать во время записи', + 'Check each new capture as soon as it’s taken, instead of waiting until you publish.': 'Проверять каждую новую запись сразу, а не перед публикацией.', + 'Always hide these words': 'Всегда скрывать эти слова', + 'Names, project codes, or anything else to blur wherever it appears. One per line.': 'Имена, коды проектов и всё остальное, что нужно размывать везде, где оно встречается. По одному в строке.', + 'AI': 'ИИ', + 'Let AI write your guides: a model on your own computer through Ollama, or an AI agent such as Claude or Codex.': 'Пусть ИИ пишет руководства: модель на вашем компьютере через Ollama или ИИ-агент, например Claude или Codex.', + 'Enable AI': 'Включить ИИ', + 'Turn on the AI ▾ menu in the guide toolbar: write a whole guide, a step, or one field.': 'Включает меню ИИ ▾ на панели руководства: написать руководство целиком, шаг или одно поле.', + 'Auto-document captures': 'Автоматически описывать записи', + 'Write a title and description for each new capture as you record. Turn off to use AI only when you ask.': 'Писать название и описание для каждой новой записи во время записи. Отключите, чтобы использовать ИИ только по запросу.', + 'Let the model see screenshots': 'Показывать модели снимки экрана', + 'Models that can read images see each step’s screenshot, with blurred areas already hidden. Turn off to send text only.': 'Модели, умеющие читать изображения, видят снимок экрана каждого шага, причём размытые области уже скрыты. Отключите, чтобы отправлять только текст.', + 'Ollama': 'Ollama', + 'Runs on your computer, so your screenshots and text stay here.': 'Работает на вашем компьютере, поэтому снимки экрана и текст остаются здесь.', + 'Host': 'Узел', + 'Where Ollama is running.': 'Где запущена Ollama.', + 'Model': 'Модель', + 'Any installed model. Choose Test connection to list them.': 'Любая установленная модель. Нажмите «Проверить подключение», чтобы увидеть список.', + 'AI guide': 'Руководство по ИИ', + 'Recommended models': 'Рекомендуемые модели', + 'AI agents (Claude, Codex)': 'ИИ-агенты (Claude, Codex)', + 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'ИИ-приложения, такие как Claude и Codex, могут подключаться к StepForge и писать руководства за вас. Они могут переписывать текст, создавать подшаги, добавлять заполнители и рисовать на снимках экрана, но не могут менять порядок шагов или что-либо удалять.', + 'Let AI agents edit your guides': 'Разрешить ИИ-агентам изменять руководства', + 'Turn off to cut off every connected agent at once.': 'Отключите, чтобы сразу отключить всех подключённых агентов.', + 'Agents can see screenshots': 'Агенты видят снимки экрана', + 'Blurred areas are filled in first. Turn off to share step text only.': 'Размытые области сначала закрашиваются. Отключите, чтобы передавать только текст шагов.', + 'Let AI agents edit brand profiles': 'Разрешить ИИ-агентам изменять профили бренда', + 'Agents can make and change brand profiles: logo, colors, fonts and CSS, and choose the one exports use. They can’t delete a profile.': 'Агенты могут создавать и менять профили бренда (логотип, цвета, шрифты и CSS) и выбирать профиль для экспорта. Удалять профили они не могут.', + 'Accounts': 'Учётные записи', + 'Sync guides with Google Drive, OneDrive, Dropbox or Nextcloud, share them on the web with GitHub, or publish them to Confluence or Wiki.js.': 'Синхронизируйте руководства с Google Drive, OneDrive, Dropbox или Nextcloud, публикуйте их в интернете через GitHub или в Confluence и Wiki.js.', + 'Placeholders': 'Заполнители', + 'Reusable text for every guide. Type [[name]] in a guide to insert it.': 'Текст для повторного использования во всех руководствах. Введите [[имя]] в руководстве, чтобы вставить его.', + 'About': 'О программе', + 'Local-first step-by-step guides.': 'Пошаговые руководства, которые хранятся у вас.', + 'Version': 'Версия', + 'License': 'Лицензия', + 'Data folder': 'Папка данных', + 'Updates': 'Обновления', + 'Checks GitHub for a newer release. StepForge only connects when you press the button.': 'Проверяет наличие нового выпуска на GitHub. StepForge подключается только при нажатии кнопки.', + 'Project on GitHub': 'Проект на GitHub', + 'Release notes': 'Примечания к выпуску', + 'Report an issue': 'Сообщить о проблеме', + 'Settings sections': 'Разделы настроек', + 'AI is off. These settings are kept for later.': 'ИИ отключён. Эти настройки сохранены на будущее.', + 'Default template': 'Шаблон по умолчанию', + 'Choose an output folder': 'Выберите папку для экспорта', + 'No branding': 'Без фирменного стиля', + 'JSON exports keep the raw data and aren’t branded.': 'Экспорт в JSON сохраняет исходные данные и не использует фирменный стиль.', + 'Change the default in Settings → Branding.': 'Изменить значение по умолчанию можно в разделе Настройки → Фирменный стиль.', + 'None': 'Нет', + 'Choose…': 'Выбрать…', + 'This format has no adjustable options.': 'У этого формата нет настраиваемых параметров.', + 'Keep it online for': 'Хранить в интернете', + 'The guide is removed from the web after this.': 'По истечении этого срока руководство удаляется из интернета.', + 'Published guides are public.': 'Опубликованные руководства общедоступны.', + 'Anyone with the link can open it, and anyone who looks at your GitHub repository can find it. Blur passwords and other private details before you export.': 'Его может открыть любой, у кого есть ссылка, и найти любой, кто посмотрит ваш репозиторий GitHub. Перед экспортом размойте пароли и другие личные данные.', + 'Publish on the web': 'Опубликовать в интернете', + 'After exporting, also put this guide on your GitHub Pages site and get a link to share.': 'После экспорта также разместить руководство на вашем сайте GitHub Pages и получить ссылку.', + 'Connect GitHub in Settings → Accounts → GitHub to publish guides on the web.': 'Подключите GitHub в разделе Настройки → Учётные записи → GitHub, чтобы публиковать руководства в интернете.', + 'Export and publish': 'Экспортировать и опубликовать', + 'Publish to {site}…': 'Опубликовать в {site}…', + 'Publishing to {site} is easier.': 'Публиковать в {site} проще.', + 'Share → Publish to {site}… creates the page for you, uploads its screenshots and updates the same page when you publish again. Export only if you’d rather upload the files yourself.': 'Поделиться → Опубликовать в {site}… создаёт страницу за вас, загружает снимки экрана и обновляет ту же страницу при повторной публикации. Экспортируйте, только если хотите загрузить файлы сами.', + 'Save template': 'Сохранить шаблон', + 'Template name': 'Название шаблона', + 'Save as template…': 'Сохранить как шаблон…', + 'Manage…': 'Управление…', + 'Format': 'Формат', + 'Template': 'Шаблон', + 'Output folder': 'Папка для экспорта', + 'Options': 'Параметры', + 'Preview…': 'Предпросмотр…', + 'Preview': 'Предпросмотр', + 'Exporting…': 'Экспорт…', + 'No templates saved for this format yet.': 'Для этого формата пока нет сохранённых шаблонов.', + 'Rename template': 'Переименовать шаблон', + 'Name': 'Имя', + 'Rename': 'Переименовать', + 'Duplicate': 'Дублировать', + 'Share…': 'Поделиться…', + 'Import .sfglt…': 'Импортировать .sfglt…', + 'Templates are shareable as .sfglt files.': 'Шаблонами можно делиться в виде файлов .sfglt.', + 'Done': 'Готово', + 'Locked by {user}@{host}': 'Заблокировано: {user}@{host}', + 'another user': 'другой пользователь', + 'another host': 'другой компьютер', + 'No active conflict': 'Нет активного конфликта', + 'Archive': 'Архив', + 'Not linked': 'Не связано', + 'Opened': 'Открыт', + 'Unknown': 'Неизвестно', + 'Last saved': 'Последнее сохранение', + 'Lock': 'Блокировка', + 'Another editor has the archive locked. You can force-save if you intend to overwrite it.': 'Архив заблокирован другим редактором. Вы можете сохранить принудительно, если хотите перезаписать его.', + 'Linked Guide': 'Связанное руководство', + 'Show file': 'Показать файл', + 'Force save': 'Сохранить принудительно', + 'Save now': 'Сохранить сейчас', + 'No snapshots yet. Automatic snapshots are created as you work; create one manually any time.': 'Снимков пока нет. Автоматические снимки создаются во время работы; вручную снимок можно создать в любой момент.', + 'See what changed between this snapshot and the guide now': 'Посмотреть, что изменилось между этим снимком и текущим руководством', + 'Comparing…': 'Сравнение…', + 'Compare': 'Сравнить', + 'Create snapshot': 'Создать снимок', + 'Backups & snapshots': 'Резервные копии и снимки', + 'Compare shows what changed since a snapshot. Restores are undoable — the current state is snapshotted first.': '«Сравнить» показывает изменения с момента снимка. Восстановление можно отменить: сначала создаётся снимок текущего состояния.', + 'Value': 'Значение', + 'Remove': 'Удалить', + 'e.g. Windows 11 24H2': 'например, Windows 11 24H2', + 'Use the default ({interval})': 'По умолчанию ({interval})', + 'Use the default (off)': 'По умолчанию (выключено)', + 'After you save, “May need review” shows in the library on {date} unless the guide is saved or verified again first.': 'После сохранения отметка «Требует проверки» появится в библиотеке {date}, если руководство не будет снова сохранено или проверено раньше.', + 'This guide is never flagged for review.': 'Это руководство никогда не помечается для проверки.', + 'Today': 'Сегодня', + 'e.g. Jane Doe': 'например, Иван Петров', + 'e.g. Alex Lee, Sam Patel': 'например, Анна Смирнова, Олег Ким', + 'e.g. Acme Corp': 'например, ООО «Ромашка»', + 'A short summary of this guide.': 'Краткое описание руководства.', + 'Guide information': 'Сведения о руководстве', + 'All fields are optional. They appear below the title on the guide and on the PDF cover page.': 'Все поля необязательны. Они отображаются под названием руководства и на титульной странице PDF.', + 'Author': 'Автор', + 'Co-authors': 'Соавторы', + 'Organization': 'Организация', + 'Description': 'Описание', + 'Shown on the first page of the PDF and at the top of other export formats.': 'Отображается на первой странице PDF и в начале других форматов экспорта.', + 'Review': 'Проверка', + 'Software version': 'Версия программы', + 'Last verified': 'Последняя проверка', + 'Review again': 'Проверить снова', + 'Include this guide in cloud sync': 'Включить руководство в облачную синхронизацию', + 'When off, this guide stays only on this device.': 'Если выключено, руководство хранится только на этом устройстве.', + 'Cloud snapshots': 'Облачные снимки', + 'Remove cloud copies': 'Удалить облачные копии', + 'Current cloud snapshot': 'Текущий облачный снимок', + 'Previous snapshot {number}': 'Предыдущий снимок {number}', + 'Current': 'Текущий', + 'No cloud snapshots are available for this guide.': 'Для этого руководства нет облачных снимков.', + 'The current snapshot and up to two previous snapshots are retained.': 'Хранятся текущий снимок и до двух предыдущих.', + 'Capture & steps': 'Запись и шаги', + 'Save (writes linked archive when guide is linked)': 'Сохранить (записывает связанный архив, если руководство связано)', + 'Quick actions palette': 'Палитра быстрых действий', + 'Find and replace across the guide': 'Найти и заменить во всём руководстве', + 'Previous / next step': 'Предыдущий / следующий шаг', + 'Move step (and its substeps) up / down': 'Переместить шаг (с подшагами) вверх / вниз', + 'Delete current step': 'Удалить текущий шаг', + 'Undo / redo (including step moves, deleting steps and AI writes)': 'Отменить / повторить (включая перемещение и удаление шагов и правки ИИ)', + 'Paste annotation, or clipboard image as new step': 'Вставить аннотацию или изображение из буфера обмена как новый шаг', + 'Canvas tools': 'Инструменты холста', + 'Select · Rectangle · Oval · Line · Arrow · Text': 'Выбор · Прямоугольник · Овал · Линия · Стрелка · Текст', + 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop': 'Подсказка · Номер · Размытие · Выделение · Лупа · Курсор · Обрезка', + 'Copy selected annotation (in step-select mode, paste it onto many steps)': 'Копировать выбранную аннотацию (в режиме выбора шагов — вставить её на несколько шагов)', + 'Delete selected annotation': 'Удалить выбранную аннотацию', + 'Deselect annotation': 'Снять выбор с аннотации', + 'Nudge selection by 1 px / 10 px': 'Сдвинуть выделение на 1 px / 10 px', + 'View': 'Вид', + 'Zoom in / out': 'Увеличить / уменьшить', + 'Fit image to window': 'Вписать изображение в окно', + 'Compare with a snapshot': 'Сравнение со снимком', + 'Next change': 'Следующее изменение', + 'Previous change': 'Предыдущее изменение', + 'Mark the current change as viewed and go to the next one': 'Отметить текущее изменение как просмотренное и перейти к следующему', + 'Close the comparison': 'Закрыть сравнение', + 'Keyboard shortcuts': 'Сочетания клавиш', + 'Continue': 'Продолжить', + 'StepForge will hide after you continue.': 'StepForge скроется, когда вы продолжите.', + 'Before recording starts': 'Перед началом записи', + 'Recording tip': 'Совет по записи', + 'To stop, use StepForge REC in the GNOME top panel, or restore StepForge from the dock.': 'Чтобы остановить запись, используйте StepForge REC на верхней панели GNOME или восстановите StepForge из дока.', + 'When you want to pause or stop, use the red tray icon in the system tray.': 'Чтобы приостановить или остановить запись, используйте красный значок в области уведомлений.', + '{count} matches, {selected} selected, {blocked} cannot take this replacement.': { one: '{count} совпадение, выбрано: {selected}, не подходят для замены: {blocked}.', few: '{count} совпадения, выбрано: {selected}, не подходят для замены: {blocked}.', many: '{count} совпадений, выбрано: {selected}, не подходят для замены: {blocked}.', other: '{count} совпадений, выбрано: {selected}, не подходят для замены: {blocked}.' }, + '{count} matches, {selected} selected.': { one: '{count} совпадение, выбрано: {selected}.', few: '{count} совпадения, выбрано: {selected}.', many: '{count} совпадений, выбрано: {selected}.', other: '{count} совпадений, выбрано: {selected}.' }, + '{count} days': { one: '{count} день', few: '{count} дня', many: '{count} дней', other: '{count} дней' }, + 'email address': 'адрес электронной почты', + 'phone number': 'номер телефона', + 'card number': 'номер карты', + 'Social Security number': 'номер социального страхования', + 'IP address': 'IP-адрес', + 'password or key': 'пароль или ключ', + 'link with a private token': 'ссылка с личным токеном', + 'word you asked to hide': 'слово, которое вы попросили скрывать', + 'Rectangle': 'Прямоугольник', + 'Oval': 'Овал', + 'Line': 'Линия', + 'Arrow': 'Стрелка', + 'Text': 'Текст', + 'Tooltip': 'Подсказка', + 'Number': 'Номер', + 'Blur': 'Размытие', + 'Highlight': 'Выделение', + 'Magnify': 'Лупа', + 'Cursor': 'Курсор', + 'Text block': 'Текстовый блок', + 'Code block': 'Блок кода', + 'Table': 'Таблица', + 'Stop writing': 'Остановить', + 'Turn on AI in Settings…': 'Включить ИИ в настройках…', + 'Write the whole guide': 'Написать руководство целиком', + 'Write the whole step': 'Написать шаг целиком', + 'Write the title': 'Написать название', + 'Write the description': 'Написать описание', + 'Write the blocks': 'Написать блоки', + 'writing…': 'пишет…', + 'Stopping AI…': 'Остановка ИИ…', + 'Turn on AI in Settings first.': 'Сначала включите ИИ в настройках.', + 'AI could not write this step.': 'ИИ не удалось написать этот шаг.', + 'AI could not write the title.': 'ИИ не удалось написать название.', + 'AI could not write the description.': 'ИИ не удалось написать описание.', + 'AI could not write the blocks.': 'ИИ не удалось написать блоки.', + 'There is nothing for AI to write from yet. Capture some steps first.': 'ИИ пока не на чем основываться. Сначала запишите несколько шагов.', + 'Then it writes the guide’s title and description. Text you already wrote is polished, not replaced with something new.': 'Затем он напишет название и описание руководства. Уже написанный вами текст будет отшлифован, а не заменён.', + 'A snapshot is saved first, so you can undo this from More → Backups & snapshots.': 'Сначала сохраняется снимок, поэтому это можно отменить через Ещё → Резервные копии и снимки.', + 'before AI': 'перед ИИ', + 'Could not save a snapshot first, so AI did not start: {error}': 'Не удалось сначала сохранить снимок, поэтому ИИ не запущен: {error}', + 'AI could not write a step.': 'ИИ не удалось написать шаг.', + 'AI could not organize the guide.': 'ИИ не удалось упорядочить руководство.', + 'AI could not write the guide title.': 'ИИ не удалось написать название руководства.', + 'Added {placeholders}.': 'Добавлено: {placeholders}.', + 'Steps': 'Шаги', + 'Add': 'Добавить', + 'Import': 'Импорт', + 'Select steps; Shift-click to select a range': 'Выбор шагов; Shift+щелчок — выбрать диапазон', + 'Move step up': 'Переместить шаг вверх', + 'Move step down': 'Переместить шаг вниз', + 'Fit': 'Вписать', + 'Undo': 'Отменить', + 'Redo': 'Повторить', + 'Select an image step to edit annotations.': 'Выберите шаг с изображением, чтобы изменить аннотации.', + 'Step title': 'Название шага', + 'Todo': 'К выполнению', + 'In progress': 'В работе', + 'Zoom': 'Масштаб', + 'Pan X': 'Сдвиг X', + 'Pan Y': 'Сдвиг Y', + 'Exports crop to this view; the original image is never modified.': 'При экспорте изображение обрезается по этому виду; исходное изображение не изменяется.', + 'Annotations': 'Аннотации', + 'Blocks': 'Блоки', + '+ Text block': '+ Текстовый блок', + '+ Code': '+ Код', + '+ Table': '+ Таблица', + 'Save changes. For guides linked to a shared archive, also writes the archive file.': 'Сохранить изменения. Для руководств, связанных с общим архивом, также записывается файл архива.', + 'Snapshot': 'Снимок', + 'Untitled step': 'Шаг без названия', + 'Select a step first.': 'Сначала выберите шаг.', + 'Select a step to add blocks.': 'Выберите шаг, чтобы добавить блоки.', + 'Remove block': 'Удалить блок', + 'Move block up': 'Переместить блок вверх', + 'Move block down': 'Переместить блок вниз', + 'Before title': 'Перед названием', + 'After title': 'После названия', + 'Before image': 'Перед изображением', + 'After image': 'После изображения', + 'Before description': 'Перед описанием', + 'After description': 'После описания', + 'Note': 'Примечание', + 'Warning': 'Предупреждение', + 'Important': 'Важно', + 'Tip': 'Совет', + 'Block title': 'Заголовок блока', + 'Block text': 'Текст блока', + 'Language (e.g. bash)': 'Язык (например, bash)', + 'Code': 'Код', + 'One row per line, cells separated by |': 'Одна строка таблицы на строку, ячейки разделяются |', + 'First line is the header row.': 'Первая строка — строка заголовков.', + 'Informational text, code, and table blocks can be reordered with drag handles or arrows.': 'Текстовые блоки, блоки кода и таблицы можно переупорядочивать маркерами или стрелками.', + 'Add substep': 'Добавить подшаг', + 'Make substep of…': 'Сделать подшагом…', + 'Duplicate step': 'Дублировать шаг', + 'Choose image…': 'Выбрать изображение…', + 'Retake screenshot': 'Переснять снимок экрана', + 'Full screen': 'Весь экран', + 'Region…': 'Область…', + 'Move up': 'Вверх', + 'Move down': 'Вниз', + 'Move to…': 'Переместить в…', + 'Delete step': 'Удалить шаг', + 'Select step {step}': 'Выбрать шаг {step}', + 'No steps yet.': 'Шагов пока нет.', + 'Add the copied annotation at the same position on each selected step': 'Добавить скопированную аннотацию в то же место на каждом выбранном шаге', + 'linked': 'связано', + 'local': 'локально', + 'No step selected.': 'Шаг не выбран.', + 'Select a step to edit annotations.': 'Выберите шаг, чтобы изменить аннотации.', + 'No annotations yet. Pick a tool and drag on the canvas.': 'Аннотаций пока нет. Выберите инструмент и проведите по холсту.', + 'Private detail: {kind}': 'Личные данные: {kind}', + 'added by StepForge': 'добавлено StepForge', + 'Select an annotation to edit its style.': 'Выберите аннотацию, чтобы изменить её стиль.', + 'Bottom': 'Снизу', + 'Top': 'Сверху', + 'Left': 'Слева', + 'Right': 'Справа', + 'Color': 'Цвет', + 'Stroke': 'Контур', + 'Annotation text': 'Текст аннотации', + 'Type': 'Тип', + 'Fill': 'Заливка', + 'Stroke width': 'Толщина контура', + 'Font size': 'Размер шрифта', + 'Text color': 'Цвет текста', + 'Radius': 'Радиус', + 'Tail': 'Хвостик', + 'Copy this style to every other “{type}” annotation:': 'Применить этот стиль ко всем остальным аннотациям «{type}»:', + 'Overwrite the style of every “{type}” annotation on this step with the style shown above.': 'Заменить стиль всех аннотаций «{type}» на этом шаге стилем, показанным выше.', + 'This step': 'Этот шаг', + 'Overwrite the style of every “{type}” annotation across all steps in this guide with the style shown above.': 'Заменить стиль всех аннотаций «{type}» на всех шагах руководства стилем, показанным выше.', + 'Entire guide': 'Всё руководство', + 'Could not undo the move: {error}': 'Не удалось отменить перемещение: {error}', + 'Undo anyway': 'Всё равно отменить', + 'Could not undo every step: {error}': 'Не удалось отменить изменения всех шагов: {error}', + 'Nothing to undo.': 'Нечего отменять.', + 'Nothing to redo.': 'Нечего повторять.', + 'Save failed': 'Не удалось сохранить', + 'Could not save this step — your changes are kept. Retrying…': 'Не удалось сохранить шаг — изменения сохранены в редакторе. Повторная попытка…', + 'Could not save guide details — your changes are kept. Retrying…': 'Не удалось сохранить сведения о руководстве — изменения сохранены в редакторе. Повторная попытка…', + 'Saved and synced to linked archive.': 'Сохранено и синхронизировано со связанным архивом.', + 'Saved locally, but the linked archive is locked by another session.': 'Сохранено локально, но связанный архив заблокирован другим сеансом.', + 'Saved.': 'Сохранено.', + 'Merged changes from another session.': 'Объединены изменения из другого сеанса.', + 'Snapshot name': 'Название снимка', + 'Snapshot created.': 'Снимок создан.', + 'Add Step': 'Добавить шаг', + 'Step added.': 'Шаг добавлен.', + 'Add Substep': 'Добавить подшаг', + 'Substep title': 'Название подшага', + 'Untitled substep': 'Подшаг без названия', + 'Substep added.': 'Подшаг добавлен.', + 'A step cannot be made a substep of itself or one of its own substeps.': 'Шаг нельзя сделать подшагом самого себя или одного из своих подшагов.', + '“{title}” is already a substep of step {number}.': '«{title}» уже является подшагом шага {number}.', + '“{title}” is now a substep of step {number}.': '«{title}» теперь подшаг шага {number}.', + 'Untitled step copy': 'Копия шага без названия', + 'Step duplicated.': 'Шаг продублирован.', + 'Stop the recording session before retaking a screenshot.': 'Остановите сеанс записи, прежде чем переснимать снимок экрана.', + 'Screenshot retaken.': 'Снимок экрана переснят.', + 'Screenshot replaced.': 'Снимок экрана заменён.', + 'Private details will be checked again before publishing.': 'Личные данные будут снова проверены перед публикацией.', + 'Its annotations were kept, so check they still line up.': 'Аннотации сохранены — проверьте, что они по-прежнему на своих местах.', + 'Press Ctrl+Z to undo.': 'Нажмите Ctrl+Z, чтобы отменить.', + 'Step deleted. Press Ctrl+Z to undo.': 'Шаг удалён. Нажмите Ctrl+Z, чтобы отменить.', + 'Could not move the step: {error}': 'Не удалось переместить шаг: {error}', + 'Top level': 'Верхний уровень', + 'At the top': 'В самое начало', + 'Move “{title}”': 'Переместить «{title}»', + 'Its substeps move with it.': 'Его подшаги переместятся вместе с ним.', + 'The guide changed while you were choosing, so the step was not moved. Try Move to… again.': 'Руководство изменилось, пока вы выбирали, поэтому шаг не перемещён. Попробуйте «Переместить в…» ещё раз.', + 'Step moved. Press Ctrl+Z to undo.': 'Шаг перемещён. Нажмите Ctrl+Z, чтобы отменить.', + 'A step can’t be dropped inside its own substeps.': 'Шаг нельзя поместить внутрь его собственных подшагов.', + 'Images imported.': 'Изображения импортированы.', + 'Captured.': 'Записано.', + 'Capture full screen': 'Записать весь экран', + 'Capture window': 'Записать окно', + 'Capture region…': 'Записать область…', + 'Paste image as step': 'Вставить изображение как шаг', + 'Import images…': 'Импортировать изображения…', + 'Start capture session (hotkey)': 'Начать сеанс записи (по сочетанию клавиш)', + 'Image pasted as a new step.': 'Изображение вставлено как новый шаг.', + 'Clipboard has no image.': 'В буфере обмена нет изображения.', + 'Shared to {path}': 'Сохранено в {path}', + 'Restore “{name}”? Current state is snapshotted first, so this is undoable.': 'Восстановить «{name}»? Сначала будет создан снимок текущего состояния, так что это можно отменить.', + 'Snapshot restored.': 'Снимок восстановлен.', + 'Could not compare with this snapshot: {error}': 'Не удалось сравнить с этим снимком: {error}', + 'Guide placeholders': 'Заполнители руководства', + 'Use [[Name]] in titles, descriptions, and blocks. Guide values override global ones.': 'Используйте [[Имя]] в названиях, описаниях и блоках. Значения руководства заменяют общие.', + 'Placeholders saved.': 'Заполнители сохранены.', + 'Guide information saved.': 'Сведения о руководстве сохранены.', + 'Restore this cloud snapshot? Your current local guide is kept as a cloud snapshot first.': 'Восстановить этот облачный снимок? Текущее локальное руководство сначала сохранится как облачный снимок.', + 'Cloud snapshot restored.': 'Облачный снимок восстановлен.', + 'Remove every cloud snapshot for this guide? This also turns off cloud sync for the guide. The local guide is kept.': 'Удалить все облачные снимки этого руководства? Облачная синхронизация для него также отключится. Локальное руководство сохранится.', + 'Cloud copies removed.': 'Облачные копии удалены.', + 'Style applied to all “{type}” annotations in this step.': 'Стиль применён ко всем аннотациям «{type}» на этом шаге.', + 'Style applied to all “{type}” annotations in the guide.': 'Стиль применён ко всем аннотациям «{type}» в руководстве.', + 'Capture paused.': 'Запись приостановлена.', + 'Capture resumed.': 'Запись продолжена.', + 'Imported template for {format}.': 'Шаблон для {format} импортирован.', + 'Template shared as .sfglt.': 'Шаблон сохранён как .sfglt.', + 'Preview opened (first steps only).': 'Предпросмотр открыт (только первые шаги).', + 'This guide': 'Это руководство', + 'Linked archive saved.': 'Связанный архив сохранён.', + 'Could not save linked archive.': 'Не удалось сохранить связанный архив.', + 'Linked archive force-saved.': 'Связанный архив сохранён принудительно.', + 'Export the current guide': 'Экспортировать текущее руководство', + 'Linked guide': 'Связанное руководство', + 'Show linked archive details': 'Показать сведения о связанном архиве', + 'Start capture session': 'Начать сеанс записи', + 'Enable hotkey capture for this guide': 'Включить запись по сочетанию клавиш для этого руководства', + 'Could not save every step: {error}': 'Не удалось сохранить все шаги: {error}', + '{count} skipped.': 'Пропущено: {count}.', + 'Nothing was replaced.': 'Ничего не заменено.', + 'No steps changed.': 'Шаги не изменены.', + 'Stopped early: {error}': 'Остановлено досрочно: {error}', + 'Crop the working image to the selected area?': 'Обрезать рабочее изображение по выделенной области?', + 'Image cropped.': 'Изображение обрезано.', + 'Edit tooltip': 'Изменить подсказку', + 'Edit text': 'Изменить текст', + 'Annotation copied.': 'Аннотация скопирована.', + 'AI will write the title, description and blocks for {count} steps, make substeps where steps belong together, and add placeholders for values that repeat.': { one: 'ИИ напишет название, описание и блоки для {count} шага.', few: 'ИИ напишет название, описание и блоки для {count} шагов, объединит связанные шаги в подшаги и добавит заполнители для повторяющихся значений.', many: 'ИИ напишет название, описание и блоки для {count} шагов, объединит связанные шаги в подшаги и добавит заполнители для повторяющихся значений.', other: 'ИИ напишет название, описание и блоки для {count} шагов, объединит связанные шаги в подшаги и добавит заполнители для повторяющихся значений.' }, + 'Made {count} substeps.': { one: 'Создан {count} подшаг.', few: 'Создано {count} подшага.', many: 'Создано {count} подшагов.', other: 'Создано {count} подшагов.' }, + 'Stopped. AI wrote {written} of {count} steps.': { one: 'Остановлено. ИИ написал {written} из {count} шага.', few: 'Остановлено. ИИ написал {written} из {count} шагов.', many: 'Остановлено. ИИ написал {written} из {count} шагов.', other: 'Остановлено. ИИ написал {written} из {count} шагов.' }, + 'AI wrote {written} of {count} steps.': { one: 'ИИ написал {written} из {count} шага.', few: 'ИИ написал {written} из {count} шагов.', many: 'ИИ написал {written} из {count} шагов.', other: 'ИИ написал {written} из {count} шагов.' }, + 'AI wrote the whole guide ({count} steps).': { one: 'ИИ написал руководство целиком ({count} шаг).', few: 'ИИ написал руководство целиком ({count} шага).', many: 'ИИ написал руководство целиком ({count} шагов).', other: 'ИИ написал руководство целиком ({count} шагов).' }, + '{count} steps': { one: '{count} шаг', few: '{count} шага', many: '{count} шагов', other: '{count} шагов' }, + 'Paste annotation to {count} steps': { one: 'Вставить аннотацию на {count} шаг', few: 'Вставить аннотацию на {count} шага', many: 'Вставить аннотацию на {count} шагов', other: 'Вставить аннотацию на {count} шагов' }, + '{count} steps were changed since this edit, perhaps by AI or another app. Undo anyway and overwrite those changes?': { one: 'С момента этой правки шаг изменился — возможно, из-за ИИ или другого приложения. Всё равно отменить и перезаписать эти изменения?', few: 'С момента этой правки изменились {count} шага — возможно, из-за ИИ или другого приложения. Всё равно отменить и перезаписать эти изменения?', many: 'С момента этой правки изменилось {count} шагов — возможно, из-за ИИ или другого приложения. Всё равно отменить и перезаписать эти изменения?', other: 'С момента этой правки изменилось {count} шагов — возможно, из-за ИИ или другого приложения. Всё равно отменить и перезаписать эти изменения?' }, + '{count} conflicts were settled in favor of the most recent edit.': { one: '{count} конфликт решён в пользу последней правки.', few: '{count} конфликта решены в пользу последней правки.', many: '{count} конфликтов решены в пользу последней правки.', other: '{count} конфликтов решены в пользу последней правки.' }, + 'Replaced {applied} in {count} steps.': { one: 'Замен: {applied} в {count} шаге.', few: 'Замен: {applied} в {count} шагах.', many: 'Замен: {applied} в {count} шагах.', other: 'Замен: {applied} в {count} шагах.' }, + 'Annotation pasted onto {count} steps.': { one: 'Аннотация вставлена на {count} шаг.', few: 'Аннотация вставлена на {count} шага.', many: 'Аннотация вставлена на {count} шагов.', other: 'Аннотация вставлена на {count} шагов.' }, + 'Skipped {count} steps (already there, no image, or a different image shape): {steps}.': { one: 'Пропущен {count} шаг (уже есть, нет изображения или другая форма изображения): {steps}.', few: 'Пропущено {count} шага (уже есть, нет изображения или другая форма изображения): {steps}.', many: 'Пропущено {count} шагов (уже есть, нет изображения или другая форма изображения): {steps}.', other: 'Пропущено {count} шагов (уже есть, нет изображения или другая форма изображения): {steps}.' }, + 'Close (Esc)': 'Закрыть (Esc)', + 'Confirm': 'Подтверждение', + 'Nothing else to choose': 'Больше нечего выбрать', + '{count} years': { one: '{count} год', few: '{count} года', many: '{count} лет', other: '{count} лет' }, + '{count} months': { one: '{count} месяц', few: '{count} месяца', many: '{count} месяцев', other: '{count} месяцев' }, + 'Guide library': 'Библиотека руководств', + 'Cloud sync status': 'Состояние облачной синхронизации', + 'Search guides… (Ctrl+/ for quick actions)': 'Поиск руководств… (Ctrl+/ — быстрые действия)', + 'Beta': 'Бета', }); diff --git a/core/locales/zh-CN.js b/core/locales/zh-CN.js index 49ad820f..56dc41c2 100644 --- a/core/locales/zh-CN.js +++ b/core/locales/zh-CN.js @@ -5,4 +5,736 @@ if (typeof module !== 'undefined' && module.exports) module.exports = messages; if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['zh-CN'] = messages; })(typeof window !== 'undefined' ? window : null, { + 'Import images as steps': '将图片导入为步骤', + 'Choose a screenshot for this step': '为此步骤选择截图', + 'Choose guide storage folder': '选择指南存储文件夹', + 'Share guide as file': '以文件形式分享指南', + 'Open guide archive': '打开指南存档', + 'Choose output folder': '选择输出文件夹', + 'Choose an image': '选择图片', + 'Choose a font file': '选择字体文件', + 'Choose a CSS file': '选择 CSS 文件', + 'Choose a certificate': '选择证书', + '{host} asks for a certificate to sign you in.': '{host} 需要证书才能让你登录。', + 'Choose the one from your smart card that you use to sign in to websites. StepForge remembers your choice for this site.': '请选择你的智能卡中用于登录网站的证书。StepForge 会为此网站记住你的选择。', + '{subject} (issued by {issuer})': '{subject}(颁发者:{issuer})', + 'Cancel': '取消', + 'just now': '刚刚', + '{count} min ago': '{count} 分钟前', + '{count} hr ago': '{count} 小时前', + 'yesterday': '昨天', + 'Last verified {date}': '上次验证于 {date}', + 'Last saved {date}': '上次保存于 {date}', + '{since}, over {interval} ago. Open the guide to check it still matches the software, or right-click → Mark as verified.': '{since},已超过 {interval}。请打开指南检查它是否仍与软件一致,或右键单击 → 标记为已验证。', + 'Deleted for good within a day': '将在一天内永久删除', + 'An AI agent updated your brand profiles.': 'AI 代理更新了你的品牌配置文件。', + 'An AI agent updated this guide.': 'AI 代理更新了此指南。', + 'Documentation generated.': '已生成文档。', + 'Capture, annotate, and export step-by-step guides.': '捕获、标注并导出分步指南。', + 'New Capture': '新建捕获', + 'Start a guide and capture your screen': '创建指南并捕获屏幕', + 'Existing Workspace': '现有工作区', + 'Browse your guide library': '浏览你的指南库', + 'Settings': '设置', + 'Theme, capture, and export options': '主题、捕获和导出选项', + 'Recent guides': '最近的指南', + 'Untitled capture': '未命名的捕获', + 'synced': '已同步', + 'syncing…': '正在同步…', + 'pending': '等待中', + 'needs attention': '需要处理', + '{service} settings': '{service} 设置', + 'Recording - stopped': '录制 - 已停止', + 'New recording': '新建录制', + 'waiting for screen sharing': '正在等待屏幕共享', + 'ready': '就绪', + 'paused': '已暂停', + 'on click': '单击时', + 'every {seconds}s': '每 {seconds} 秒', + 'hotkey only': '仅快捷键', + 'StepForge tucks away and starts capturing': 'StepForge 将隐藏并开始捕获', + 'Start recording': '开始录制', + 'Stop recording': '停止录制', + 'Capture session — {trigger}': '捕获会话 — {trigger}', + 'Recording - {trigger}': '录制 - {trigger}', + 'Full screen (window capture unavailable)': '全屏(窗口捕获不可用)', + 'Library': '指南库', + 'Capture a screenshot step': '捕获截图步骤', + 'Capture ▾': '捕获 ▾', + 'Save': '保存', + 'Export': '导出', + 'Share this guide as a file or on the web': '以文件形式或在网上分享此指南', + 'Save as a .sfgz file…': '另存为 .sfgz 文件…', + 'Publish to the web…': '发布到网上…', + 'Publish to Confluence…': '发布到 Confluence…', + 'Publish to Wiki.js…': '发布到 Wiki.js…', + 'Share ▾': '分享 ▾', + 'AI is writing. Click to stop.': 'AI 正在撰写。单击可停止。', + 'Let AI write this guide': '让 AI 撰写此指南', + 'AI ▾': 'AI ▾', + 'Rename guide…': '重命名指南…', + 'Guide information…': '指南信息…', + 'Guide placeholders…': '指南占位符…', + 'Backups & snapshots…': '备份和快照…', + 'Find private details…': '查找隐私信息…', + 'Linked guide…': '链接的指南…', + 'Linked guide (not linked)': '链接的指南(未链接)', + 'Keyboard shortcuts…': '键盘快捷键…', + 'Quick actions (Ctrl+/)': '快速操作 (Ctrl+/)', + 'Settings…': '设置…', + 'More ▾': '更多 ▾', + 'Results for “{query}”': '“{query}”的搜索结果', + 'All guides': '所有指南', + 'Favorites': '收藏', + 'Folders': '文件夹', + 'New folder': '新建文件夹', + 'No folders yet': '还没有文件夹', + 'Trash': '回收站', + 'Select': '选择', + 'Empty trash': '清空回收站', + 'Import ▾': '导入 ▾', + 'New guide': '新建指南', + 'Import a copy of a guide (.sfgz)…': '导入指南副本 (.sfgz)…', + 'Open a linked guide (.sfgz)…': '打开链接的指南 (.sfgz)…', + 'Rename folder': '重命名文件夹', + 'Delete folder': '删除文件夹', + 'Search': '搜索', + 'No results for this query.': '没有与此查询匹配的结果。', + 'No guides yet': '还没有指南', + 'Create a guide and start capturing steps, or import one someone shared with you.': '创建指南并开始捕获步骤,或导入别人分享给你的指南。', + 'No favorites': '没有收藏', + 'Star a guide to keep it here.': '为指南加星标即可在此处保留。', + 'This folder is empty': '此文件夹为空', + 'Right-click a guide, or select guides, to move them here.': '右键单击指南,或选择多个指南,即可将其移到这里。', + 'Trash is empty': '回收站为空', + 'Deleted guides stay here until you empty the trash.': '已删除的指南会保留在这里,直到你清空回收站。', + 'Restore': '恢复', + 'Deleted {when}': '删除于 {when}', + 'Deselect': '取消选择', + 'Open guide · Ctrl-click to select': '打开指南 · Ctrl+单击以选择', + 'Remove from favorites': '取消收藏', + 'Add to favorites': '添加到收藏', + 'Untitled guide': '未命名指南', + 'Saved to a linked .sfgz file': '已保存到链接的 .sfgz 文件', + 'Linked': '已链接', + 'Open on another device': '已在其他设备上打开', + 'Locked': '已锁定', + 'May need review': '可能需要审阅', + 'Step': '步骤', + 'Guide': '指南', + 'Move to {folder}': '移到 {folder}', + 'Move to no folder': '移出文件夹', + 'Open guide': '打开指南', + 'Unfavorite': '取消收藏', + 'Favorite': '收藏', + 'Mark as verified': '标记为已验证', + 'Duplicate guide': '复制指南', + 'Delete guide': '删除指南', + 'Delete forever': '永久删除', + 'Selection actions': '所选项操作', + '{count} of {total} selected': '已选择 {count} / {total}', + 'Click guides to select them': '单击指南以选择', + 'Clear': '清除', + 'Select all': '全选', + 'Move to ▾': '移到 ▾', + 'Record that these guides still match the software today': '记录这些指南仍与当前软件一致', + 'Delete': '删除', + 'No folder': '不放入文件夹', + 'New Guide': '新建指南', + 'Title': '标题', + 'Folder name': '文件夹名称', + 'Delete the folder “{folder}”? Guides stay in the library.': '删除文件夹“{folder}”?指南会保留在指南库中。', + 'Marked as verified today.': '今天已标记为已验证。', + '{count} guides marked as verified today.': '今天已将 {count} 个指南标记为已验证。', + 'Delete “{title}”? It moves to Trash. If it syncs to the cloud, it is also removed from your other devices and one cloud recovery snapshot is retained.': '删除“{title}”?它会移到回收站。如果它同步到云端,也会从你的其他设备中移除,并在云端保留一个恢复快照。', + 'Permanently empty the trash?': '永久清空回收站?', + 'Rename guide': '重命名指南', + 'Create a blank guide': '创建空白指南', + 'Import archive': '导入存档', + 'Open a .sfgz guide archive': '打开 .sfgz 指南存档', + 'Open linked archive': '打开链接的存档', + 'Import a linked guide from .sfgz': '从 .sfgz 导入链接的指南', + 'Open application settings': '打开应用设置', + 'Refresh library': '刷新指南库', + 'Reload guides and folders': '重新加载指南和文件夹', + '{count} days ago': { other: '{count} 天前' }, + 'Deleted for good in {count} days': { other: '将在 {count} 天后永久删除' }, + 'Blurred {count} possible private details in this step.': { other: '已模糊此步骤中 {count} 处可能的隐私信息。' }, + '{count} deleted guides': { other: '{count} 个已删除的指南' }, + '{count} guides': { other: '{count} 个指南' }, + 'Deleted guides stay here for {count} days, then are deleted for good. Change this in Settings → General.': { other: '已删除的指南会在这里保留 {count} 天,然后被永久删除。可在“设置 → 常规”中更改。' }, + 'Permanently delete {count} items? This cannot be undone.': { other: '永久删除 {count} 个项目?此操作无法撤消。' }, + 'Delete {count} guides? They’ll move to Trash. Guides that sync to the cloud are also removed from your other devices, with one cloud recovery snapshot retained.': { other: '删除 {count} 个指南?它们会移到回收站。同步到云端的指南也会从你的其他设备中移除,并在云端保留一个恢复快照。' }, + 'Plain (no highlighting)': '纯文本(无高亮)', + 'Clear shortcut': '清除快捷键', + 'Click, then press a key combination': '单击后按下组合键', + 'Press a key combination…': '请按下组合键…', + 'Click to set shortcut': '单击以设置快捷键', + 'Move step': '移动步骤', + '{place} (current place)': '{place}(当前位置)', + 'Place': '位置', + 'Level': '层级', + 'Move': '移动', + 'OK': '确定', + 'Search guides, steps, and commands': '搜索指南、步骤和命令', + 'Type to search, arrows to move, Enter to open.': '输入以搜索,方向键移动,Enter 打开。', + 'No matches.': '没有匹配项。', + 'Quick Actions': '快速操作', + 'Close': '关闭', + 'Find': '查找', + 'Replace with': '替换为', + 'Replace': '替换', + 'Type something to find.': '请输入要查找的内容。', + 'Searching…': '正在搜索…', + 'Replace {count}': '替换 {count} 处', + 'Step {number}': '步骤 {number}', + 'Showing the first {count} matches. Narrow the search to see the rest.': '仅显示前 {count} 个匹配项。缩小搜索范围以查看其余结果。', + 'Could not replace: {error}': '无法替换:{error}', + 'Find and replace': '查找和替换', + 'Searches step titles and descriptions, text, code and table blocks, and annotation labels. Screenshots are not changed. Ctrl+Z undoes the whole replacement.': '搜索步骤标题和描述、文本块、代码块、表格块以及标注标签。截图不会更改。Ctrl+Z 可撤消整个替换。', + 'Check for updates': '检查更新', + 'Update now': '立即更新', + 'Starting…': '正在启动…', + 'Your latest changes couldn’t be saved, so the update didn’t start.': '你最近的更改未能保存,因此更新没有开始。', + 'Couldn’t start the update. Try again, or download the new installer.': '无法开始更新。请重试,或下载新的安装程序。', + 'Closing StepForge…': '正在关闭 StepForge…', + 'An administrator window will install the update and reopen StepForge.': '管理员窗口将安装更新并重新打开 StepForge。', + 'Couldn’t start the update.': '无法开始更新。', + 'Open Microsoft Store': '打开 Microsoft Store', + 'Couldn’t open the Microsoft Store. Open it from the Start menu, then choose Library → Get updates.': '无法打开 Microsoft Store。请从“开始”菜单打开它,然后选择“库 → 获取更新”。', + 'Checking…': '正在检查…', + 'Couldn’t check for updates. Try again later.': '无法检查更新。请稍后重试。', + 'Version {version} is available': '版本 {version} 现已推出', + 'You have {version}.': '你的版本是 {version}。', + 'View release and download': '查看版本并下载', + 'You’re up to date': '已是最新版本', + 'Version {version} is the latest release.': '版本 {version} 是最新版本。', + 'Couldn’t check for updates.': '无法检查更新。', + 'Try again': '重试', + 'Checking guide storage location…': '正在检查指南存储位置…', + 'Choose folder…': '选择文件夹…', + 'Use default': '使用默认位置', + 'Cancel pending move': '取消待定的移动', + 'Default location.': '默认位置。', + 'Default: {path}': '默认:{path}', + 'Moving to {path} the next time StepForge starts.': '下次启动 StepForge 时将移动到 {path}。', + 'Set by STEPFORGE_DATA_DIR for this session.': '本次会话由 STEPFORGE_DATA_DIR 设置。', + 'Could not read guide storage location.': '无法读取指南存储位置。', + 'Could not schedule the library move.': '无法安排指南库移动。', + 'Development build': '开发版本', + 'Release': '正式版本', + 'Unavailable': '不可用', + 'System': '跟随系统', + 'Light': '浅色', + 'Dark': '深色', + 'System default': '跟随系统', + 'Fullscreen': '全屏', + 'Window': '窗口', + 'One per line, for example a name or project code': '每行一个,例如姓名或项目代码', + 'Words to always hide': '始终隐藏的词语', + 'Default focus amount': '默认聚焦程度', + 'Reset': '重置', + 'Hotkey': '快捷键', + 'Timed interval': '定时间隔', + 'After 7 days': '7 天后', + 'After 30 days': '30 天后', + 'After 90 days': '90 天后', + 'Never': '从不', + 'After {interval}': '{interval}后', + 'Loading the setup commands…': '正在加载设置命令…', + 'Run this in a terminal once.': '在终端中运行一次即可。', + 'Claude Desktop, Cursor, and other apps': 'Claude Desktop、Cursor 及其他应用', + 'Add this to the app’s MCP servers settings.': '将此内容添加到应用的 MCP 服务器设置中。', + 'Copied': '已复制', + 'Copy': '复制', + 'Could not build the setup commands: {error}': '无法生成设置命令:{error}', + 'Not tested yet. Vision-capable models can also inspect each step’s screenshot.': '尚未测试。支持视觉的模型还可以查看每个步骤的截图。', + 'Test connection': '测试连接', + 'Testing…': '正在测试…', + 'Checking Ollama at the configured host…': '正在检查配置的主机上的 Ollama…', + 'Could not connect to Ollama.': '无法连接到 Ollama。', + 'Connected to {host} with {model}. It can inspect screenshots.': '已连接到 {host},使用 {model}。它可以查看截图。', + 'Connected to {host} with {model}. This model is text-only, so StepForge will use OCR and metadata only.': '已连接到 {host},使用 {model}。此模型仅支持文本,因此 StepForge 只会使用 OCR 和元数据。', + 'Connected to {host}, but {model} isn’t installed. Installed models: {installed}.': '已连接到 {host},但未安装 {model}。已安装的模型:{installed}。', + 'that model': '该模型', + 'Connected to {host}, but it has no models yet. Run “ollama pull gemma3” first.': '已连接到 {host},但还没有任何模型。请先运行“ollama pull gemma3”。', + 'Placeholder name': '占位符名称', + 'Content': '内容', + 'Placeholder content': '占位符内容', + 'Markdown or regular text': 'Markdown 或普通文本', + 'Remove placeholder': '删除占位符', + 'Add placeholder': '添加占位符', + 'General': '常规', + 'Appearance, exports, where your guides and deleted guides are kept, and when guides are due for review.': '外观、导出、指南和已删除指南的存放位置,以及指南何时需要审阅。', + 'Appearance': '外观', + 'Theme': '主题', + 'Match your system, or always use light or dark.': '跟随系统,或始终使用浅色或深色。', + 'Spellcheck': '拼写检查', + 'Underline misspelled words while editing.': '编辑时为拼错的单词加下划线。', + 'Exports': '导出', + 'Open folder after export': '导出后打开文件夹', + 'Show the exported file in your file manager.': '在文件管理器中显示导出的文件。', + 'Preview step count': '预览步骤数', + 'How many steps the export preview renders.': '导出预览中显示的步骤数量。', + 'Code theme': '代码主题', + 'Syntax colors for code blocks in PDF, Word, HTML and Confluence exports.': 'PDF、Word、HTML 和 Confluence 导出中代码块的语法颜色。', + 'Guide storage': '指南存储', + 'Choose an empty folder. StepForge copies and verifies your library, then switches to it the next time it starts. The old library is kept as a backup.': '请选择一个空文件夹。StepForge 会复制并校验你的指南库,并在下次启动时切换到该文件夹。旧的指南库会保留为备份。', + 'Empty the trash': '清空回收站', + 'Deleted guides are removed for good after this long.': '已删除的指南在此时长后将被永久删除。', + 'Guide reviews': '指南审阅', + 'Flag guides for review': '标记需要审阅的指南', + 'Guides that haven’t been saved or marked verified for this long show “May need review” in the library. Each guide can change this in Guide information.': '在此时长内未保存或未标记为已验证的指南,会在指南库中显示“可能需要审阅”。每个指南都可以在“指南信息”中更改此设置。', + 'Language': '语言', + 'The language of StepForge’s menus, buttons and messages. Exported guides stay in English.': 'StepForge 菜单、按钮和消息使用的语言。导出的指南仍为英文。', + 'Branding': '品牌', + 'Customize how exported guides appear.': '自定义导出指南的外观。', + 'Capture': '捕获', + 'How StepForge records steps.': 'StepForge 如何录制步骤。', + 'Recording': '录制', + 'Default mode': '默认模式', + 'Record the whole screen or the active window. For a region, use Capture → Capture region….': '录制整个屏幕或活动窗口。如需录制区域,请使用“捕获 → 捕获区域…”。', + 'Delay': '延迟', + 'Milliseconds to wait before each screenshot.': '每次截图前等待的毫秒数。', + 'Click marker': '单击标记', + 'Draw a marker where you clicked.': '在单击位置绘制标记。', + 'Capture outside clicks': '捕获外部单击', + 'Record clicks in other apps while a capture session is running.': '在捕获会话期间记录其他应用中的单击。', + 'When clicks can’t be detected': '无法检测单击时', + 'Capture with': '捕获方式', + 'Some desktops don’t report clicks. Choose how steps are captured instead.': '某些桌面环境不会报告单击。请选择改用哪种方式捕获步骤。', + 'Timer interval': '计时器间隔', + 'Seconds between timed captures.': '定时捕获之间的秒数。', + 'Hotkeys': '快捷键', + 'Take a step. Also used when clicks can’t be detected.': '捕获一个步骤。无法检测单击时也会使用。', + 'Pause / resume': '暂停 / 继续', + 'Pause or resume the capture session.': '暂停或继续捕获会话。', + 'Editor': '编辑器', + 'Defaults for new steps and automatic backups.': '新步骤的默认设置和自动备份。', + 'New steps': '新步骤', + 'Focus recorded clicks': '聚焦录制的单击', + 'Zoom new screenshots toward where you clicked.': '将新截图向单击位置放大。', + 'More context (1×) to closer focus (2×). Applies to new captures only.': '从更多上下文 (1×) 到更近聚焦 (2×)。仅适用于新捕获。', + 'Focused view for new steps': '新步骤使用聚焦视图', + 'Open new steps in focused view.': '以聚焦视图打开新步骤。', + 'Backups': '备份', + 'Snapshots to keep': '保留的快照数', + 'Older backup snapshots of each guide are removed. 0 keeps all of them.': '每个指南较旧的备份快照会被删除。0 表示全部保留。', + 'Privacy': '隐私', + 'Blur private details in screenshots. Everything is checked on this computer.': '模糊截图中的隐私信息。所有检查都在此计算机上完成。', + 'Private details': '隐私信息', + 'Before a guide goes on the web, StepForge reads its screenshots and blurs email addresses, phone and card numbers, Social Security numbers, IP addresses, passwords, keys, and links with private tokens. You can review every blur and undo any that aren’t needed.': '在指南发布到网上之前,StepForge 会读取其截图并模糊电子邮件地址、电话号码和卡号、社会保障号、IP 地址、密码、密钥以及包含私密令牌的链接。你可以查看每处模糊,并撤消不需要的模糊。', + 'Blur while recording': '录制时模糊', + 'Check each new capture as soon as it’s taken, instead of waiting until you publish.': '每次捕获后立即检查,而不是等到发布时。', + 'Always hide these words': '始终隐藏这些词语', + 'Names, project codes, or anything else to blur wherever it appears. One per line.': '姓名、项目代码或其他任何需要在出现时模糊的内容。每行一个。', + 'AI': 'AI', + 'Let AI write your guides: a model on your own computer through Ollama, or an AI agent such as Claude or Codex.': '让 AI 撰写你的指南:通过 Ollama 在你自己的计算机上运行模型,或使用 Claude、Codex 等 AI 代理。', + 'Enable AI': '启用 AI', + 'Turn on the AI ▾ menu in the guide toolbar: write a whole guide, a step, or one field.': '在指南工具栏中开启 AI ▾ 菜单:撰写整个指南、一个步骤或一个字段。', + 'Auto-document captures': '自动为捕获撰写文档', + 'Write a title and description for each new capture as you record. Turn off to use AI only when you ask.': '录制时为每个新捕获撰写标题和描述。关闭后仅在你请求时使用 AI。', + 'Let the model see screenshots': '允许模型查看截图', + 'Models that can read images see each step’s screenshot, with blurred areas already hidden. Turn off to send text only.': '能读取图片的模型会看到每个步骤的截图,模糊区域已被隐藏。关闭后仅发送文本。', + 'Ollama': 'Ollama', + 'Runs on your computer, so your screenshots and text stay here.': '在你的计算机上运行,因此截图和文本都留在本地。', + 'Host': '主机', + 'Where Ollama is running.': 'Ollama 的运行位置。', + 'Model': '模型', + 'Any installed model. Choose Test connection to list them.': '任何已安装的模型。选择“测试连接”即可列出。', + 'AI guide': 'AI 指南', + 'Recommended models': '推荐模型', + 'AI agents (Claude, Codex)': 'AI 代理(Claude、Codex)', + 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Claude 和 Codex 等 AI 应用可以连接到 StepForge 并为你撰写指南。它们可以改写文本、创建子步骤、添加占位符并在截图上绘制,但不能重新排序步骤或删除任何内容。', + 'Let AI agents edit your guides': '允许 AI 代理编辑你的指南', + 'Turn off to cut off every connected agent at once.': '关闭后会立即断开所有已连接的代理。', + 'Agents can see screenshots': '代理可以查看截图', + 'Blurred areas are filled in first. Turn off to share step text only.': '模糊区域会先被填充。关闭后仅分享步骤文本。', + 'Let AI agents edit brand profiles': '允许 AI 代理编辑品牌配置文件', + 'Agents can make and change brand profiles: logo, colors, fonts and CSS, and choose the one exports use. They can’t delete a profile.': '代理可以创建和更改品牌配置文件(徽标、颜色、字体和 CSS),并选择导出使用哪一个。它们不能删除配置文件。', + 'Accounts': '帐户', + 'Sync guides with Google Drive, OneDrive, Dropbox or Nextcloud, share them on the web with GitHub, or publish them to Confluence or Wiki.js.': '将指南与 Google Drive、OneDrive、Dropbox 或 Nextcloud 同步,通过 GitHub 在网上分享,或发布到 Confluence 或 Wiki.js。', + 'Placeholders': '占位符', + 'Reusable text for every guide. Type [[name]] in a guide to insert it.': '适用于所有指南的可复用文本。在指南中输入 [[名称]] 即可插入。', + 'About': '关于', + 'Local-first step-by-step guides.': '本地优先的分步指南。', + 'Version': '版本', + 'License': '许可证', + 'Data folder': '数据文件夹', + 'Updates': '更新', + 'Checks GitHub for a newer release. StepForge only connects when you press the button.': '在 GitHub 上检查是否有新版本。StepForge 仅在你按下按钮时联网。', + 'Project on GitHub': 'GitHub 上的项目', + 'Release notes': '发行说明', + 'Report an issue': '报告问题', + 'Settings sections': '设置分区', + 'AI is off. These settings are kept for later.': 'AI 已关闭。这些设置会保留以备后用。', + 'Default template': '默认模板', + 'Choose an output folder': '选择输出文件夹', + 'No branding': '不使用品牌', + 'JSON exports keep the raw data and aren’t branded.': 'JSON 导出保留原始数据,不应用品牌。', + 'Change the default in Settings → Branding.': '可在“设置 → 品牌”中更改默认值。', + 'None': '无', + 'Choose…': '选择…', + 'This format has no adjustable options.': '此格式没有可调整的选项。', + 'Keep it online for': '在线保留时长', + 'The guide is removed from the web after this.': '在此时长后,指南会从网上移除。', + 'Published guides are public.': '已发布的指南是公开的。', + 'Anyone with the link can open it, and anyone who looks at your GitHub repository can find it. Blur passwords and other private details before you export.': '任何拥有链接的人都可以打开它,任何查看你 GitHub 仓库的人都能找到它。导出前请模糊密码和其他隐私信息。', + 'Publish on the web': '发布到网上', + 'After exporting, also put this guide on your GitHub Pages site and get a link to share.': '导出后,还将此指南放到你的 GitHub Pages 网站上并获取分享链接。', + 'Connect GitHub in Settings → Accounts → GitHub to publish guides on the web.': '请在“设置 → 帐户 → GitHub”中连接 GitHub,以便将指南发布到网上。', + 'Export and publish': '导出并发布', + 'Publish to {site}…': '发布到 {site}…', + 'Publishing to {site} is easier.': '发布到 {site} 更简单。', + 'Share → Publish to {site}… creates the page for you, uploads its screenshots and updates the same page when you publish again. Export only if you’d rather upload the files yourself.': '“分享 → 发布到 {site}…”会为你创建页面、上传截图,并在再次发布时更新同一页面。只有在你想自己上传文件时才需要导出。', + 'Save template': '保存模板', + 'Template name': '模板名称', + 'Save as template…': '另存为模板…', + 'Manage…': '管理…', + 'Format': '格式', + 'Template': '模板', + 'Output folder': '输出文件夹', + 'Options': '选项', + 'Preview…': '预览…', + 'Preview': '预览', + 'Exporting…': '正在导出…', + 'No templates saved for this format yet.': '此格式还没有保存的模板。', + 'Rename template': '重命名模板', + 'Name': '名称', + 'Rename': '重命名', + 'Duplicate': '复制', + 'Share…': '分享…', + 'Import .sfglt…': '导入 .sfglt…', + 'Templates are shareable as .sfglt files.': '模板可以通过 .sfglt 文件分享。', + 'Done': '完成', + 'Locked by {user}@{host}': '已被 {user}@{host} 锁定', + 'another user': '其他用户', + 'another host': '其他主机', + 'No active conflict': '没有活动冲突', + 'Archive': '存档', + 'Not linked': '未链接', + 'Opened': '打开时间', + 'Unknown': '未知', + 'Last saved': '上次保存', + 'Lock': '锁定', + 'Another editor has the archive locked. You can force-save if you intend to overwrite it.': '另一个编辑器已锁定该存档。如果你确定要覆盖它,可以强制保存。', + 'Linked Guide': '链接的指南', + 'Show file': '显示文件', + 'Force save': '强制保存', + 'Save now': '立即保存', + 'No snapshots yet. Automatic snapshots are created as you work; create one manually any time.': '还没有快照。工作时会自动创建快照;你也可以随时手动创建。', + 'See what changed between this snapshot and the guide now': '查看此快照与当前指南之间的差异', + 'Comparing…': '正在比较…', + 'Compare': '比较', + 'Create snapshot': '创建快照', + 'Backups & snapshots': '备份和快照', + 'Compare shows what changed since a snapshot. Restores are undoable — the current state is snapshotted first.': '“比较”会显示自快照以来的更改。恢复操作可以撤消——会先为当前状态创建快照。', + 'Value': '值', + 'Remove': '删除', + 'e.g. Windows 11 24H2': '例如 Windows 11 24H2', + 'Use the default ({interval})': '使用默认值({interval})', + 'Use the default (off)': '使用默认值(关闭)', + 'After you save, “May need review” shows in the library on {date} unless the guide is saved or verified again first.': '保存后,除非指南在此之前再次保存或验证,否则将于 {date} 在指南库中显示“可能需要审阅”。', + 'This guide is never flagged for review.': '此指南从不标记为需要审阅。', + 'Today': '今天', + 'e.g. Jane Doe': '例如 张伟', + 'e.g. Alex Lee, Sam Patel': '例如 李明、王芳', + 'e.g. Acme Corp': '例如 某某公司', + 'A short summary of this guide.': '此指南的简短摘要。', + 'Guide information': '指南信息', + 'All fields are optional. They appear below the title on the guide and on the PDF cover page.': '所有字段均为可选。它们显示在指南标题下方和 PDF 封面上。', + 'Author': '作者', + 'Co-authors': '合著者', + 'Organization': '组织', + 'Description': '描述', + 'Shown on the first page of the PDF and at the top of other export formats.': '显示在 PDF 第一页及其他导出格式的顶部。', + 'Review': '审阅', + 'Software version': '软件版本', + 'Last verified': '上次验证', + 'Review again': '再次审阅', + 'Include this guide in cloud sync': '将此指南加入云同步', + 'When off, this guide stays only on this device.': '关闭后,此指南只保留在此设备上。', + 'Cloud snapshots': '云快照', + 'Remove cloud copies': '删除云副本', + 'Current cloud snapshot': '当前云快照', + 'Previous snapshot {number}': '之前的快照 {number}', + 'Current': '当前', + 'No cloud snapshots are available for this guide.': '此指南没有可用的云快照。', + 'The current snapshot and up to two previous snapshots are retained.': '会保留当前快照和最多两个之前的快照。', + 'Capture & steps': '捕获和步骤', + 'Save (writes linked archive when guide is linked)': '保存(指南已链接时会写入链接的存档)', + 'Quick actions palette': '快速操作面板', + 'Find and replace across the guide': '在整个指南中查找和替换', + 'Previous / next step': '上一步 / 下一步', + 'Move step (and its substeps) up / down': '上移 / 下移步骤(连同其子步骤)', + 'Delete current step': '删除当前步骤', + 'Undo / redo (including step moves, deleting steps and AI writes)': '撤消 / 重做(包括移动和删除步骤以及 AI 撰写)', + 'Paste annotation, or clipboard image as new step': '粘贴标注,或将剪贴板图片粘贴为新步骤', + 'Canvas tools': '画布工具', + 'Select · Rectangle · Oval · Line · Arrow · Text': '选择 · 矩形 · 椭圆 · 直线 · 箭头 · 文本', + 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop': '提示框 · 编号 · 模糊 · 高亮 · 放大镜 · 光标 · 裁剪', + 'Copy selected annotation (in step-select mode, paste it onto many steps)': '复制所选标注(在步骤选择模式下,可将其粘贴到多个步骤)', + 'Delete selected annotation': '删除所选标注', + 'Deselect annotation': '取消选择标注', + 'Nudge selection by 1 px / 10 px': '将所选内容移动 1 像素 / 10 像素', + 'View': '视图', + 'Zoom in / out': '放大 / 缩小', + 'Fit image to window': '使图片适应窗口', + 'Compare with a snapshot': '与快照比较', + 'Next change': '下一处更改', + 'Previous change': '上一处更改', + 'Mark the current change as viewed and go to the next one': '将当前更改标记为已查看并转到下一处', + 'Close the comparison': '关闭比较', + 'Keyboard shortcuts': '键盘快捷键', + 'Continue': '继续', + 'StepForge will hide after you continue.': '继续后 StepForge 将隐藏。', + 'Before recording starts': '开始录制之前', + 'Recording tip': '录制提示', + 'To stop, use StepForge REC in the GNOME top panel, or restore StepForge from the dock.': '要停止录制,请使用 GNOME 顶部面板中的 StepForge REC,或从程序坞恢复 StepForge。', + 'When you want to pause or stop, use the red tray icon in the system tray.': '需要暂停或停止时,请使用系统托盘中的红色图标。', + '{count} matches, {selected} selected, {blocked} cannot take this replacement.': { other: '{count} 个匹配项,已选择 {selected} 个,{blocked} 个无法进行此替换。' }, + '{count} matches, {selected} selected.': { other: '{count} 个匹配项,已选择 {selected} 个。' }, + '{count} days': { other: '{count} 天' }, + 'email address': '电子邮件地址', + 'phone number': '电话号码', + 'card number': '卡号', + 'Social Security number': '社会保障号', + 'IP address': 'IP 地址', + 'password or key': '密码或密钥', + 'link with a private token': '包含私密令牌的链接', + 'word you asked to hide': '你要求隐藏的词语', + 'Rectangle': '矩形', + 'Oval': '椭圆', + 'Line': '直线', + 'Arrow': '箭头', + 'Text': '文本', + 'Tooltip': '提示框', + 'Number': '编号', + 'Blur': '模糊', + 'Highlight': '高亮', + 'Magnify': '放大镜', + 'Cursor': '光标', + 'Text block': '文本块', + 'Code block': '代码块', + 'Table': '表格', + 'Stop writing': '停止撰写', + 'Turn on AI in Settings…': '在设置中开启 AI…', + 'Write the whole guide': '撰写整个指南', + 'Write the whole step': '撰写整个步骤', + 'Write the title': '撰写标题', + 'Write the description': '撰写描述', + 'Write the blocks': '撰写内容块', + 'writing…': '正在撰写…', + 'Stopping AI…': '正在停止 AI…', + 'Turn on AI in Settings first.': '请先在设置中开启 AI。', + 'AI could not write this step.': 'AI 无法撰写此步骤。', + 'AI could not write the title.': 'AI 无法撰写标题。', + 'AI could not write the description.': 'AI 无法撰写描述。', + 'AI could not write the blocks.': 'AI 无法撰写内容块。', + 'There is nothing for AI to write from yet. Capture some steps first.': 'AI 还没有可依据的内容。请先捕获一些步骤。', + 'Then it writes the guide’s title and description. Text you already wrote is polished, not replaced with something new.': '然后它会撰写指南的标题和描述。你已写好的文本会被润色,而不会被替换。', + 'A snapshot is saved first, so you can undo this from More → Backups & snapshots.': '会先保存一个快照,因此你可以通过“更多 → 备份和快照”撤消此操作。', + 'before AI': 'AI 撰写前', + 'Could not save a snapshot first, so AI did not start: {error}': '无法先保存快照,因此 AI 没有开始:{error}', + 'AI could not write a step.': 'AI 无法撰写某个步骤。', + 'AI could not organize the guide.': 'AI 无法整理指南。', + 'AI could not write the guide title.': 'AI 无法撰写指南标题。', + 'Added {placeholders}.': '已添加 {placeholders}。', + 'Steps': '步骤', + 'Add': '添加', + 'Import': '导入', + 'Select steps; Shift-click to select a range': '选择步骤;按住 Shift 单击可选择范围', + 'Move step up': '上移步骤', + 'Move step down': '下移步骤', + 'Fit': '适应', + 'Undo': '撤消', + 'Redo': '重做', + 'Select an image step to edit annotations.': '请选择一个带图片的步骤来编辑标注。', + 'Step title': '步骤标题', + 'Todo': '待办', + 'In progress': '进行中', + 'Zoom': '缩放', + 'Pan X': '平移 X', + 'Pan Y': '平移 Y', + 'Exports crop to this view; the original image is never modified.': '导出时会裁剪为此视图;原始图片永远不会被修改。', + 'Annotations': '标注', + 'Blocks': '内容块', + '+ Text block': '+ 文本块', + '+ Code': '+ 代码', + '+ Table': '+ 表格', + 'Save changes. For guides linked to a shared archive, also writes the archive file.': '保存更改。对于链接到共享存档的指南,还会写入存档文件。', + 'Snapshot': '快照', + 'Untitled step': '未命名步骤', + 'Select a step first.': '请先选择一个步骤。', + 'Select a step to add blocks.': '选择一个步骤以添加内容块。', + 'Remove block': '删除内容块', + 'Move block up': '上移内容块', + 'Move block down': '下移内容块', + 'Before title': '标题之前', + 'After title': '标题之后', + 'Before image': '图片之前', + 'After image': '图片之后', + 'Before description': '描述之前', + 'After description': '描述之后', + 'Note': '注意', + 'Warning': '警告', + 'Important': '重要', + 'Tip': '提示', + 'Block title': '块标题', + 'Block text': '块文本', + 'Language (e.g. bash)': '语言(例如 bash)', + 'Code': '代码', + 'One row per line, cells separated by |': '每行一行表格,单元格用 | 分隔', + 'First line is the header row.': '第一行是表头行。', + 'Informational text, code, and table blocks can be reordered with drag handles or arrows.': '文本块、代码块和表格块可以通过拖动手柄或箭头重新排序。', + 'Add substep': '添加子步骤', + 'Make substep of…': '设为以下步骤的子步骤…', + 'Duplicate step': '复制步骤', + 'Choose image…': '选择图片…', + 'Retake screenshot': '重新截图', + 'Full screen': '全屏', + 'Region…': '区域…', + 'Move up': '上移', + 'Move down': '下移', + 'Move to…': '移到…', + 'Delete step': '删除步骤', + 'Select step {step}': '选择步骤 {step}', + 'No steps yet.': '还没有步骤。', + 'Add the copied annotation at the same position on each selected step': '将复制的标注添加到每个所选步骤的相同位置', + 'linked': '已链接', + 'local': '本地', + 'No step selected.': '未选择步骤。', + 'Select a step to edit annotations.': '选择一个步骤以编辑标注。', + 'No annotations yet. Pick a tool and drag on the canvas.': '还没有标注。选择一个工具并在画布上拖动。', + 'Private detail: {kind}': '隐私信息:{kind}', + 'added by StepForge': '由 StepForge 添加', + 'Select an annotation to edit its style.': '选择一个标注以编辑其样式。', + 'Bottom': '底部', + 'Top': '顶部', + 'Left': '左侧', + 'Right': '右侧', + 'Color': '颜色', + 'Stroke': '描边', + 'Annotation text': '标注文本', + 'Type': '类型', + 'Fill': '填充', + 'Stroke width': '描边宽度', + 'Font size': '字号', + 'Text color': '文本颜色', + 'Radius': '圆角', + 'Tail': '尾部', + 'Copy this style to every other “{type}” annotation:': '将此样式复制到其他所有“{type}”标注:', + 'Overwrite the style of every “{type}” annotation on this step with the style shown above.': '用上方显示的样式覆盖此步骤中所有“{type}”标注的样式。', + 'This step': '此步骤', + 'Overwrite the style of every “{type}” annotation across all steps in this guide with the style shown above.': '用上方显示的样式覆盖此指南所有步骤中“{type}”标注的样式。', + 'Entire guide': '整个指南', + 'Could not undo the move: {error}': '无法撤消移动:{error}', + 'Undo anyway': '仍然撤消', + 'Could not undo every step: {error}': '无法撤消所有步骤:{error}', + 'Nothing to undo.': '没有可撤消的操作。', + 'Nothing to redo.': '没有可重做的操作。', + 'Save failed': '保存失败', + 'Could not save this step — your changes are kept. Retrying…': '无法保存此步骤——你的更改已保留。正在重试…', + 'Could not save guide details — your changes are kept. Retrying…': '无法保存指南详细信息——你的更改已保留。正在重试…', + 'Saved and synced to linked archive.': '已保存并同步到链接的存档。', + 'Saved locally, but the linked archive is locked by another session.': '已在本地保存,但链接的存档被另一个会话锁定。', + 'Saved.': '已保存。', + 'Merged changes from another session.': '已合并来自另一个会话的更改。', + 'Snapshot name': '快照名称', + 'Snapshot created.': '已创建快照。', + 'Add Step': '添加步骤', + 'Step added.': '已添加步骤。', + 'Add Substep': '添加子步骤', + 'Substep title': '子步骤标题', + 'Untitled substep': '未命名子步骤', + 'Substep added.': '已添加子步骤。', + 'A step cannot be made a substep of itself or one of its own substeps.': '步骤不能成为其自身或其子步骤的子步骤。', + '“{title}” is already a substep of step {number}.': '“{title}”已经是步骤 {number} 的子步骤。', + '“{title}” is now a substep of step {number}.': '“{title}”现在是步骤 {number} 的子步骤。', + 'Untitled step copy': '未命名步骤副本', + 'Step duplicated.': '已复制步骤。', + 'Stop the recording session before retaking a screenshot.': '请先停止录制会话,再重新截图。', + 'Screenshot retaken.': '已重新截图。', + 'Screenshot replaced.': '已替换截图。', + 'Private details will be checked again before publishing.': '发布前会再次检查隐私信息。', + 'Its annotations were kept, so check they still line up.': '已保留其标注,请检查它们是否仍然对齐。', + 'Press Ctrl+Z to undo.': '按 Ctrl+Z 撤消。', + 'Step deleted. Press Ctrl+Z to undo.': '已删除步骤。按 Ctrl+Z 撤消。', + 'Could not move the step: {error}': '无法移动步骤:{error}', + 'Top level': '顶层', + 'At the top': '最前面', + 'Move “{title}”': '移动“{title}”', + 'Its substeps move with it.': '其子步骤会一起移动。', + 'The guide changed while you were choosing, so the step was not moved. Try Move to… again.': '在你选择时指南发生了变化,因此步骤未移动。请再次尝试“移到…”。', + 'Step moved. Press Ctrl+Z to undo.': '已移动步骤。按 Ctrl+Z 撤消。', + 'A step can’t be dropped inside its own substeps.': '步骤不能放入其自身的子步骤中。', + 'Images imported.': '已导入图片。', + 'Captured.': '已捕获。', + 'Capture full screen': '捕获全屏', + 'Capture window': '捕获窗口', + 'Capture region…': '捕获区域…', + 'Paste image as step': '将图片粘贴为步骤', + 'Import images…': '导入图片…', + 'Start capture session (hotkey)': '开始捕获会话(快捷键)', + 'Image pasted as a new step.': '已将图片粘贴为新步骤。', + 'Clipboard has no image.': '剪贴板中没有图片。', + 'Shared to {path}': '已分享到 {path}', + 'Restore “{name}”? Current state is snapshotted first, so this is undoable.': '恢复“{name}”?会先为当前状态创建快照,因此可以撤消。', + 'Snapshot restored.': '已恢复快照。', + 'Could not compare with this snapshot: {error}': '无法与此快照比较:{error}', + 'Guide placeholders': '指南占位符', + 'Use [[Name]] in titles, descriptions, and blocks. Guide values override global ones.': '在标题、描述和内容块中使用 [[名称]]。指南中的值会覆盖全局值。', + 'Placeholders saved.': '已保存占位符。', + 'Guide information saved.': '已保存指南信息。', + 'Restore this cloud snapshot? Your current local guide is kept as a cloud snapshot first.': '恢复此云快照?你当前的本地指南会先保存为云快照。', + 'Cloud snapshot restored.': '已恢复云快照。', + 'Remove every cloud snapshot for this guide? This also turns off cloud sync for the guide. The local guide is kept.': '删除此指南的所有云快照?这也会关闭此指南的云同步。本地指南会保留。', + 'Cloud copies removed.': '已删除云副本。', + 'Style applied to all “{type}” annotations in this step.': '已将样式应用于此步骤中的所有“{type}”标注。', + 'Style applied to all “{type}” annotations in the guide.': '已将样式应用于指南中的所有“{type}”标注。', + 'Capture paused.': '已暂停捕获。', + 'Capture resumed.': '已继续捕获。', + 'Imported template for {format}.': '已导入 {format} 的模板。', + 'Template shared as .sfglt.': '已将模板分享为 .sfglt。', + 'Preview opened (first steps only).': '已打开预览(仅前几个步骤)。', + 'This guide': '此指南', + 'Linked archive saved.': '已保存链接的存档。', + 'Could not save linked archive.': '无法保存链接的存档。', + 'Linked archive force-saved.': '已强制保存链接的存档。', + 'Export the current guide': '导出当前指南', + 'Linked guide': '链接的指南', + 'Show linked archive details': '显示链接的存档详细信息', + 'Start capture session': '开始捕获会话', + 'Enable hotkey capture for this guide': '为此指南启用快捷键捕获', + 'Could not save every step: {error}': '无法保存所有步骤:{error}', + '{count} skipped.': '已跳过 {count} 个。', + 'Nothing was replaced.': '没有替换任何内容。', + 'No steps changed.': '没有步骤发生更改。', + 'Stopped early: {error}': '提前停止:{error}', + 'Crop the working image to the selected area?': '将工作图片裁剪为所选区域?', + 'Image cropped.': '已裁剪图片。', + 'Edit tooltip': '编辑提示框', + 'Edit text': '编辑文本', + 'Annotation copied.': '已复制标注。', + 'AI will write the title, description and blocks for {count} steps, make substeps where steps belong together, and add placeholders for values that repeat.': { other: 'AI 将为 {count} 个步骤撰写标题、描述和内容块,在步骤相关时创建子步骤,并为重复的值添加占位符。' }, + 'Made {count} substeps.': { other: '已创建 {count} 个子步骤。' }, + 'Stopped. AI wrote {written} of {count} steps.': { other: '已停止。AI 已撰写 {count} 个步骤中的 {written} 个。' }, + 'AI wrote {written} of {count} steps.': { other: 'AI 已撰写 {count} 个步骤中的 {written} 个。' }, + 'AI wrote the whole guide ({count} steps).': { other: 'AI 已撰写整个指南({count} 个步骤)。' }, + '{count} steps': { other: '{count} 个步骤' }, + 'Paste annotation to {count} steps': { other: '将标注粘贴到 {count} 个步骤' }, + '{count} steps were changed since this edit, perhaps by AI or another app. Undo anyway and overwrite those changes?': { other: '自此编辑以来已有 {count} 个步骤被更改,可能是 AI 或其他应用所为。仍要撤消并覆盖这些更改吗?' }, + '{count} conflicts were settled in favor of the most recent edit.': { other: '已按最近的编辑解决 {count} 个冲突。' }, + 'Replaced {applied} in {count} steps.': { other: '已在 {count} 个步骤中替换 {applied} 处。' }, + 'Annotation pasted onto {count} steps.': { other: '已将标注粘贴到 {count} 个步骤。' }, + 'Skipped {count} steps (already there, no image, or a different image shape): {steps}.': { other: '已跳过 {count} 个步骤(已存在、没有图片或图片形状不同):{steps}。' }, + 'Close (Esc)': '关闭 (Esc)', + 'Confirm': '确认', + 'Nothing else to choose': '没有其他可选项', + '{count} years': { other: '{count} 年' }, + '{count} months': { other: '{count} 个月' }, + 'Guide library': '指南库', + 'Cloud sync status': '云同步状态', + 'Search guides… (Ctrl+/ for quick actions)': '搜索指南… (Ctrl+/ 打开快速操作)', + 'Beta': '测试版', }); diff --git a/tests/unit/i18n.test.js b/tests/unit/i18n.test.js new file mode 100644 index 00000000..c63f72db --- /dev/null +++ b/tests/unit/i18n.test.js @@ -0,0 +1,112 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { LANGUAGES, createTranslator, matchLocale, resolveLanguage } = require('../../core/i18n'); +const LOCALES = require('../../core/locales'); +const { DEFAULT_SETTINGS } = require('../../core/settings'); + +const placeholders = (text) => [...String(text).matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort(); + +test('the six interface languages, English first, each with a Store language tag', () => { + assert.deepEqual(LANGUAGES.map((l) => l.code), ['en', 'fr', 'es', 'ru', 'zh-CN', 'ja']); + assert.deepEqual(LANGUAGES.map((l) => l.store), ['en-US', 'fr-FR', 'es-ES', 'ru-RU', 'zh-CN', 'ja-JP']); + assert.deepEqual(Object.keys(LOCALES).sort(), LANGUAGES.slice(1).map((l) => l.code).sort()); + assert.equal(DEFAULT_SETTINGS.language, 'system'); +}); + +test('system locales map to the closest language StepForge has', () => { + assert.equal(matchLocale('fr-CA'), 'fr'); + assert.equal(matchLocale('es_MX'), 'es'); + assert.equal(matchLocale('ru'), 'ru'); + assert.equal(matchLocale('zh-CN'), 'zh-CN'); + assert.equal(matchLocale('zh-Hans-SG'), 'zh-CN'); + assert.equal(matchLocale('zh-TW'), null, 'Traditional Chinese is not translated'); + assert.equal(matchLocale('zh-Hant-HK'), null); + assert.equal(matchLocale('ja-JP'), 'ja'); + assert.equal(matchLocale('de-DE'), null); + assert.equal(matchLocale(''), null); +}); + +test('a chosen language wins; System default takes the first supported OS language, else English', () => { + assert.equal(resolveLanguage('ja', ['fr-FR']), 'ja'); + assert.equal(resolveLanguage('system', ['de-DE', 'es-ES', 'fr-FR']), 'es'); + assert.equal(resolveLanguage('system', ['de-DE']), 'en'); + assert.equal(resolveLanguage('system', []), 'en'); + assert.equal(resolveLanguage('klingon', ['ru-RU']), 'ru', 'an unknown setting acts like System default'); +}); + +test('text is translated with its placeholders filled, and falls back to English', () => { + const { t, language } = createTranslator('fr', LOCALES); + assert.equal(language, 'fr'); + assert.equal(t('Settings'), 'Paramètres'); + assert.equal(t('Move to {folder}', { folder: 'Docs' }), 'Déplacer vers Docs'); + assert.equal(t('Text no catalog has yet'), 'Text no catalog has yet'); + assert.equal(t('Hello {name}', { name: 'Ana' }), 'Hello Ana'); + assert.equal(t('Hello {name}'), 'Hello {name}', 'a missing value leaves the placeholder'); + const english = createTranslator('en', LOCALES); + assert.equal(english.t('Settings'), 'Settings'); + assert.equal(createTranslator('xx', LOCALES).language, 'en'); +}); + +test('counted text uses each language’s plural forms', () => { + const en = createTranslator('en', LOCALES); + assert.equal(en.tn(1, '{count} guide', '{count} guides'), '1 guide'); + assert.equal(en.tn(3, '{count} guide', '{count} guides'), '3 guides'); + const ru = createTranslator('ru', LOCALES); + assert.equal(ru.tn(1, '{count} step', '{count} steps'), '1 шаг'); + assert.equal(ru.tn(3, '{count} step', '{count} steps'), '3 шага'); + assert.equal(ru.tn(5, '{count} step', '{count} steps'), '5 шагов'); + assert.equal(ru.tn(21, '{count} step', '{count} steps'), '21 шаг'); + const fr = createTranslator('fr', LOCALES); + assert.equal(fr.tn(1, '{count} step', '{count} steps'), '1 étape'); + assert.equal(fr.tn(2, '{count} step', '{count} steps'), '2 étapes'); + assert.equal(createTranslator('ja', LOCALES).tn(2, '{count} step', '{count} steps'), '2 ステップ'); + assert.equal(fr.tn(2, 'Uncounted {count} thing', 'Uncounted {count} things'), 'Uncounted 2 things'); +}); + +test('every catalog has the same text, with the same placeholders as the English', () => { + const keys = Object.keys(LOCALES.fr).sort(); + for (const [code, catalog] of Object.entries(LOCALES)) { + assert.deepEqual(Object.keys(catalog).sort(), keys, `${code} has the same keys as fr`); + const rules = new Intl.PluralRules(code); + for (const [key, value] of Object.entries(catalog)) { + if (typeof value === 'string') { + assert.ok(value.trim(), `${code}: ${key} is empty`); + assert.deepEqual(placeholders(value), placeholders(key), `${code}: ${key}`); + continue; + } + assert.ok(value.other, `${code}: ${key} has an "other" form`); + for (const n of [0, 1, 2, 3, 5, 11, 21, 22, 25, 100]) { + assert.ok(value[rules.select(n)] || value.other, `${code}: ${key} has a form for ${n}`); + } + for (const form of Object.values(value)) { + const want = placeholders(key).filter((name) => name !== 'count'); + assert.deepEqual(placeholders(form).filter((name) => name !== 'count'), want, `${code}: ${key} → ${form}`); + } + } + } +}); + +test('the renderer shows the language index.html was loaded with', () => { + const load = (search) => { + const context = vm.createContext({ URLSearchParams, location: { search } }); + context.window = context; // as in the page, where the script tags attach to window + for (const file of ['core/i18n.js', 'core/locales/fr.js', 'core/locales/ja.js', 'app/renderer/util.js']) { + vm.runInContext(fs.readFileSync(path.join(__dirname, '../..', file), 'utf8'), context); + } + return vm.runInContext('({ settings: t("Settings"), language: uiLanguage, dateLocale, guides: tn(2, "{count} guide", "{count} guides") })', context); + }; + const fr = load('?lang=fr'); + assert.equal(fr.settings, 'Paramètres'); + assert.equal(fr.language, 'fr'); + assert.equal(fr.dateLocale, 'fr'); + assert.equal(fr.guides, '2 guides'); + assert.equal(load('?lang=ja').settings, '設定'); + const en = load(''); + assert.equal(en.settings, 'Settings'); + assert.equal(en.dateLocale, undefined, 'English dates keep the system’s format'); +}); From 9565e0dfb899d85c2261c179e3986c20fcdb40be Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 10:33:05 -0500 Subject: [PATCH 3/7] Document the Language setting, translating new text and the Store listing languages --- README.md | 4 ++++ docs/ARCHITECTURE.md | 3 ++- docs/CONTRIBUTING.md | 25 +++++++++++++++++++++++++ docs/GETTING_STARTED.md | 6 ++++++ docs/windows/MICROSOFT_STORE_RELEASE.md | 10 ++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f46caf5..fdbd526f 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,10 @@ was recorded with StepForge. - **Publish to Confluence** (beta) on Confluence Cloud or your organization's Data Center site, including sites that need a smart card (CAC). - **Publish to Wiki.js** (beta), with the screenshots uploaded to the wiki. +- **Six languages.** English, plus French, Spanish, Russian, Simplified + Chinese and Japanese (beta). StepForge follows your system language, or + pick one in **Settings → General → Language**. Exported guides stay in + English. **Optional, and off until you turn them on** diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b14557b7..e88d7416 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,7 +13,8 @@ app/ Electron shell: main process, preload bridge, renderer UI, capture, Google Drive client, AI (Ollama) client core/ Dependency-free domain logic (schema, store, archive, search, placeholders, render AST, png/gif/pdf/zip primitives, locks, - snapshots, settings, cloud sync engine) + snapshots, settings, cloud sync engine, interface + languages: i18n.js and the translations in locales/) exporters/ One module per output format, all consuming the Render AST gnome-extension/ GNOME Shell extension used for click capture on Wayland packaging/ Windows (NSIS, Chocolatey) and Linux (deb, rpm, Launchpad) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 2d62b5a3..47711c66 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -159,6 +159,30 @@ runtime files but can be larger. Normal RPM builds retain the distro's compression defaults. A small real-RPM fixture checks both compression paths on every Fedora PR run. +## Interface text and translations + +StepForge's interface is in English, French, Spanish, Russian, Simplified +Chinese and Japanese. `core/i18n.js` explains how it works. In short: + +- Write interface text in English, inside `t('…')`, and put values in + placeholders: `t('Move to {folder}', { folder: name })`. Text that changes + with a number uses `tn(count, '{count} step', '{count} steps')`. The main + process uses `t` for its own dialogs. +- Keep each message one whole sentence in one string literal. Don't join + fragments or build text at run time: word order differs between languages, + and `npm run translations` can only find literals. +- `npm run translations` lists the text each language in `core/locales/` is + missing. Missing text shows in English, so the app still works, but add the + translations before a release. Plural entries give one string per plural + category of the language (`one`/`other`, and `few`/`many` for Russian). +- Product names (StepForge, GitHub, Confluence, Wiki.js, Ollama), key names + and file extensions stay as they are. Exported guides are English only. + +To add a language, add it to `LANGUAGES` in `core/i18n.js`, add its catalog +to `core/locales/` and `core/locales/index.js`, and load it with a script tag +in `app/renderer/index.html`. The Microsoft Store package lists every language +in `LANGUAGES` (see `docs/windows/MICROSOFT_STORE_RELEASE.md`). + ## Build installable packages | Target | Command | Output | @@ -269,6 +293,7 @@ Before requesting review, make sure: - [ ] `bash tests/run_test.sh` passes locally. - [ ] New behavior has tests that check real output. - [ ] Docs are updated for anything users will notice. +- [ ] New interface text is in `t()`, and `npm run translations` lists nothing missing. - [ ] The change stays within the issue's scope. - [ ] Anything that still needs manual verification is listed in the PR. - [ ] No new network calls, dependencies, or third-party branding. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index ec9e908c..8f0bdcad 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -209,6 +209,12 @@ The library is your home screen. Each one shows how long it has left. To keep them longer, or until you empty the trash yourself, use **Settings → General → Trash**. +StepForge shows its menus and messages in your system's language when it has +it: English, French, Spanish, Russian, Simplified Chinese or Japanese. The +translations other than English are in beta. To pick a different language, +use **Settings → General → Language**; StepForge reloads its window in the new +language, after saving your open guide. Exported guides stay in English. + Guides live in your StepForge data folder. To keep them somewhere else, such as a larger drive, use **Settings → General → Guide storage**. StepForge copies and verifies your library before switching to the new location. diff --git a/docs/windows/MICROSOFT_STORE_RELEASE.md b/docs/windows/MICROSOFT_STORE_RELEASE.md index 709e5b01..19bc3f70 100644 --- a/docs/windows/MICROSOFT_STORE_RELEASE.md +++ b/docs/windows/MICROSOFT_STORE_RELEASE.md @@ -128,6 +128,16 @@ their own guides to the web (GitHub Pages) and to Confluence or Wiki.js. - Short description, for example: "Record clicks as step-by-step guides with screenshots, then edit and export them to PDF, Word, HTML and more." +**Other listing languages.** The package declares every interface language +(`LANGUAGES` in `core/i18n.js`: en-US, fr-FR, es-ES, ru-RU, zh-CN and ja-JP), +so the Store shows StepForge as available in them, and Partner Center adds +them to the app's overview once the package is uploaded. Only one listing is +required. A language without its own listing shows the English one, so you can +submit with English only and add translated descriptions and screenshots later +under **Store listings → Manage additional languages**. Remove a language +there only if you don't want a listing for it; the app keeps the language +either way. + **Submission options → Restricted capabilities.** The package declares `runFullTrust`, as every desktop (Electron) app does. Give this reason: From 205b59cbee690b8c89016d82a18f0fa633e3127c Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 10:51:26 -0500 Subject: [PATCH 4/7] Fix AI success messages and editor language switching --- app/renderer/editor.js | 22 +++++++++++++++-- core/locales/es.js | 4 ++++ core/locales/fr.js | 4 ++++ core/locales/ja.js | 4 ++++ core/locales/ru.js | 4 ++++ core/locales/zh-CN.js | 4 ++++ tests/unit/ai-visibility.test.js | 41 ++++++++++++++++++++++++++++++++ 7 files changed, 81 insertions(+), 2 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index ea44dcb6..035aa8da 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -348,6 +348,10 @@ class GuideEditor { step: t('AI could not write this step.'), title: t('AI could not write the title.'), description: t('AI could not write the description.'), blocks: t('AI could not write the blocks.'), }[target]; + const success = { + step: t('AI wrote this step.'), title: t('AI wrote the title.'), + description: t('AI wrote the description.'), blocks: t('AI wrote the blocks.'), + }[target]; const run = { cancelled: false, progress: '' }; this.aiRun = run; this.emitMeta(); @@ -361,9 +365,9 @@ class GuideEditor { } if (this.applyAiStep(result.step)) { this.pushCanvasHistory(this.multiStepRecord([before], [result.step])); - this.onToast(`AI wrote ${what}. Press Ctrl+Z to undo.`); + this.onToast(`${success} ${t('Press Ctrl+Z to undo.')}`); } else { - this.onToast(`AI wrote ${what}.`); + this.onToast(success); } } catch (err) { if (!run.cancelled) this.onToast(err.message || failure, { error: true }); @@ -2661,6 +2665,7 @@ class GuideEditor { async openSettings(section) { const settings = await api.settings.all(); const placeholders = await api.settings.globalPlaceholders(); + let newLanguage = null; await dialogs.showSettingsDialog({ api, settings, @@ -2686,8 +2691,21 @@ class GuideEditor { await api.settings.set({ keyPath: 'branding', value: next.branding }); await api.settings.set({ keyPath: 'backups', value: next.backups }); await api.settings.setGlobalPlaceholders(next.placeholders || {}); + if (next.language !== settings.language) newLanguage = next.language; }, }); + // The quick-actions and AI menus open Settings here too. A language + // reload must wait for the guide to be saved, just as in the app toolbar. + if (newLanguage) { + try { + if (this.pendingSave || this.pendingGuideSave) await this.saveAll(); + } catch { + return; + } + if (this.pendingSave || this.pendingGuideSave) return; + const result = await api.app.setLanguage({ language: newLanguage }); + if (result.reloaded) return; + } this.setSettings(await api.settings.all()); } diff --git a/core/locales/es.js b/core/locales/es.js index 4527052a..2b6a39dd 100644 --- a/core/locales/es.js +++ b/core/locales/es.js @@ -5,6 +5,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = messages; if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['es'] = messages; })(typeof window !== 'undefined' ? window : null, { + "AI wrote this step.": "La IA ha redactado este paso.", + "AI wrote the title.": "La IA ha redactado el título.", + "AI wrote the description.": "La IA ha redactado la descripción.", + "AI wrote the blocks.": "La IA ha redactado los bloques.", 'Import images as steps': 'Importar imágenes como pasos', 'Choose a screenshot for this step': 'Elegir una captura de pantalla para este paso', 'Choose guide storage folder': 'Elegir la carpeta de almacenamiento de guías', diff --git a/core/locales/fr.js b/core/locales/fr.js index 4f8c1253..e8aa39dd 100644 --- a/core/locales/fr.js +++ b/core/locales/fr.js @@ -5,6 +5,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = messages; if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['fr'] = messages; })(typeof window !== 'undefined' ? window : null, { + "AI wrote this step.": "L’IA a rédigé cette étape.", + "AI wrote the title.": "L’IA a rédigé le titre.", + "AI wrote the description.": "L’IA a rédigé la description.", + "AI wrote the blocks.": "L’IA a rédigé les blocs.", 'Import images as steps': 'Importer des images comme étapes', 'Choose a screenshot for this step': 'Choisir une capture d’écran pour cette étape', 'Choose guide storage folder': 'Choisir le dossier de stockage des guides', diff --git a/core/locales/ja.js b/core/locales/ja.js index 96012e47..266028e5 100644 --- a/core/locales/ja.js +++ b/core/locales/ja.js @@ -5,6 +5,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = messages; if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['ja'] = messages; })(typeof window !== 'undefined' ? window : null, { + "AI wrote this step.": "AI がこのステップを作成しました。", + "AI wrote the title.": "AI がタイトルを作成しました。", + "AI wrote the description.": "AI が説明を作成しました。", + "AI wrote the blocks.": "AI がブロックを作成しました。", 'Import images as steps': '画像をステップとして読み込む', 'Choose a screenshot for this step': 'このステップのスクリーンショットを選択', 'Choose guide storage folder': 'ガイドの保存フォルダーを選択', diff --git a/core/locales/ru.js b/core/locales/ru.js index dbe9cb52..76d53bcc 100644 --- a/core/locales/ru.js +++ b/core/locales/ru.js @@ -5,6 +5,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = messages; if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['ru'] = messages; })(typeof window !== 'undefined' ? window : null, { + "AI wrote this step.": "ИИ написал этот шаг.", + "AI wrote the title.": "ИИ написал заголовок.", + "AI wrote the description.": "ИИ написал описание.", + "AI wrote the blocks.": "ИИ написал блоки.", 'Import images as steps': 'Импортировать изображения как шаги', 'Choose a screenshot for this step': 'Выберите снимок экрана для этого шага', 'Choose guide storage folder': 'Выберите папку для хранения руководств', diff --git a/core/locales/zh-CN.js b/core/locales/zh-CN.js index 56dc41c2..74c6dc52 100644 --- a/core/locales/zh-CN.js +++ b/core/locales/zh-CN.js @@ -5,6 +5,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = messages; if (root) (root.StepForgeLocales = root.StepForgeLocales || {})['zh-CN'] = messages; })(typeof window !== 'undefined' ? window : null, { + "AI wrote this step.": "AI 已编写此步骤。", + "AI wrote the title.": "AI 已编写标题。", + "AI wrote the description.": "AI 已编写说明。", + "AI wrote the blocks.": "AI 已编写内容块。", 'Import images as steps': '将图片导入为步骤', 'Choose a screenshot for this step': '为此步骤选择截图', 'Choose guide storage folder': '选择指南存储文件夹', diff --git a/tests/unit/ai-visibility.test.js b/tests/unit/ai-visibility.test.js index 35a7ef30..81bd5af7 100644 --- a/tests/unit/ai-visibility.test.js +++ b/tests/unit/ai-visibility.test.js @@ -110,3 +110,44 @@ test('AI ▾ sits right before More ▾, asks the editor for its menu, and shows assert.ok(busy, 'expected the AI button to show progress'); assert.equal(busy.className, 'ai-busy'); }); + +test('successful AI edits report success for every target and retain undo', async () => { + for (const target of ['step', 'title', 'description', 'blocks']) { + for (const applied of [true, false]) { + const notices = []; + const { editor } = editorContext({ api: { ai: { fillStep: async () => ({ ok: true, step: { stepId: 's1' } }) } } }); + editor.setSettings({ ai: { enabled: true } }); + editor.emitMeta = () => {}; + editor.applyAiStep = () => applied; + editor.multiStepRecord = () => ({ type: 'steps' }); + let undo = 0; + editor.pushCanvasHistory = () => { undo++; }; + editor.onToast = (message, options) => notices.push({ message, options }); + await editor.writeStepWithAi(target); + assert.equal(notices.length, 1); + assert.match(notices[0].message, /^AI wrote /); + assert.equal(notices[0].options?.error, undefined); + assert.equal(notices[0].message.includes('Ctrl+Z'), applied); + assert.equal(undo, applied ? 1 : 0); + assert.equal(editor.aiRun, null); + } + } +}); + +test('Settings opened from the editor saves a language choice after flushing edits', async () => { + for (const saveFails of [false, true]) { + const events = []; + const settings = { language: 'en', ai: { enabled: true } }; + const { editor } = editorContext({ + api: { + settings: { all: async () => settings, globalPlaceholders: async () => ({}), set: async () => {}, setGlobalPlaceholders: async () => {} }, + app: { setLanguage: async ({ language }) => { events.push(language); return { reloaded: true }; } }, + }, + dialogs: { showSettingsDialog: async ({ onSave }) => onSave({ ...settings, language: 'fr' }) }, + }); + editor.pendingSave = true; + editor.saveAll = async () => { events.push('save'); editor.pendingSave = saveFails; }; + await editor.openSettings(); + assert.deepEqual(events, saveFails ? ['save'] : ['save', 'fr']); + } +}); From a9e9486312207a55f8954193f1a68646d89abd27 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 10:56:02 -0500 Subject: [PATCH 5/7] Translate expanded agent permissions for combined features --- core/locales/es.js | 1 + core/locales/fr.js | 1 + core/locales/ja.js | 1 + core/locales/ru.js | 1 + core/locales/zh-CN.js | 1 + 5 files changed, 5 insertions(+) diff --git a/core/locales/es.js b/core/locales/es.js index 2b6a39dd..42abafd3 100644 --- a/core/locales/es.js +++ b/core/locales/es.js @@ -349,6 +349,7 @@ 'AI guide': 'Guía de IA', 'Recommended models': 'Modelos recomendados', 'AI agents (Claude, Codex)': 'Agentes de IA (Claude, Codex)', + "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before structural or destructive changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Las aplicaciones de IA como Claude y Codex pueden conectarse a StepForge y editar tus guías. Pueden crear y eliminar guías y pasos, reordenar pasos, reemplazar o eliminar capturas de pantalla, editar texto, añadir marcadores de posición y añadir o eliminar anotaciones, excepto los desenfoques. StepForge guarda instantáneas antes de los cambios estructurales o destructivos; restáuralas desde Más → Copias de seguridad e instantáneas. Las guías eliminadas van a la papelera.", 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Aplicaciones de IA como Claude y Codex pueden conectarse a StepForge y escribir tus guías por ti. Pueden reescribir texto, crear subpasos, añadir marcadores de posición y dibujar en las capturas, pero no pueden reordenar pasos ni eliminar nada.', 'Let AI agents edit your guides': 'Permitir que los agentes de IA editen tus guías', 'Turn off to cut off every connected agent at once.': 'Desactívalo para desconectar a la vez a todos los agentes conectados.', diff --git a/core/locales/fr.js b/core/locales/fr.js index e8aa39dd..f0984d84 100644 --- a/core/locales/fr.js +++ b/core/locales/fr.js @@ -349,6 +349,7 @@ 'AI guide': 'Guide de l’IA', 'Recommended models': 'Modèles recommandés', 'AI agents (Claude, Codex)': 'Agents IA (Claude, Codex)', + "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before structural or destructive changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Les applications d’IA comme Claude et Codex peuvent se connecter à StepForge et modifier vos guides. Elles peuvent créer et supprimer des guides et des étapes, réorganiser les étapes, remplacer ou supprimer des captures d’écran, modifier le texte, ajouter des espaces réservés et ajouter ou supprimer des annotations, sauf les flous. StepForge enregistre des instantanés avant les modifications structurelles ou destructives ; restaurez-les depuis Plus → Sauvegardes et instantanés. Les guides supprimés vont dans la corbeille.", 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Les applications d’IA comme Claude et Codex peuvent se connecter à StepForge et rédiger vos guides pour vous. Elles peuvent réécrire du texte, créer des sous-étapes, ajouter des espaces réservés et dessiner sur les captures, mais ne peuvent ni réorganiser les étapes ni rien supprimer.', 'Let AI agents edit your guides': 'Autoriser les agents IA à modifier vos guides', 'Turn off to cut off every connected agent at once.': 'Désactivez pour couper d’un coup tous les agents connectés.', diff --git a/core/locales/ja.js b/core/locales/ja.js index 266028e5..07e9df9d 100644 --- a/core/locales/ja.js +++ b/core/locales/ja.js @@ -349,6 +349,7 @@ 'AI guide': 'AI ガイド', 'Recommended models': 'おすすめのモデル', 'AI agents (Claude, Codex)': 'AI エージェント(Claude、Codex)', + "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before structural or destructive changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Claude や Codex などの AI アプリは StepForge に接続してガイドを編集できます。ガイドやステップの作成と削除、ステップの並べ替え、スクリーンショットの差し替えと削除、テキストの編集、プレースホルダーの追加、ぼかし以外の注釈の追加と削除ができます。StepForge は構成の変更や削除などの操作前にスナップショットを保存します。「その他 → バックアップとスナップショット」から復元できます。削除されたガイドはゴミ箱に移動します。", 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Claude や Codex などの AI アプリは StepForge に接続して、ガイドを代わりに作成できます。テキストの書き直し、サブステップの作成、プレースホルダーの追加、スクリーンショットへの描画はできますが、ステップの並べ替えや削除はできません。', 'Let AI agents edit your guides': 'AI エージェントにガイドの編集を許可', 'Turn off to cut off every connected agent at once.': 'オフにすると、接続しているすべてのエージェントを一度に切断します。', diff --git a/core/locales/ru.js b/core/locales/ru.js index 76d53bcc..cff0acee 100644 --- a/core/locales/ru.js +++ b/core/locales/ru.js @@ -349,6 +349,7 @@ 'AI guide': 'Руководство по ИИ', 'Recommended models': 'Рекомендуемые модели', 'AI agents (Claude, Codex)': 'ИИ-агенты (Claude, Codex)', + "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before structural or destructive changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Приложения ИИ, такие как Claude и Codex, могут подключаться к StepForge и редактировать ваши руководства. Они могут создавать и удалять руководства и шаги, менять порядок шагов, заменять или удалять снимки экрана, редактировать текст, добавлять подстановки, а также добавлять и удалять аннотации, кроме размытия. StepForge сохраняет снимки состояния перед структурными изменениями или удалением данных; восстановить их можно через Ещё → Резервные копии и снимки. Удалённые руководства попадают в корзину.", 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'ИИ-приложения, такие как Claude и Codex, могут подключаться к StepForge и писать руководства за вас. Они могут переписывать текст, создавать подшаги, добавлять заполнители и рисовать на снимках экрана, но не могут менять порядок шагов или что-либо удалять.', 'Let AI agents edit your guides': 'Разрешить ИИ-агентам изменять руководства', 'Turn off to cut off every connected agent at once.': 'Отключите, чтобы сразу отключить всех подключённых агентов.', diff --git a/core/locales/zh-CN.js b/core/locales/zh-CN.js index 74c6dc52..1420ce5b 100644 --- a/core/locales/zh-CN.js +++ b/core/locales/zh-CN.js @@ -349,6 +349,7 @@ 'AI guide': 'AI 指南', 'Recommended models': '推荐模型', 'AI agents (Claude, Codex)': 'AI 代理(Claude、Codex)', + "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before structural or destructive changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Claude 和 Codex 等 AI 应用可以连接到 StepForge 并编辑您的指南。它们可以创建和删除指南及步骤、调整步骤顺序、替换或移除截图、编辑文字、添加占位符,以及添加或删除模糊遮盖以外的标注。StepForge 会在更改结构或执行破坏性操作前保存快照;您可以通过“更多 → 备份与快照”恢复。删除的指南会移入回收站。", 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Claude 和 Codex 等 AI 应用可以连接到 StepForge 并为你撰写指南。它们可以改写文本、创建子步骤、添加占位符并在截图上绘制,但不能重新排序步骤或删除任何内容。', 'Let AI agents edit your guides': '允许 AI 代理编辑你的指南', 'Turn off to cut off every connected agent at once.': '关闭后会立即断开所有已连接的代理。', From 7b98068c9a0cfff8f8c32855987abb4701f749ac Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 11:40:18 -0500 Subject: [PATCH 6/7] Keep language selection available when guide saves fail --- app/renderer/app.js | 4 +++ app/renderer/dialogs.js | 2 +- app/renderer/editor.js | 11 ++++++ core/locales/es.js | 1 + core/locales/fr.js | 1 + core/locales/ja.js | 1 + core/locales/ru.js | 1 + core/locales/zh-CN.js | 1 + tests/unit/ai-visibility.test.js | 57 ++++++++++++++++++++++++++++++++ tests/unit/windows-store.test.js | 4 ++- 10 files changed, 81 insertions(+), 2 deletions(-) diff --git a/app/renderer/app.js b/app/renderer/app.js index 1f8453ae..80871f71 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -1198,6 +1198,10 @@ class StepForgeApp { // Update now closes StepForge; save the open guide first. flushEdits: () => this.flushEditorBeforeNavigation(), onSave: async (next) => { + if (next.language !== settings.language && !await this.flushEditorBeforeNavigation()) { + toast(t('Your guide could not be saved. Your language choice is still selected; try Save again.'), { error: true }); + return false; + } await api.settings.set({ keyPath: 'appearance', value: next.appearance }); await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck }); await api.settings.set({ keyPath: 'capture', value: next.capture }); diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index 72e2bb54..e89a0b00 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -1077,7 +1077,7 @@ function showSettingsDialog({ return acc; }, {}), }; - await onSave(next); + if (await onSave(next) === false) return; accountsPanel.dispose(); close(); resolve(true); diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 035aa8da..e8651e36 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -2681,6 +2681,17 @@ class GuideEditor { return !this.pendingSave && !this.pendingGuideSave; }, onSave: async (next) => { + if (next.language !== settings.language) { + let saved = false; + try { + if (this.pendingSave || this.pendingGuideSave) await this.saveAll(); + saved = !this.pendingSave && !this.pendingGuideSave; + } catch { /* Keep the form and its language selection for a retry. */ } + if (!saved) { + this.onToast(t('Your guide could not be saved. Your language choice is still selected; try Save again.'), { error: true }); + return false; + } + } await api.settings.set({ keyPath: 'appearance', value: next.appearance }); await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck }); await api.settings.set({ keyPath: 'capture', value: next.capture }); diff --git a/core/locales/es.js b/core/locales/es.js index 42abafd3..6b0c1416 100644 --- a/core/locales/es.js +++ b/core/locales/es.js @@ -9,6 +9,7 @@ "AI wrote the title.": "La IA ha redactado el título.", "AI wrote the description.": "La IA ha redactado la descripción.", "AI wrote the blocks.": "La IA ha redactado los bloques.", + "Your guide could not be saved. Your language choice is still selected; try Save again.": "No se pudo guardar la guía. El idioma elegido sigue seleccionado; vuelve a pulsar Guardar.", 'Import images as steps': 'Importar imágenes como pasos', 'Choose a screenshot for this step': 'Elegir una captura de pantalla para este paso', 'Choose guide storage folder': 'Elegir la carpeta de almacenamiento de guías', diff --git a/core/locales/fr.js b/core/locales/fr.js index f0984d84..103c3bee 100644 --- a/core/locales/fr.js +++ b/core/locales/fr.js @@ -9,6 +9,7 @@ "AI wrote the title.": "L’IA a rédigé le titre.", "AI wrote the description.": "L’IA a rédigé la description.", "AI wrote the blocks.": "L’IA a rédigé les blocs.", + "Your guide could not be saved. Your language choice is still selected; try Save again.": "Votre guide n’a pas pu être enregistré. La langue choisie reste sélectionnée ; réessayez avec Enregistrer.", 'Import images as steps': 'Importer des images comme étapes', 'Choose a screenshot for this step': 'Choisir une capture d’écran pour cette étape', 'Choose guide storage folder': 'Choisir le dossier de stockage des guides', diff --git a/core/locales/ja.js b/core/locales/ja.js index 07e9df9d..8073ed25 100644 --- a/core/locales/ja.js +++ b/core/locales/ja.js @@ -9,6 +9,7 @@ "AI wrote the title.": "AI がタイトルを作成しました。", "AI wrote the description.": "AI が説明を作成しました。", "AI wrote the blocks.": "AI がブロックを作成しました。", + "Your guide could not be saved. Your language choice is still selected; try Save again.": "ガイドを保存できませんでした。選択した言語はそのままです。もう一度「保存」を試してください。", 'Import images as steps': '画像をステップとして読み込む', 'Choose a screenshot for this step': 'このステップのスクリーンショットを選択', 'Choose guide storage folder': 'ガイドの保存フォルダーを選択', diff --git a/core/locales/ru.js b/core/locales/ru.js index cff0acee..f7f573de 100644 --- a/core/locales/ru.js +++ b/core/locales/ru.js @@ -9,6 +9,7 @@ "AI wrote the title.": "ИИ написал заголовок.", "AI wrote the description.": "ИИ написал описание.", "AI wrote the blocks.": "ИИ написал блоки.", + "Your guide could not be saved. Your language choice is still selected; try Save again.": "Не удалось сохранить руководство. Выбранный язык сохранён в форме; нажмите «Сохранить» ещё раз.", 'Import images as steps': 'Импортировать изображения как шаги', 'Choose a screenshot for this step': 'Выберите снимок экрана для этого шага', 'Choose guide storage folder': 'Выберите папку для хранения руководств', diff --git a/core/locales/zh-CN.js b/core/locales/zh-CN.js index 1420ce5b..812a3efc 100644 --- a/core/locales/zh-CN.js +++ b/core/locales/zh-CN.js @@ -9,6 +9,7 @@ "AI wrote the title.": "AI 已编写标题。", "AI wrote the description.": "AI 已编写说明。", "AI wrote the blocks.": "AI 已编写内容块。", + "Your guide could not be saved. Your language choice is still selected; try Save again.": "无法保存指南。您选择的语言仍处于选中状态;请再次尝试保存。", 'Import images as steps': '将图片导入为步骤', 'Choose a screenshot for this step': '为此步骤选择截图', 'Choose guide storage folder': '选择指南存储文件夹', diff --git a/tests/unit/ai-visibility.test.js b/tests/unit/ai-visibility.test.js index 81bd5af7..8c7094ba 100644 --- a/tests/unit/ai-visibility.test.js +++ b/tests/unit/ai-visibility.test.js @@ -151,3 +151,60 @@ test('Settings opened from the editor saves a language choice after flushing edi assert.deepEqual(events, saveFails ? ['save'] : ['save', 'fr']); } }); + +test('a failed guide save keeps the editor Settings language choice available for retry', async () => { + const notices = []; + const languages = []; + let fail = true, writes = 0; + const settings = { language: 'en', ai: { enabled: true } }; + const { editor } = editorContext({ + api: { + settings: { all: async () => settings, globalPlaceholders: async () => ({}), + set: async () => { writes++; }, setGlobalPlaceholders: async () => {} }, + app: { setLanguage: async ({ language }) => { languages.push(language); return { reloaded: true }; } }, + }, + dialogs: { showSettingsDialog: async ({ onSave }) => { + const choice = { ...settings, language: 'fr' }; + assert.equal(await onSave(choice), false, 'the form must stay open'); + assert.equal(writes, 0, 'do not partly apply Settings before saving the guide'); + assert.deepEqual(languages, []); + assert.match(notices[0], /language choice is still selected/); + fail = false; + await onSave(choice); + } }, + }); + editor.onToast = (message) => notices.push(message); + editor.pendingSave = true; + editor.saveAll = async () => { if (fail) throw new Error('disk busy'); editor.pendingSave = false; }; + await editor.openSettings(); + assert.deepEqual(languages, ['fr']); + assert.ok(writes > 0); +}); + +test('app Settings refuses to close on a failed language-save preflight and accepts retry', async () => { + const settings = { language: 'en' }; + const notices = [], languages = []; + let canSave = false, writes = 0; + const api = { + settings: { all: async () => settings, globalPlaceholders: async () => ({}), + set: async () => { writes++; }, setGlobalPlaceholders: async () => {} }, + app: { setLanguage: async ({ language }) => { languages.push(language); return { reloaded: true }; } }, + }; + const context = { ...rendererI18n(), window: { stepforge: api }, document: { addEventListener() {} }, + toast: (message) => notices.push(message) }; + context.window.StepForgeDialogs = { showSettingsDialog: async ({ onSave }) => { + const next = { language: 'ja' }; + assert.equal(await onSave(next), false); + assert.equal(writes, 0); + assert.match(notices[0], /language choice is still selected/); + canSave = true; + await onSave(next); + } }; + const source = fs.readFileSync(path.join(__dirname, '../../app/renderer/app.js'), 'utf8'); + vm.runInNewContext(source.replace('\nboot();', '\n'), context); + const app = Object.create(context.window.StepForgeApp.prototype); + app.state = {}; + app.flushEditorBeforeNavigation = async () => canSave; + await app.openSettings(); + assert.deepEqual(languages, ['ja']); +}); diff --git a/tests/unit/windows-store.test.js b/tests/unit/windows-store.test.js index d68d186e..6c4cd8f9 100644 --- a/tests/unit/windows-store.test.js +++ b/tests/unit/windows-store.test.js @@ -43,11 +43,13 @@ test('every Windows install keeps its library in the home folder, outside AppDat test('the default library outside Windows is unchanged', (t) => { if (process.platform === 'win32') return; + const userData = makeTmpDir('store-default-library'); + t.after(() => rmrf(userData)); const saved = process.env.STEPFORGE_DATA_DIR; delete process.env.STEPFORGE_DATA_DIR; t.after(() => { if (saved !== undefined) process.env.STEPFORGE_DATA_DIR = saved; }); assert.equal(resolveDataDir({ homeDir: '/home/casey' }), path.join(process.env.XDG_DATA_HOME || '/home/casey/.local/share', 'stepforge')); - assert.equal(libraryLocationFor('/tmp/x', { homeDir: '/home/casey' }).legacyPath, null, 'nothing to move on Linux'); + assert.equal(libraryLocationFor(userData, { homeDir: '/home/casey' }).legacyPath, null, 'nothing to move on Linux'); }); test('STEPFORGE_DATA_DIR still wins, and a chosen library stays where it was chosen', (t) => { From 59f7ba9e82c6fb99e9c7591c741bfc40bbeadf45 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 11:41:26 -0500 Subject: [PATCH 7/7] Clarify translated agent snapshot coverage --- core/locales/es.js | 2 +- core/locales/fr.js | 2 +- core/locales/ja.js | 2 +- core/locales/ru.js | 2 +- core/locales/zh-CN.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/locales/es.js b/core/locales/es.js index 6b0c1416..41d74212 100644 --- a/core/locales/es.js +++ b/core/locales/es.js @@ -350,7 +350,7 @@ 'AI guide': 'Guía de IA', 'Recommended models': 'Modelos recomendados', 'AI agents (Claude, Codex)': 'Agentes de IA (Claude, Codex)', - "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before structural or destructive changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Las aplicaciones de IA como Claude y Codex pueden conectarse a StepForge y editar tus guías. Pueden crear y eliminar guías y pasos, reordenar pasos, reemplazar o eliminar capturas de pantalla, editar texto, añadir marcadores de posición y añadir o eliminar anotaciones, excepto los desenfoques. StepForge guarda instantáneas antes de los cambios estructurales o destructivos; restáuralas desde Más → Copias de seguridad e instantáneas. Las guías eliminadas van a la papelera.", + "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before step creation, moves, deletions and screenshot changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Las aplicaciones de IA como Claude y Codex pueden conectarse a StepForge y editar tus guías. Pueden crear y eliminar guías y pasos, reordenar pasos, reemplazar o eliminar capturas de pantalla, editar texto, añadir marcadores de posición y añadir o eliminar anotaciones, excepto los desenfoques. StepForge guarda instantáneas antes de crear o mover pasos, eliminar contenido y cambiar capturas de pantalla; restáuralas desde Más → Copias de seguridad e instantáneas. Las guías eliminadas van a la papelera.", 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Aplicaciones de IA como Claude y Codex pueden conectarse a StepForge y escribir tus guías por ti. Pueden reescribir texto, crear subpasos, añadir marcadores de posición y dibujar en las capturas, pero no pueden reordenar pasos ni eliminar nada.', 'Let AI agents edit your guides': 'Permitir que los agentes de IA editen tus guías', 'Turn off to cut off every connected agent at once.': 'Desactívalo para desconectar a la vez a todos los agentes conectados.', diff --git a/core/locales/fr.js b/core/locales/fr.js index 103c3bee..97db8e0b 100644 --- a/core/locales/fr.js +++ b/core/locales/fr.js @@ -350,7 +350,7 @@ 'AI guide': 'Guide de l’IA', 'Recommended models': 'Modèles recommandés', 'AI agents (Claude, Codex)': 'Agents IA (Claude, Codex)', - "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before structural or destructive changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Les applications d’IA comme Claude et Codex peuvent se connecter à StepForge et modifier vos guides. Elles peuvent créer et supprimer des guides et des étapes, réorganiser les étapes, remplacer ou supprimer des captures d’écran, modifier le texte, ajouter des espaces réservés et ajouter ou supprimer des annotations, sauf les flous. StepForge enregistre des instantanés avant les modifications structurelles ou destructives ; restaurez-les depuis Plus → Sauvegardes et instantanés. Les guides supprimés vont dans la corbeille.", + "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before step creation, moves, deletions and screenshot changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Les applications d’IA comme Claude et Codex peuvent se connecter à StepForge et modifier vos guides. Elles peuvent créer et supprimer des guides et des étapes, réorganiser les étapes, remplacer ou supprimer des captures d’écran, modifier le texte, ajouter des espaces réservés et ajouter ou supprimer des annotations, sauf les flous. StepForge enregistre des instantanés avant l’ajout ou le déplacement d’étapes, les suppressions et les changements de capture d’écran ; restaurez-les depuis Plus → Sauvegardes et instantanés. Les guides supprimés vont dans la corbeille.", 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Les applications d’IA comme Claude et Codex peuvent se connecter à StepForge et rédiger vos guides pour vous. Elles peuvent réécrire du texte, créer des sous-étapes, ajouter des espaces réservés et dessiner sur les captures, mais ne peuvent ni réorganiser les étapes ni rien supprimer.', 'Let AI agents edit your guides': 'Autoriser les agents IA à modifier vos guides', 'Turn off to cut off every connected agent at once.': 'Désactivez pour couper d’un coup tous les agents connectés.', diff --git a/core/locales/ja.js b/core/locales/ja.js index 8073ed25..333dcec4 100644 --- a/core/locales/ja.js +++ b/core/locales/ja.js @@ -350,7 +350,7 @@ 'AI guide': 'AI ガイド', 'Recommended models': 'おすすめのモデル', 'AI agents (Claude, Codex)': 'AI エージェント(Claude、Codex)', - "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before structural or destructive changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Claude や Codex などの AI アプリは StepForge に接続してガイドを編集できます。ガイドやステップの作成と削除、ステップの並べ替え、スクリーンショットの差し替えと削除、テキストの編集、プレースホルダーの追加、ぼかし以外の注釈の追加と削除ができます。StepForge は構成の変更や削除などの操作前にスナップショットを保存します。「その他 → バックアップとスナップショット」から復元できます。削除されたガイドはゴミ箱に移動します。", + "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before step creation, moves, deletions and screenshot changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Claude や Codex などの AI アプリは StepForge に接続してガイドを編集できます。ガイドやステップの作成と削除、ステップの並べ替え、スクリーンショットの差し替えと削除、テキストの編集、プレースホルダーの追加、ぼかし以外の注釈の追加と削除ができます。StepForge はステップの作成や移動、削除、スクリーンショットの変更前にスナップショットを保存します。「その他 → バックアップとスナップショット」から復元できます。削除されたガイドはゴミ箱に移動します。", 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Claude や Codex などの AI アプリは StepForge に接続して、ガイドを代わりに作成できます。テキストの書き直し、サブステップの作成、プレースホルダーの追加、スクリーンショットへの描画はできますが、ステップの並べ替えや削除はできません。', 'Let AI agents edit your guides': 'AI エージェントにガイドの編集を許可', 'Turn off to cut off every connected agent at once.': 'オフにすると、接続しているすべてのエージェントを一度に切断します。', diff --git a/core/locales/ru.js b/core/locales/ru.js index f7f573de..fafc37b3 100644 --- a/core/locales/ru.js +++ b/core/locales/ru.js @@ -350,7 +350,7 @@ 'AI guide': 'Руководство по ИИ', 'Recommended models': 'Рекомендуемые модели', 'AI agents (Claude, Codex)': 'ИИ-агенты (Claude, Codex)', - "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before structural or destructive changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Приложения ИИ, такие как Claude и Codex, могут подключаться к StepForge и редактировать ваши руководства. Они могут создавать и удалять руководства и шаги, менять порядок шагов, заменять или удалять снимки экрана, редактировать текст, добавлять подстановки, а также добавлять и удалять аннотации, кроме размытия. StepForge сохраняет снимки состояния перед структурными изменениями или удалением данных; восстановить их можно через Ещё → Резервные копии и снимки. Удалённые руководства попадают в корзину.", + "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before step creation, moves, deletions and screenshot changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Приложения ИИ, такие как Claude и Codex, могут подключаться к StepForge и редактировать ваши руководства. Они могут создавать и удалять руководства и шаги, менять порядок шагов, заменять или удалять снимки экрана, редактировать текст, добавлять подстановки, а также добавлять и удалять аннотации, кроме размытия. StepForge сохраняет снимки состояния перед созданием и перемещением шагов, удалением содержимого и изменением снимков экрана; восстановить их можно через Ещё → Резервные копии и снимки. Удалённые руководства попадают в корзину.", 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'ИИ-приложения, такие как Claude и Codex, могут подключаться к StepForge и писать руководства за вас. Они могут переписывать текст, создавать подшаги, добавлять заполнители и рисовать на снимках экрана, но не могут менять порядок шагов или что-либо удалять.', 'Let AI agents edit your guides': 'Разрешить ИИ-агентам изменять руководства', 'Turn off to cut off every connected agent at once.': 'Отключите, чтобы сразу отключить всех подключённых агентов.', diff --git a/core/locales/zh-CN.js b/core/locales/zh-CN.js index 812a3efc..3103b7e1 100644 --- a/core/locales/zh-CN.js +++ b/core/locales/zh-CN.js @@ -350,7 +350,7 @@ 'AI guide': 'AI 指南', 'Recommended models': '推荐模型', 'AI agents (Claude, Codex)': 'AI 代理(Claude、Codex)', - "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before structural or destructive changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Claude 和 Codex 等 AI 应用可以连接到 StepForge 并编辑您的指南。它们可以创建和删除指南及步骤、调整步骤顺序、替换或移除截图、编辑文字、添加占位符,以及添加或删除模糊遮盖以外的标注。StepForge 会在更改结构或执行破坏性操作前保存快照;您可以通过“更多 → 备份与快照”恢复。删除的指南会移入回收站。", + "AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before step creation, moves, deletions and screenshot changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.": "Claude 和 Codex 等 AI 应用可以连接到 StepForge 并编辑您的指南。它们可以创建和删除指南及步骤、调整步骤顺序、替换或移除截图、编辑文字、添加占位符,以及添加或删除模糊遮盖以外的标注。StepForge 会在创建或移动步骤、删除内容以及更改截图之前保存快照;您可以通过“更多 → 备份与快照”恢复。删除的指南会移入回收站。", 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.': 'Claude 和 Codex 等 AI 应用可以连接到 StepForge 并为你撰写指南。它们可以改写文本、创建子步骤、添加占位符并在截图上绘制,但不能重新排序步骤或删除任何内容。', 'Let AI agents edit your guides': '允许 AI 代理编辑你的指南', 'Turn off to cut off every connected agent at once.': '关闭后会立即断开所有已连接的代理。',