From 36ebd9042a449aba0f5ae63ce8f4109eaa024644 Mon Sep 17 00:00:00 2001 From: kemalai Date: Thu, 2 Jul 2026 20:55:43 +0300 Subject: [PATCH] fix: crawl correctness, performance, and full EN/TR localization --- apps/cli/src/index.ts | 58 +++- apps/desktop/src/main/ai-providers.ts | 7 +- apps/desktop/src/main/api-fetch.ts | 52 +++ apps/desktop/src/main/bigquery-export.ts | 9 +- apps/desktop/src/main/ga4.ts | 5 +- apps/desktop/src/main/google-oauth.ts | 9 +- apps/desktop/src/main/gsc.ts | 7 +- apps/desktop/src/main/index.ts | 198 ++++++----- apps/desktop/src/main/menu-i18n.ts | 197 +++++++++++ apps/desktop/src/main/seo-providers.ts | 9 +- apps/desktop/src/main/sheets-export.ts | 5 +- apps/desktop/src/renderer/src/App.tsx | 4 +- .../src/renderer/src/LogAnalyzerView.tsx | 9 + .../src/components/BottomDetailPanel.tsx | 97 ++++-- .../renderer/src/components/ErrorBoundary.tsx | 7 +- .../renderer/src/components/ReportsDialog.tsx | 64 ++-- .../src/components/SettingsDialog.tsx | 137 +++++--- .../src/renderer/src/components/StatsBar.tsx | 51 ++- .../src/renderer/src/hooks/usePerfMeter.ts | 2 +- apps/desktop/src/renderer/src/i18n/labels.ts | 55 ++- .../src/renderer/src/i18n/locales/en.json | 70 +++- .../src/renderer/src/i18n/locales/tr.json | 71 +++- .../desktop/src/renderer/src/tabs/UrlsTab.tsx | 62 +++- .../renderer/src/tabs/VisualizationTab.tsx | 7 +- apps/mcp-server/src/index.ts | 44 ++- apps/mcp-server/src/tools.ts | 50 +-- eslint.config.js | 35 +- packages/core/src/compare.ts | 7 +- packages/core/src/crawler.ts | 317 +++++++++++++----- packages/core/src/html-parser.ts | 2 +- packages/core/src/log-analyzer.ts | Bin 8168 -> 8186 bytes packages/core/src/robots.ts | 2 +- packages/core/src/tabular-export.ts | 7 +- packages/core/src/url-utils.ts | 99 +++++- packages/core/src/xml-export.ts | 1 + packages/db/src/project-db.ts | 68 ++-- 36 files changed, 1372 insertions(+), 452 deletions(-) create mode 100644 apps/desktop/src/main/api-fetch.ts diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index d36f75e..06a6423 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -193,22 +193,37 @@ async function main(): Promise { } } + // Per-flag overrides win ONLY when the flag is actually present. Applying + // them unconditionally would let a `parseNumeric(undefined, DEFAULT)` / + // `?? DEFAULT` fallback silently overwrite a value the user set in the + // --config file (the help text promises "the command line still win[s]", + // not "defaults clobber the file"). const config = { ...DEFAULT_CRAWL_CONFIG, ...fileConfig, mode: listFile ? ('list' as const) : ('spider' as const), urlList: listUrls, startUrl, - maxDepth: parseNumeric(values.depth, DEFAULT_CRAWL_CONFIG.maxDepth), - maxUrls: parseNumeric(values.max, DEFAULT_CRAWL_CONFIG.maxUrls), - maxConcurrency: parseNumeric(values.concurrency, DEFAULT_CRAWL_CONFIG.maxConcurrency), - maxRps: parseNumeric(values.rps, DEFAULT_CRAWL_CONFIG.maxRps), - userAgent: values['user-agent'] ?? DEFAULT_CRAWL_CONFIG.userAgent, - respectRobotsTxt: !values['no-robots'], - crawlExternal: Boolean(values.external), - customHeaders: parseHeaders(values.header), - includePatterns: values.include ?? [], - excludePatterns: values.exclude ?? [], + ...(values.depth !== undefined + ? { maxDepth: parseNumeric(values.depth, DEFAULT_CRAWL_CONFIG.maxDepth) } + : {}), + ...(values.max !== undefined + ? { maxUrls: parseNumeric(values.max, DEFAULT_CRAWL_CONFIG.maxUrls) } + : {}), + ...(values.concurrency !== undefined + ? { maxConcurrency: parseNumeric(values.concurrency, DEFAULT_CRAWL_CONFIG.maxConcurrency) } + : {}), + ...(values.rps !== undefined + ? { maxRps: parseNumeric(values.rps, DEFAULT_CRAWL_CONFIG.maxRps) } + : {}), + ...(values['user-agent'] !== undefined ? { userAgent: values['user-agent'] } : {}), + // `--no-robots` / `--external` only flip the flag ON in their direction; + // absent, the file value (or default) stands. + ...(values['no-robots'] ? { respectRobotsTxt: false } : {}), + ...(values.external ? { crawlExternal: true } : {}), + ...(values.header !== undefined ? { customHeaders: parseHeaders(values.header) } : {}), + ...(values.include !== undefined ? { includePatterns: values.include } : {}), + ...(values.exclude !== undefined ? { excludePatterns: values.exclude } : {}), }; const crawler = new Crawler(config, db); @@ -221,7 +236,13 @@ async function main(): Promise { ); }); } + let crawlError: string | null = null; crawler.on('error', (msg) => { + // Record the first fatal error (invalid/unreachable start URL, seed + // failure, bad pattern) so the exit code and --json `ok` reflect it — + // otherwise an unreachable seed produces an empty DB with no 4xx/5xx + // rows and the CLI misreports success to a CI pipeline. + if (crawlError === null) crawlError = String(msg); process.stderr.write(`\n[error] ${msg}\n`); }); @@ -244,16 +265,22 @@ async function main(): Promise { } const issues = db.getOverviewCounts().issues; - const hasErrors = Object.keys(summary.byStatus).some((k) => { - const n = Number.parseInt(k, 10); - return Number.isFinite(n) && n >= 400; - }); + // A crawl that fetched nothing (unreachable seed, everything filtered) is a + // failure for CI purposes — a successful spider always fetches ≥1 page. + const hasErrors = + crawlError !== null || + summary.total === 0 || + Object.keys(summary.byStatus).some((k) => { + const n = Number.parseInt(k, 10); + return Number.isFinite(n) && n >= 400; + }); if (jsonMode) { process.stdout.write( JSON.stringify( { ok: !hasErrors, + error: crawlError, startUrl, dbPath, summary, @@ -469,7 +496,8 @@ function flattenLogResult(filePath: string, result: LogAnalysisResult): LogInges lastTs: u.lastTs, })), daily: Array.from(result.daily.entries()).map(([key, hits]) => { - const sp = key.indexOf(' '); + // Key is `${day}\t${bucket}` (tab-separated in log-analyzer.ts). + const sp = key.indexOf('\t'); return { day: key.slice(0, sp), bucket: key.slice(sp + 1), hits }; }), status: Array.from(result.status.entries()).map(([status, v]) => ({ diff --git a/apps/desktop/src/main/ai-providers.ts b/apps/desktop/src/main/ai-providers.ts index 4e3bf88..4106ae4 100644 --- a/apps/desktop/src/main/ai-providers.ts +++ b/apps/desktop/src/main/ai-providers.ts @@ -13,6 +13,7 @@ */ import type { AiProvider } from '@freecrawl/shared-types'; import { resolveCredentials } from './credentials.js'; +import { apiFetch } from './api-fetch.js'; const REQUEST_TIMEOUT_MS = 120_000; const DEFAULT_MAX_TOKENS = 800; @@ -95,7 +96,7 @@ async function runOpenAi(model: string, prompt: string): Promise { model, ); } - const res = await fetch('https://api.openai.com/v1/chat/completions', { + const res = await apiFetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -137,7 +138,7 @@ async function runAnthropic(model: string, prompt: string): Promise model, ); } - const res = await fetch('https://api.anthropic.com/v1/messages', { + const res = await apiFetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -184,7 +185,7 @@ async function runOllama(model: string, prompt: string): Promise { const endpoint = (creds['endpoint'] ?? 'http://localhost:11434').trim() || 'http://localhost:11434'; let res: Response; try { - res = await fetch(`${endpoint.replace(/\/+$/, '')}/api/generate`, { + res = await apiFetch(`${endpoint.replace(/\/+$/, '')}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, prompt, stream: false }), diff --git a/apps/desktop/src/main/api-fetch.ts b/apps/desktop/src/main/api-fetch.ts new file mode 100644 index 0000000..e49816a --- /dev/null +++ b/apps/desktop/src/main/api-fetch.ts @@ -0,0 +1,52 @@ +/** + * Shared undici dispatcher for main-process API integrations — AI providers, + * Search Console, GA4, Google Sheets / BigQuery, OAuth, SEO providers and the + * update check. + * + * The crawler installs a *process-global* dispatcher via `setGlobalDispatcher` + * with `headersTimeout: 10_000` (see packages/core `http-client.ts`). That is + * correct for crawling — a stuck origin can't freeze the connection pool — but + * fatal for these clients: a plain global `fetch` inherits the global + * dispatcher, so any API whose response takes more than 10 s to the first byte + * (a slow LLM completion, a large GSC/GA4 report, a Sheets/BigQuery write) is + * killed at the transport layer as a generic "fetch failed" + * (UND_ERR_HEADERS_TIMEOUT), regardless of the client's own longer + * `AbortSignal`. The failure only appears *after* the first crawl of a session + * installs the global dispatcher, which makes it maddening to diagnose. + * + * This dedicated dispatcher uses generous header/body timeouts (each client + * still enforces its real cap via `AbortSignal`) and honours HTTPS_PROXY / + * HTTP_PROXY so corporate-proxy users keep working, mirroring the PSI client + * in `pagespeed.ts` that already solved this for its own requests. + */ +import { Agent, ProxyAgent, fetch as undiciFetch, type Dispatcher } from 'undici'; + +/** Above the longest client-side AbortSignal (120 s in sheets-export). */ +const TIMEOUT_MS = 125_000; + +function createApiDispatcher(): Dispatcher { + const timeouts = { headersTimeout: TIMEOUT_MS, bodyTimeout: TIMEOUT_MS }; + const proxy = + process.env['HTTPS_PROXY'] ?? + process.env['https_proxy'] ?? + process.env['HTTP_PROXY'] ?? + process.env['http_proxy'] ?? + null; + if (proxy) { + return new ProxyAgent({ uri: proxy, ...timeouts }); + } + return new Agent({ ...timeouts, connect: { autoSelectFamily: true } }); +} + +const apiDispatcher = createApiDispatcher(); + +type FetchParams = Parameters; + +/** + * Drop-in replacement for the global `fetch` in main-process integration + * clients. Pins every request to the dedicated integration dispatcher above so + * it never inherits the crawler's aggressive global `headersTimeout`. + */ +export function apiFetch(input: FetchParams[0], init?: FetchParams[1]) { + return undiciFetch(input, { ...(init ?? {}), dispatcher: apiDispatcher }); +} diff --git a/apps/desktop/src/main/bigquery-export.ts b/apps/desktop/src/main/bigquery-export.ts index d35ed2f..bbb100c 100644 --- a/apps/desktop/src/main/bigquery-export.ts +++ b/apps/desktop/src/main/bigquery-export.ts @@ -20,6 +20,7 @@ import { createSign } from 'node:crypto'; import type { ProjectDb } from '@freecrawl/db'; import type { CrawlUrlRow, UrlCategory } from '@freecrawl/shared-types'; import { resolveCredentials } from './credentials.js'; +import { apiFetch } from './api-fetch.js'; import * as logger from './logger.js'; const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'; @@ -117,7 +118,7 @@ async function mintAccessToken(sa: ServiceAccount): Promise { .sign(sa.private_key, 'base64url'); const jwt = `${signingInput}.${signature}`; - const res = await fetch(TOKEN_ENDPOINT, { + const res = await apiFetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ @@ -168,7 +169,7 @@ async function ensureDataset( token: string, datasetId: string, ): Promise { - const res = await fetch( + const res = await apiFetch( `${BIGQUERY_API}/projects/${encodeURIComponent(projectId)}/datasets`, { method: 'POST', @@ -194,7 +195,7 @@ async function createTable( datasetId: string, tableId: string, ): Promise { - const res = await fetch( + const res = await apiFetch( `${BIGQUERY_API}/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent( datasetId, )}/tables`, @@ -227,7 +228,7 @@ async function insertAllChunk( tableId: string, rows: { json: Record }[], ): Promise { - const res = await fetch( + const res = await apiFetch( `${BIGQUERY_API}/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent( datasetId, )}/tables/${encodeURIComponent(tableId)}/insertAll`, diff --git a/apps/desktop/src/main/ga4.ts b/apps/desktop/src/main/ga4.ts index 8339e33..ec7fcb6 100644 --- a/apps/desktop/src/main/ga4.ts +++ b/apps/desktop/src/main/ga4.ts @@ -14,6 +14,7 @@ */ import type { Ga4Property } from '@freecrawl/shared-types'; import { getAccessToken } from './google-oauth.js'; +import { apiFetch } from './api-fetch.js'; import * as logger from './logger.js'; const ADMIN_BASE = 'https://analyticsadmin.googleapis.com/v1beta'; @@ -53,7 +54,7 @@ export async function listProperties(): Promise { const url = `${ADMIN_BASE}/accountSummaries?pageSize=200${ pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : '' }`; - const res = await fetch(url, { + const res = await apiFetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(30_000), }); @@ -119,7 +120,7 @@ export async function runReport( let offset = 0; for (;;) { - const res = await fetch(endpoint, { + const res = await apiFetch(endpoint, { method: 'POST', headers: { Authorization: `Bearer ${token}`, diff --git a/apps/desktop/src/main/google-oauth.ts b/apps/desktop/src/main/google-oauth.ts index b057912..b2def98 100644 --- a/apps/desktop/src/main/google-oauth.ts +++ b/apps/desktop/src/main/google-oauth.ts @@ -29,6 +29,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { join } from 'node:path'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import type { GoogleAuthState } from '@freecrawl/shared-types'; +import { apiFetch } from './api-fetch.js'; import { resolveCredentials } from './credentials.js'; import * as logger from './logger.js'; @@ -286,7 +287,7 @@ export async function startAuth(integrationId: string): Promise }); // ── Exchange the code for tokens ────────────────────────────────── - const tokenRes = await fetch(TOKEN_ENDPOINT, { + const tokenRes = await apiFetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ @@ -322,7 +323,7 @@ export async function startAuth(integrationId: string): Promise let email: string | null = null; if (typeof accessToken === 'string' && accessToken) { try { - const uiRes = await fetch(USERINFO_ENDPOINT, { + const uiRes = await apiFetch(USERINFO_ENDPOINT, { headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(15_000), }); @@ -383,7 +384,7 @@ export async function getAccessToken(integrationId: string): Promise { throw new Error(`"${integrationId}" is not connected — sign in with Google first.`); } const { clientId, clientSecret } = resolveClient(integrationId); - const res = await fetch(TOKEN_ENDPOINT, { + const res = await apiFetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ @@ -416,7 +417,7 @@ export async function revokeAuth(integrationId: string): Promise { const token = await getAccessToken('gsc'); - const res = await fetch(`${API_BASE}/sites`, { + const res = await apiFetch(`${API_BASE}/sites`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(30_000), }); @@ -82,7 +83,7 @@ export async function querySearchAnalytics( let startRow = 0; for (;;) { - const res = await fetch(endpoint, { + const res = await apiFetch(endpoint, { method: 'POST', headers: { Authorization: `Bearer ${token}`, @@ -151,7 +152,7 @@ export async function inspectUrl( url: string, ): Promise { const token = await getAccessToken('gsc'); - const res = await fetch( + const res = await apiFetch( 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { method: 'POST', diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 38384ee..1553ccf 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -148,6 +148,7 @@ import { type LogImportSummary, type LogExportInput, type LogExportResult, + type LogEntry, } from '@freecrawl/shared-types'; import { BrowserPool, @@ -180,9 +181,11 @@ import { type LogExportTables, } from '@freecrawl/core'; import { fetch as undiciFetch } from 'undici'; +import { apiFetch } from './api-fetch.js'; +import { spawn } from 'node:child_process'; import { ProjectDb } from '@freecrawl/db'; import { buildAppMenu } from './menu.js'; -import { isMenuLang, type MenuLang } from './menu-i18n.js'; +import { getMenuLabels, isMenuLang, type MenuLang } from './menu-i18n.js'; import { getState as getIntegrationsState, setCredentials as setIntegrationCredentials, @@ -932,6 +935,12 @@ function getMenuLang(): MenuLang { return isMenuLang(raw) ? raw : 'en'; } +/** Localized menu/dialog labels for the active UI language. Lets native + * dialogs pull `L().dlgX` inline without threading a param through handlers. */ +function L(): ReturnType { + return getMenuLabels(getMenuLang()); +} + function rebuildMenu(): void { Menu.setApplicationMenu( buildAppMenu({ @@ -943,7 +952,7 @@ function rebuildMenu(): void { openProjectAtPath(path); } catch (err) { dialog.showErrorBox( - 'Open Project Failed', + L().dlgOpenProjectFailedTitle, `Could not open ${path}.\n\n${(err as Error).message}`, ); // Drop the bad entry so it doesn't keep failing. @@ -974,9 +983,9 @@ function openLogsFolder(): void { if (mainWindow) { void dialog.showMessageBox(mainWindow, { type: 'warning', - title: 'Logs Folder Unavailable', - message: 'Disk logging has not been initialised. Logs are kept in memory only for this session.', - buttons: ['OK'], + title: L().dlgLogsFolderUnavailableTitle, + message: L().dlgLogsFolderUnavailableMsg, + buttons: [L().btnOk], noLink: true, }); } @@ -1009,12 +1018,12 @@ function resetDiagnosticDialogs(): void { if (mainWindow) { void dialog.showMessageBox(mainWindow, { type: 'info', - title: 'Diagnostic Warnings Reset', + title: L().dlgDiagResetTitle, message: removed === 0 ? 'No suppressed diagnostic warnings to reset.' : `Re-enabled ${removed} previously dismissed warning${removed === 1 ? '' : 's'}. They will pop up again the next time the underlying issue occurs.`, - buttons: ['OK'], + buttons: [L().btnOk], noLink: true, }); } @@ -1223,7 +1232,7 @@ async function downloadAndRevealInstaller(asset: GitHubReleaseAsset): Promise { const ac = new AbortController(); const timeout = setTimeout(() => ac.abort(), 10_000); try { - const res = await fetch(apiUrl, { + const res = await apiFetch(apiUrl, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': `FreeCrawl-SEO-Tool/${installed}`, @@ -1357,12 +1366,12 @@ async function runUpdateCheck(silent: boolean): Promise { } void dialog.showMessageBox(win, { type: 'warning', - title: 'Update Check Failed', + title: L().dlgUpdateCheckFailedTitle, message: "Couldn't reach the GitHub Releases API.", detail: (fetchError ?? 'No release tag in response.') + '\n\nYou can browse releases manually at:\nhttps://github.com/kemalai/FreeCrawl-SEO-Tool/releases', - buttons: ['Open Releases Page', 'Close'], + buttons: [L().btnOpenReleasesPage, L().btnClose], defaultId: 0, cancelId: 1, noLink: true, @@ -1382,12 +1391,12 @@ async function runUpdateCheck(silent: boolean): Promise { if (silent) return; void dialog.showMessageBox(win, { type: 'info', - title: 'Up to Date', + title: L().dlgUpToDateTitle, message: `You're on the latest version (v${installed}).`, detail: release.published_at ? `Latest GitHub release: ${latest}\nPublished: ${new Date(release.published_at).toLocaleString()}` : `Latest GitHub release: ${latest}`, - buttons: ['OK'], + buttons: [L().btnOk], noLink: true, }); return; @@ -1428,7 +1437,7 @@ async function runUpdateCheck(silent: boolean): Promise { const result = await dialog.showMessageBox(win, { type: 'info', - title: 'Update Available', + title: L().dlgUpdateAvailableTitle, message: `${latest} is available.`, detail, buttons, @@ -1458,7 +1467,7 @@ async function runUpdateCheck(silent: boolean): Promise { async function promptOpenProject(): Promise { if (!mainWindow) return; const res = await dialog.showOpenDialog(mainWindow, { - title: 'Open Project', + title: L().dlgOpenProjectTitle, properties: ['openFile'], filters: [ { name: 'FreeCrawl Project', extensions: ['seoproject', 'sqlite', 'db'] }, @@ -1470,7 +1479,7 @@ async function promptOpenProject(): Promise { openProjectAtPath(res.filePaths[0]!); } catch (err) { dialog.showErrorBox( - 'Open Project Failed', + L().dlgOpenProjectFailedTitle, `Could not open the selected file.\n\n${(err as Error).message}`, ); } @@ -1558,10 +1567,21 @@ function createWindow(): void { }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url); + // Only hand real web / mail links to the OS. Without a scheme allowlist a + // crawl-derived `file://` / `smb://` / custom-protocol URL would be opened + // with the user's privileges. + if (isSafeExternalUrl(url)) void shell.openExternal(url); return { action: 'deny' }; }); + // Null the reference when the window closes so the ~15 `mainWindow?.…send()` + // sites short-circuit instead of throwing "Object has been destroyed" — a + // closed-but-non-null BrowserWindow still throws on `.webContents`. Matches + // the Logs / Visualization / Log Analyzer windows, which already do this. + mainWindow.on('closed', () => { + mainWindow = null; + }); + if (process.env['ELECTRON_RENDERER_URL']) { void mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']); } else { @@ -1569,6 +1589,20 @@ function createWindow(): void { } } +/** + * Whether a renderer-supplied URL is safe to hand to `shell.openExternal`. + * Restricts to the schemes a user actually expects a browser / mail client to + * handle, blocking `file:`, `smb:`, and OS custom-protocol handlers. + */ +function isSafeExternalUrl(raw: string): boolean { + try { + const scheme = new URL(raw).protocol.toLowerCase(); + return scheme === 'http:' || scheme === 'https:' || scheme === 'mailto:'; + } catch { + return false; + } +} + /** * Open (or focus) the Logs popup window. Loads the same renderer bundle * with `?logs=1` so the renderer entry branches to the LogsView component. @@ -1826,7 +1860,8 @@ function ingestLogResult(filePath: string, result: LogAnalysisResult): LogImport lastTs: u.lastTs, })), daily: Array.from(result.daily.entries()).map(([key, hits]) => { - const sp = key.indexOf(' '); + // Key is `${day}\t${bucket}` (tab-separated in log-analyzer.ts). + const sp = key.indexOf('\t'); return { day: key.slice(0, sp), bucket: key.slice(sp + 1), hits }; }), status: Array.from(result.status.entries()).map(([status, v]) => ({ @@ -1955,7 +1990,7 @@ function registerIpc(): void { return { ok: false, error: 'No window available for the file picker.', imported: null, overview: null }; } const res = await dialog.showOpenDialog(parent, { - title: 'Open Access Log', + title: L().dlgOpenAccessLogTitle, properties: ['openFile'], filters: [ { name: 'Log Files', extensions: ['log', 'txt', 'gz', 'out', 'access'] }, @@ -2027,7 +2062,7 @@ function registerIpc(): void { BrowserWindow.fromWebContents(e.sender) ?? logAnalyzerWindow ?? mainWindow; if (!parent) return { filePath: '', bytesWritten: 0 }; const res = await dialog.showSaveDialog(parent, { - title: 'Export Log Analysis', + title: L().dlgExportLogAnalysisTitle, defaultPath: `log-analysis.${format}`, filters: [ format === 'csv' @@ -2236,7 +2271,7 @@ function registerIpc(): void { ): Promise => { if (!mainWindow) return { filePath: '', bytesWritten: 0 }; const res = await dialog.showSaveDialog(mainWindow, { - title: 'Export Extraction Rules', + title: L().dlgExportExtractionRulesTitle, defaultPath: 'freecrawl-extraction-rules.json', filters: [{ name: 'JSON', extensions: ['json'] }], }); @@ -2268,7 +2303,7 @@ function registerIpc(): void { return { filePath: '', rules: [], skippedCount: 0 }; } const res = await dialog.showOpenDialog(mainWindow, { - title: 'Import Extraction Rules', + title: L().dlgImportExtractionRulesTitle, properties: ['openFile'], filters: [{ name: 'JSON', extensions: ['json'] }], }); @@ -2575,7 +2610,7 @@ function registerIpc(): void { if (!filePath) { if (!mainWindow) return { filePath: '', bytesWritten: 0 }; const res = await dialog.showSaveDialog(mainWindow, { - title: 'Export Settings', + title: L().dlgExportSettingsTitle, defaultPath: 'freecrawl-settings.json', filters: [{ name: 'JSON', extensions: ['json'] }], }); @@ -2601,7 +2636,7 @@ function registerIpc(): void { async (): Promise => { if (!mainWindow) return { filePath: '', config: null, unknownFields: [] }; const res = await dialog.showOpenDialog(mainWindow, { - title: 'Import Settings', + title: L().dlgImportSettingsTitle, properties: ['openFile'], filters: [{ name: 'JSON', extensions: ['json'] }], }); @@ -2659,7 +2694,7 @@ function registerIpc(): void { const win = mainWindow; if (!win) return null; const res = await dialog.showOpenDialog(win, { - title: input?.title ?? 'Choose Folder', + title: input?.title ?? L().dlgChooseFolderTitle, defaultPath: input?.defaultPath, properties: ['openDirectory', 'createDirectory'], }); @@ -3575,7 +3610,7 @@ function registerIpc(): void { // costs serialise + V8-deserialise + main-thread work in the // renderer process. Batching at 100 ms drops IPC volume by ~10–30× // while still feeling "live" in the log panel. - let logsBatch: import('@freecrawl/shared-types').LogEntry[] = []; + let logsBatch: LogEntry[] = []; let logsFlushTimer: NodeJS.Timeout | null = null; const flushLogsBatch = (): void => { logsFlushTimer = null; @@ -3989,18 +4024,17 @@ function registerIpc(): void { if (!mainWindow) return false; const res = await dialog.showMessageBox(mainWindow, { type: 'question', - title: 'JavaScript Rendering — Browser Missing', + title: L().dlgPlaywrightTitle, message: 'Playwright needs to download a Chromium browser before JavaScript rendering can run.', detail: 'This is a one-time ~250 MB download. The browser is stored in your user cache; FreeCrawl never sends any data to Playwright servers — only the binary is downloaded from cdn.playwright.dev.\n\nDownload now?', - buttons: ['Download now', 'Skip — disable JS render for this run'], + buttons: [L().btnDownloadNow, L().btnSkipJsRender], defaultId: 0, cancelId: 1, }); if (res.response !== 0) return false; playwrightInstallPromise = new Promise((resolve) => { - const { spawn } = require('node:child_process') as typeof import('node:child_process'); const proc = spawn( 'npx', ['playwright', 'install', 'chromium', 'chromium-headless-shell'], @@ -4030,7 +4064,7 @@ function registerIpc(): void { `Browser install failed (exit ${code}). stderr: ${stderr.slice(0, 500)}`, ); dialog.showErrorBox( - 'Browser Install Failed', + L().dlgBrowserInstallFailedTitle, `Playwright install exited with code ${code}.\n\n` + `Try running "npx playwright install chromium chromium-headless-shell" manually from a terminal in the FreeCrawl source folder.\n\n${stderr.slice(0, 400)}`, ); @@ -4041,7 +4075,7 @@ function registerIpc(): void { playwrightInstallPromise = null; logger.log('error', 'playwright-install', `spawn failed: ${err.message}`); dialog.showErrorBox( - 'Browser Install Failed', + L().dlgBrowserInstallFailedTitle, `Could not run "npx playwright install": ${err.message}\n\nPlease install Playwright browsers manually from a terminal.`, ); resolve(false); @@ -5025,10 +5059,10 @@ function registerIpc(): void { const bytes = statSync(target).size; await dialog.showMessageBox(win, { type: 'info', - title: 'Project Saved', + title: L().dlgProjectSavedTitle, message: `Snapshot written: ${(bytes / (1024 * 1024)).toFixed(1)} MB.`, detail: target, - buttons: ['OK'], + buttons: [L().btnOk], noLink: true, }); // Saved snapshot is a valid project on disk — pin it as the active @@ -5054,7 +5088,7 @@ function registerIpc(): void { if (!target) { if (!mainWindow) return null; const res = await dialog.showOpenDialog(mainWindow, { - title: 'Open Project', + title: L().dlgOpenProjectTitle, properties: ['openFile'], filters: [ { name: 'FreeCrawl Project', extensions: ['seoproject', 'sqlite', 'db'] }, @@ -5070,7 +5104,7 @@ function registerIpc(): void { } catch (err) { if (mainWindow) { dialog.showErrorBox( - 'Open Project Failed', + L().dlgOpenProjectFailedTitle, `Could not open ${target}.\n\n${(err as Error).message}`, ); } @@ -5135,12 +5169,12 @@ function registerIpc(): void { await dialog.showMessageBox(win, { type: 'info', - title: 'Encrypted Snapshot Saved', + title: L().dlgEncSnapshotSavedTitle, message: `Encrypted snapshot written: ${(result.bytesWritten / (1024 * 1024)).toFixed(1)} MB.`, detail: `${target}\n\n` + 'Keep the password safe — it cannot be recovered. Without it, the file is unreadable.', - buttons: ['OK'], + buttons: [L().btnOk], noLink: true, }); return { filePath: target, bytesWritten: result.bytesWritten }; @@ -5188,7 +5222,7 @@ function registerIpc(): void { return app.getPath('documents'); })(); const save = await dialog.showSaveDialog(win, { - title: 'Save Decrypted Project As…', + title: L().dlgSaveDecryptedProjectTitle, defaultPath: join(baseDir, 'recovered.seoproject'), filters: [ { name: 'FreeCrawl Project', extensions: ['seoproject'] }, @@ -5214,18 +5248,16 @@ function registerIpc(): void { ipcMain.handle(IPC.confirmClear, async (): Promise => { const win = mainWindow; if (!win) return { confirmed: false, skipNext: false }; - const isTr = getMenuLang() === 'tr'; + const L = getMenuLabels(getMenuLang()); const res = await dialog.showMessageBox(win, { type: 'warning', - buttons: isTr ? ['Temizle', 'İptal'] : ['Clear', 'Cancel'], + buttons: [L.btnClear, L.btnCancel], defaultId: 1, cancelId: 1, - title: isTr ? 'Crawl Verilerini Temizle' : 'Clear Crawl Data', - message: isTr ? 'Tüm crawl verileri temizlensin mi?' : 'Clear all crawl data?', - detail: isTr - ? 'Bu, bu projedeki tüm keşfedilmiş URL\'leri, linkleri ve crawl meta verilerini siler. Bu işlem geri alınamaz.' - : 'This will remove all discovered URLs, links, and crawl metadata for this project. This action cannot be undone.', - checkboxLabel: isTr ? 'Bana bir daha sorma' : "Don't ask me again", + title: L.clearCrawlData, + message: L.dlgConfirmClearMsg, + detail: L.dlgConfirmClearDetail, + checkboxLabel: L.dlgDontAskAgain, checkboxChecked: false, noLink: true, }); @@ -5320,21 +5352,24 @@ function registerIpc(): void { ipcMain.handle(IPC.urlContextMenu, (e, input: UrlContextMenuInput) => { const win = BrowserWindow.fromWebContents(e.sender); const canRecrawl = activeCrawler !== null && activeCrawler.isRunning; + const L = getMenuLabels(getMenuLang()); const template: MenuItemConstructorOptions[] = [ { - label: 'Copy', + label: L.ctxCopy, click: () => clipboard.writeText(input.url), }, { - label: 'Open in Browser', - click: () => void shell.openExternal(input.url), + label: L.ctxOpenInBrowser, + click: () => { + if (isSafeExternalUrl(input.url)) void shell.openExternal(input.url); + }, }, { type: 'separator' }, { - label: 'Re-Spider', + label: L.ctxRespider, enabled: canRecrawl, - toolTip: canRecrawl ? undefined : 'Start a crawl first', + toolTip: canRecrawl ? undefined : L.ctxStartCrawlFirst, click: () => { const db = getDb(); db.markUrlForRecrawl(input.urlId); @@ -5345,7 +5380,7 @@ function registerIpc(): void { }, }, { - label: 'Remove', + label: L.ctxRemove, click: () => { getDb().deleteUrl(input.urlId); fireDataChanged(); @@ -5353,20 +5388,7 @@ function registerIpc(): void { }, { type: 'separator' }, { - label: 'Export', - enabled: false, - submenu: [{ label: 'Selected URLs as CSV', enabled: false }], - }, - { label: 'Visualisations', enabled: false }, - { label: 'Check Index', enabled: false }, - { label: 'Backlinks', enabled: false }, - { label: 'Validation', enabled: false }, - { label: 'History', enabled: false }, - { label: 'Speed', enabled: false }, - { type: 'separator' }, - { label: 'Show Other Domains on IP', enabled: false }, - { - label: 'Open robots.txt', + label: L.ctxOpenRobotsTxt, click: () => { try { const origin = new URL(input.url).origin; @@ -5393,27 +5415,27 @@ function registerIpc(): void { const urls = db.getUrlsByIds(ids); const canRecrawl = activeCrawler !== null && activeCrawler.isRunning; const n = ids.length.toLocaleString(); + const L = getMenuLabels(getMenuLang()); const template: MenuItemConstructorOptions[] = [ { - label: `Copy ${n} URLs`, + label: L.ctxCopyNUrls.replace('{n}', n), click: () => clipboard.writeText(urls.join('\n')), }, { - label: `Open ${n} URLs in Browser`, + label: L.ctxOpenNUrlsInBrowser.replace('{n}', n), // Guard: opening hundreds of tabs at once is a bad default. enabled: urls.length <= 20, - toolTip: - urls.length > 20 ? 'Limited to 20 URLs to avoid spawning too many tabs' : undefined, + toolTip: urls.length > 20 ? L.ctxOpenLimitTooltip : undefined, click: () => { - for (const u of urls) void shell.openExternal(u); + for (const u of urls) if (isSafeExternalUrl(u)) void shell.openExternal(u); }, }, { type: 'separator' }, { - label: `Re-Spider ${n} URLs`, + label: L.ctxRespiderNUrls.replace('{n}', n), enabled: canRecrawl, - toolTip: canRecrawl ? undefined : 'Start a crawl first', + toolTip: canRecrawl ? undefined : L.ctxStartCrawlFirst, click: () => { db.markUrlsForRecrawl(ids); if (activeCrawler) { @@ -5423,7 +5445,7 @@ function registerIpc(): void { }, }, { - label: `Remove ${n} URLs`, + label: L.ctxRemoveNUrls.replace('{n}', n), click: () => { db.deleteUrls(ids); fireDataChanged(); @@ -5431,7 +5453,7 @@ function registerIpc(): void { }, { type: 'separator' }, { - label: `Export ${n} URLs as CSV…`, + label: L.ctxExportNUrlsAsCsv.replace('{n}', n), click: async () => { const w = win ?? mainWindow; if (!w) return; @@ -5798,7 +5820,7 @@ function registerIpc(): void { return { outputDir: '', files: [], errors: [] }; } const dirRes = await dialog.showOpenDialog(mainWindow, { - title: 'Bulk Export — choose output folder', + title: L().dlgBulkExportFolderTitle, properties: ['openDirectory', 'createDirectory'], }); if (dirRes.canceled || dirRes.filePaths.length === 0) { @@ -5812,14 +5834,14 @@ function registerIpc(): void { const totalRows = files.reduce((s, f) => s + f.rowsWritten, 0); await dialog.showMessageBox(mainWindow, { type: 'info', - title: 'Bulk Export Complete', + title: L().dlgBulkExportCompleteTitle, message: `${files.length} file(s) written, ${totalRows.toLocaleString()} row(s) total.`, detail: outputDir + (errors.length > 0 ? `\n\nErrors:\n${errors.map((e) => `• ${e.label}: ${e.error}`).join('\n')}` : ''), - buttons: ['OK', 'Open Folder'], + buttons: [L().btnOk, L().btnOpenFolder], defaultId: 0, noLink: true, }).then((res) => { @@ -5852,10 +5874,10 @@ function registerIpc(): void { if (mainWindow) { await dialog.showMessageBox(mainWindow, { type: 'info', - title: 'HTML Report Saved', + title: L().dlgHtmlReportSavedTitle, message: `Report written: ${(result.bytesWritten / 1024).toFixed(1)} KB.`, detail: result.filePath, - buttons: ['OK'], + buttons: [L().btnOk], noLink: true, }); } @@ -5901,7 +5923,7 @@ function registerIpc(): void { : result.files[0] ?? filePath; await dialog.showMessageBox(mainWindow, { type: result.truncated ? 'warning' : 'info', - title: 'Sitemap Generated', + title: L().dlgSitemapGeneratedTitle, message: result.sharded ? `Sharded sitemap written: ${result.urlsWritten.toLocaleString()} URLs across ${ result.files.length - 1 @@ -5910,7 +5932,7 @@ function registerIpc(): void { result.truncated ? ' (truncated at the 50,000 limit).' : '.' }`, detail, - buttons: ['OK'], + buttons: [L().btnOk], noLink: true, }); } diff --git a/apps/desktop/src/main/menu-i18n.ts b/apps/desktop/src/main/menu-i18n.ts index 22920f0..91d86ea 100644 --- a/apps/desktop/src/main/menu-i18n.ts +++ b/apps/desktop/src/main/menu-i18n.ts @@ -65,6 +65,73 @@ export interface MenuLabels { checkForUpdates: string; checkForUpdatesTooltip: string; about: string; + + // ── Right-click context menus (URL table) ── + ctxCopy: string; + ctxOpenInBrowser: string; + ctxRespider: string; + ctxStartCrawlFirst: string; + ctxRemove: string; + ctxOpenRobotsTxt: string; + /** `{n}` placeholder → selection count. */ + ctxCopyNUrls: string; + ctxOpenNUrlsInBrowser: string; + ctxOpenLimitTooltip: string; + ctxRespiderNUrls: string; + ctxRemoveNUrls: string; + ctxExportNUrlsAsCsv: string; + + // ── Recurring dialog buttons ── + btnOk: string; + btnCancel: string; + btnClose: string; + btnClear: string; + btnOpenFolder: string; + btnLater: string; + btnOpenReleasePage: string; + btnOpenReleasesPage: string; + btnDownloadInstaller: string; + btnDownloadNow: string; + btnSkipJsRender: string; + + // ── Native dialog titles ── + dlgOpenProjectTitle: string; + dlgOpenProjectFailedTitle: string; + dlgLogsFolderUnavailableTitle: string; + dlgLogsFolderUnavailableMsg: string; + dlgDiagResetTitle: string; + dlgDiagResetNoneMsg: string; + dlgDownloadCompleteTitle: string; + dlgDownloadFailedTitle: string; + dlgDownloadStartFailedMsg: string; + dlgUpdateCheckFailedTitle: string; + dlgUpdateCheckFailedMsg: string; + dlgUpToDateTitle: string; + dlgUpdateAvailableTitle: string; + dlgOpenAccessLogTitle: string; + dlgExportLogAnalysisTitle: string; + dlgExportExtractionRulesTitle: string; + dlgImportExtractionRulesTitle: string; + dlgExportSettingsTitle: string; + dlgImportSettingsTitle: string; + dlgImportFailedTitle: string; + dlgImportFailedNoSettingsMsg: string; + dlgChooseFolderTitle: string; + dlgPlaywrightTitle: string; + dlgPlaywrightMsg: string; + dlgBrowserInstallFailedTitle: string; + dlgProjectSavedTitle: string; + dlgEncSnapshotSavedTitle: string; + dlgSaveDecryptedProjectTitle: string; + dlgBulkExportFolderTitle: string; + dlgBulkExportCompleteTitle: string; + dlgHtmlReportSavedTitle: string; + dlgSitemapGeneratedTitle: string; + + // ── Clear-crawl confirmation (migrated off inline isTr) ── + dlgConfirmClearMsg: string; + dlgConfirmClearDetail: string; + dlgDontAskAgain: string; } const MENU_EN: MenuLabels = { @@ -126,6 +193,71 @@ const MENU_EN: MenuLabels = { checkForUpdatesTooltip: 'Fetch the latest GitHub release and compare it with your installed version. No background polling — runs only when you click.', about: 'About FreeCrawl SEO', + + ctxCopy: 'Copy', + ctxOpenInBrowser: 'Open in Browser', + ctxRespider: 'Re-Spider', + ctxStartCrawlFirst: 'Start a crawl first', + ctxRemove: 'Remove', + ctxOpenRobotsTxt: 'Open robots.txt', + ctxCopyNUrls: 'Copy {n} URLs', + ctxOpenNUrlsInBrowser: 'Open {n} URLs in Browser', + ctxOpenLimitTooltip: 'Limited to 20 URLs to avoid spawning too many tabs', + ctxRespiderNUrls: 'Re-Spider {n} URLs', + ctxRemoveNUrls: 'Remove {n} URLs', + ctxExportNUrlsAsCsv: 'Export {n} URLs as CSV…', + + btnOk: 'OK', + btnCancel: 'Cancel', + btnClose: 'Close', + btnClear: 'Clear', + btnOpenFolder: 'Open Folder', + btnLater: 'Later', + btnOpenReleasePage: 'Open Release Page', + btnOpenReleasesPage: 'Open Releases Page', + btnDownloadInstaller: 'Download Installer', + btnDownloadNow: 'Download now', + btnSkipJsRender: 'Skip — disable JS render for this run', + + dlgOpenProjectTitle: 'Open Project', + dlgOpenProjectFailedTitle: 'Open Project Failed', + dlgLogsFolderUnavailableTitle: 'Logs Folder Unavailable', + dlgLogsFolderUnavailableMsg: + 'Disk logging has not been initialised. Logs are kept in memory only for this session.', + dlgDiagResetTitle: 'Diagnostic Warnings Reset', + dlgDiagResetNoneMsg: 'No suppressed diagnostic warnings to reset.', + dlgDownloadCompleteTitle: 'Download Complete', + dlgDownloadFailedTitle: 'Download Failed', + dlgDownloadStartFailedMsg: 'Could not start the download.', + dlgUpdateCheckFailedTitle: 'Update Check Failed', + dlgUpdateCheckFailedMsg: "Couldn't reach the GitHub Releases API.", + dlgUpToDateTitle: 'Up to Date', + dlgUpdateAvailableTitle: 'Update Available', + dlgOpenAccessLogTitle: 'Open Access Log', + dlgExportLogAnalysisTitle: 'Export Log Analysis', + dlgExportExtractionRulesTitle: 'Export Extraction Rules', + dlgImportExtractionRulesTitle: 'Import Extraction Rules', + dlgExportSettingsTitle: 'Export Settings', + dlgImportSettingsTitle: 'Import Settings', + dlgImportFailedTitle: 'Import Failed', + dlgImportFailedNoSettingsMsg: 'Imported file does not contain a settings object.', + dlgChooseFolderTitle: 'Choose Folder', + dlgPlaywrightTitle: 'JavaScript Rendering — Browser Missing', + dlgPlaywrightMsg: + 'Playwright needs to download a Chromium browser before JavaScript rendering can run.', + dlgBrowserInstallFailedTitle: 'Browser Install Failed', + dlgProjectSavedTitle: 'Project Saved', + dlgEncSnapshotSavedTitle: 'Encrypted Snapshot Saved', + dlgSaveDecryptedProjectTitle: 'Save Decrypted Project As…', + dlgBulkExportFolderTitle: 'Bulk Export — choose output folder', + dlgBulkExportCompleteTitle: 'Bulk Export Complete', + dlgHtmlReportSavedTitle: 'HTML Report Saved', + dlgSitemapGeneratedTitle: 'Sitemap Generated', + + dlgConfirmClearMsg: 'Clear all crawl data?', + dlgConfirmClearDetail: + 'This permanently deletes every crawled URL, link, image, header and source snapshot in the active project. This cannot be undone.', + dlgDontAskAgain: "Don't ask me again", }; const MENU_TR: MenuLabels = { @@ -187,6 +319,71 @@ const MENU_TR: MenuLabels = { checkForUpdatesTooltip: 'En son GitHub release\'ini çek ve kurulu sürümünüzle karşılaştır. Arka planda yoklama yok — yalnızca tıkladığınızda çalışır.', about: 'FreeCrawl SEO Hakkında', + + ctxCopy: 'Kopyala', + ctxOpenInBrowser: 'Tarayıcıda Aç', + ctxRespider: 'Yeniden Tara', + ctxStartCrawlFirst: 'Önce bir crawl başlatın', + ctxRemove: 'Kaldır', + ctxOpenRobotsTxt: 'robots.txt\'yi Aç', + ctxCopyNUrls: '{n} URL\'yi Kopyala', + ctxOpenNUrlsInBrowser: '{n} URL\'yi Tarayıcıda Aç', + ctxOpenLimitTooltip: 'Çok fazla sekme açılmasını önlemek için 20 URL ile sınırlı', + ctxRespiderNUrls: '{n} URL\'yi Yeniden Tara', + ctxRemoveNUrls: '{n} URL\'yi Kaldır', + ctxExportNUrlsAsCsv: '{n} URL\'yi CSV Olarak Dışa Aktar…', + + btnOk: 'Tamam', + btnCancel: 'İptal', + btnClose: 'Kapat', + btnClear: 'Temizle', + btnOpenFolder: 'Klasörü Aç', + btnLater: 'Sonra', + btnOpenReleasePage: 'Release Sayfasını Aç', + btnOpenReleasesPage: 'Release Sayfasını Aç', + btnDownloadInstaller: 'Kurulumu İndir', + btnDownloadNow: 'Şimdi indir', + btnSkipJsRender: 'Atla — bu çalıştırmada JS render\'ı devre dışı bırak', + + dlgOpenProjectTitle: 'Proje Aç', + dlgOpenProjectFailedTitle: 'Proje Açılamadı', + dlgLogsFolderUnavailableTitle: 'Log Klasörü Kullanılamıyor', + dlgLogsFolderUnavailableMsg: + 'Diske log yazma başlatılmadı. Loglar bu oturum için yalnızca bellekte tutuluyor.', + dlgDiagResetTitle: 'Tanı Uyarıları Sıfırlandı', + dlgDiagResetNoneMsg: 'Sıfırlanacak bastırılmış tanı uyarısı yok.', + dlgDownloadCompleteTitle: 'İndirme Tamamlandı', + dlgDownloadFailedTitle: 'İndirme Başarısız', + dlgDownloadStartFailedMsg: 'İndirme başlatılamadı.', + dlgUpdateCheckFailedTitle: 'Güncelleme Kontrolü Başarısız', + dlgUpdateCheckFailedMsg: 'GitHub Releases API\'sine ulaşılamadı.', + dlgUpToDateTitle: 'Güncel', + dlgUpdateAvailableTitle: 'Güncelleme Mevcut', + dlgOpenAccessLogTitle: 'Erişim Logu Aç', + dlgExportLogAnalysisTitle: 'Log Analizini Dışa Aktar', + dlgExportExtractionRulesTitle: 'Çıkarım Kurallarını Dışa Aktar', + dlgImportExtractionRulesTitle: 'Çıkarım Kurallarını İçe Aktar', + dlgExportSettingsTitle: 'Ayarları Dışa Aktar', + dlgImportSettingsTitle: 'Ayarları İçe Aktar', + dlgImportFailedTitle: 'İçe Aktarma Başarısız', + dlgImportFailedNoSettingsMsg: 'İçe aktarılan dosya bir ayarlar nesnesi içermiyor.', + dlgChooseFolderTitle: 'Klasör Seç', + dlgPlaywrightTitle: 'JavaScript Render — Tarayıcı Eksik', + dlgPlaywrightMsg: + 'JavaScript render çalışabilmeden önce Playwright\'ın bir Chromium tarayıcısı indirmesi gerekiyor.', + dlgBrowserInstallFailedTitle: 'Tarayıcı Kurulumu Başarısız', + dlgProjectSavedTitle: 'Proje Kaydedildi', + dlgEncSnapshotSavedTitle: 'Şifreli Snapshot Kaydedildi', + dlgSaveDecryptedProjectTitle: 'Çözülmüş Projeyi Farklı Kaydet…', + dlgBulkExportFolderTitle: 'Toplu Dışa Aktarım — çıktı klasörünü seçin', + dlgBulkExportCompleteTitle: 'Toplu Dışa Aktarım Tamamlandı', + dlgHtmlReportSavedTitle: 'HTML Rapor Kaydedildi', + dlgSitemapGeneratedTitle: 'Sitemap Oluşturuldu', + + dlgConfirmClearMsg: 'Tüm crawl verileri temizlensin mi?', + dlgConfirmClearDetail: + 'Bu işlem, aktif projedeki taranmış her URL, link, görsel, başlık ve kaynak snapshot\'ını kalıcı olarak siler. Geri alınamaz.', + dlgDontAskAgain: 'Bir daha sorma', }; export function getMenuLabels(lang: MenuLang): MenuLabels { diff --git a/apps/desktop/src/main/seo-providers.ts b/apps/desktop/src/main/seo-providers.ts index 8174cd9..0c3d605 100644 --- a/apps/desktop/src/main/seo-providers.ts +++ b/apps/desktop/src/main/seo-providers.ts @@ -25,6 +25,7 @@ import type { SeoProvider, } from '@freecrawl/shared-types'; import { resolveCredentials } from './credentials.js'; +import { apiFetch } from './api-fetch.js'; const REQUEST_TIMEOUT_MS = 60_000; @@ -97,7 +98,7 @@ async function ahrefsFetch( }); let res: Response; try { - res = await fetch( + res = await apiFetch( `https://api.ahrefs.com/v3/site-explorer/${call.path}?${params.toString()}`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' }, @@ -222,7 +223,7 @@ async function fetchMajestic(url: string): Promise { item0: url, app_api_key: apiKey, }); - const res = await fetch(`https://api.majestic.com/api/json?${params.toString()}`, { + const res = await apiFetch(`https://api.majestic.com/api/json?${params.toString()}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const json = (await res.json().catch(() => null)) as Record | null; @@ -259,7 +260,7 @@ async function fetchMoz(url: string): Promise { ); } const basic = Buffer.from(`${accessId}:${secretKey}`).toString('base64'); - const res = await fetch('https://lsapi.seomoz.com/v2/url_metrics', { + const res = await apiFetch('https://lsapi.seomoz.com/v2/url_metrics', { method: 'POST', headers: { Authorization: `Basic ${basic}`, @@ -304,7 +305,7 @@ async function fetchSemrush(url: string): Promise { display_format: 'json', export_columns: 'Or,Ot,Oc,Ad', }); - const res = await fetch(`https://api.semrush.com/?${params.toString()}`, { + const res = await apiFetch(`https://api.semrush.com/?${params.toString()}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const text = await res.text(); diff --git a/apps/desktop/src/main/sheets-export.ts b/apps/desktop/src/main/sheets-export.ts index ace1fbe..3cf5e19 100644 --- a/apps/desktop/src/main/sheets-export.ts +++ b/apps/desktop/src/main/sheets-export.ts @@ -15,6 +15,7 @@ import type { ProjectDb } from '@freecrawl/db'; import type { CrawlUrlRow, UrlCategory } from '@freecrawl/shared-types'; import { getAccessToken } from './google-oauth.js'; +import { apiFetch } from './api-fetch.js'; import * as logger from './logger.js'; const MAX_ROWS = 50_000; @@ -63,7 +64,7 @@ export async function exportCategoryToSheets( const token = await getAccessToken('sheets'); // 1. Create the spreadsheet. - const createRes = await fetch( + const createRes = await apiFetch( 'https://sheets.googleapis.com/v4/spreadsheets', { method: 'POST', @@ -103,7 +104,7 @@ export async function exportCategoryToSheets( // 3. Write the values. if (count > 0) { - const writeRes = await fetch( + const writeRes = await apiFetch( `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/A1?valueInputOption=RAW`, { method: 'PUT', diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index a0c6aea..e934da2 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -24,7 +24,7 @@ import { AnalyticsTab } from './tabs/AnalyticsTab.js'; import { AiTab } from './tabs/AiTab.js'; import { SeoTab } from './tabs/SeoTab.js'; import { useAppStore } from './store.js'; -import type { MenuEvent } from '@freecrawl/shared-types'; +import type { MenuEvent, CrawlProgress } from '@freecrawl/shared-types'; import { clearCrawlWithConfirm } from './utils/clearCrawl.js'; export function App() { @@ -199,7 +199,7 @@ export function App() { // frame caps the React work at 60 Hz worst-case (and at 5 Hz in // the common case where the crawler's throttle dominates), so a // user click never queues behind a stack of pending re-renders. - let pendingProgress: import('@freecrawl/shared-types').CrawlProgress | null = null; + let pendingProgress: CrawlProgress | null = null; let progressRafScheduled = false; const off1 = window.freecrawl.onProgress((p) => { pendingProgress = p; diff --git a/apps/desktop/src/renderer/src/LogAnalyzerView.tsx b/apps/desktop/src/renderer/src/LogAnalyzerView.tsx index 6430210..7db50ca 100644 --- a/apps/desktop/src/renderer/src/LogAnalyzerView.tsx +++ b/apps/desktop/src/renderer/src/LogAnalyzerView.tsx @@ -383,12 +383,21 @@ function UrlStatsTab({ version, mode }: { version: number; mode: 'all' | 'orphan }, [search, filter, bot, sortBy, version]); useEffect(() => { + // Cancellation guard: fast typing / filter changes fire overlapping + // queries, and without this the slower (older) response can resolve last + // and overwrite the newer result set, showing rows for a filter the user + // no longer has selected. + let cancelled = false; const input: LogUrlStatsInput = { limit: PAGE, offset, search, sortBy, filter, bot: bot || undefined }; const call = mode === 'orphans' ? window.freecrawl.logOrphans(input) : window.freecrawl.logUrlStats(input); void call.then((r) => { + if (cancelled) return; setRows(r.rows); setTotal(r.total); }); + return () => { + cancelled = true; + }; }, [offset, search, filter, bot, sortBy, version, mode]); // The single filter @@ -1870,7 +1892,12 @@ function CustomExtractionPanel({ form, update }: PanelProps) { // silently dropping — invalid JSON is the most common // import failure mode and the user needs to know. // eslint-disable-next-line no-alert - alert(`Import failed: ${result.error}`); + alert( + t('settingsPanels.customExtraction.importFailedAlert', { + defaultValue: 'Import failed: {{error}}', + error: result.error, + }), + ); } return; } @@ -1878,7 +1905,9 @@ function CustomExtractionPanel({ form, update }: PanelProps) { // eslint-disable-next-line no-alert alert( result.error ?? - 'No valid rules found in the file.', + t('settingsPanels.customExtraction.noValidRules', { + defaultValue: 'No valid rules found in the file.', + }), ); return; } @@ -3559,10 +3588,10 @@ function RenderingPanel({ form, update }: PanelProps) { ) } > - - - - + + + + (null); // Per-integration → per-field draft text. Only fields the user has // actually typed into are tracked; a save sends just these so an @@ -3734,7 +3763,7 @@ function IntegrationsPanel() { return (
- {cat.label} + {translateLabel(cat.label, i18n.language)}
{items.map((def) => ( @@ -3777,7 +3806,7 @@ function IntegrationCard({ onSave: () => void; onClear: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const configured = state?.configured ?? false; const hasDraft = Object.values(draft).some((v) => v.length > 0); const [guideOpen, setGuideOpen] = useState(false); @@ -3787,7 +3816,7 @@ function IntegrationCard({
{def.name} - {AUTH_TYPE_LABEL[def.authType]} + {translateLabel(AUTH_TYPE_LABEL[def.authType], i18n.language)}

- {t('settings.storage.intro')} + {t('settings.storage.intro', { defaultValue: "Default folder for new .seoproject files. Save As dialogs open here by default; the Open dialog is not constrained." })}

- {t('settings.storage.defaultSaveFolder')} + {t('settings.storage.defaultSaveFolder', { defaultValue: "Default Save Folder" })}
{t('common.reset')} )}

- {t('settings.storage.resolvedPath')}:{' '} + {t('settings.storage.resolvedPath', { defaultValue: "Resolved path" })}:{' '} {resolved || '…'}

- {t('settings.storage.note')} + {t('settings.storage.note', { defaultValue: "Active crawl data lives in the app's user-data directory until you Save As — the path above only controls where Save As starts. Changing this won't move any existing project files." })}
); @@ -4020,12 +4049,12 @@ function PrivacyPanel() { return ( <>

- {t('settings.privacy.intro')} + {t('settings.privacy.intro', { defaultValue: "FreeCrawl is local-first — crawl results, project files, and URLs you visit never leave your machine." })}

- {t('settings.privacy.telemetryHeading')} + {t('settings.privacy.telemetryHeading', { defaultValue: "Anonymous Telemetry" })}
- {t('settings.privacy.statusNote')} + {t('settings.privacy.statusNote', { defaultValue: "No telemetry endpoint is currently wired up. Toggling this preference is recorded but nothing is transmitted in the current build. The toggle is exposed now so consent state persists across upgrades when a future build adds an endpoint." })}
); @@ -4069,12 +4098,12 @@ function LanguagePanel() { return ( <>

- {t('settings.language.intro')} + {t('settings.language.intro', { defaultValue: "Select the UI display language. Applied immediately." })}

- {t('settings.language.label')} + {t('settings.language.label', { defaultValue: "UI language" })}
{SUPPORTED_LANGUAGES.map((lng) => ( @@ -4094,7 +4123,7 @@ function LanguagePanel() {
- {t('settings.language.note')} + {t('settings.language.note', { defaultValue: "Some deep panels and dialogs may still display English while broader coverage is being added." })}
); diff --git a/apps/desktop/src/renderer/src/components/StatsBar.tsx b/apps/desktop/src/renderer/src/components/StatsBar.tsx index 1bb43b9..562bcd1 100644 --- a/apps/desktop/src/renderer/src/components/StatsBar.tsx +++ b/apps/desktop/src/renderer/src/components/StatsBar.tsx @@ -138,10 +138,15 @@ export function StatsBar() { valueClassName={fpsClass(perf.fps)} title={ perf.fps >= 50 - ? 'Renderer is smooth (≥ 50 fps)' + ? t('stats.fpsSmoothTitle', { defaultValue: 'Renderer is smooth (≥ 50 fps)' }) : perf.fps >= 30 - ? 'Renderer is degraded (30–49 fps) — likely competing with crawl IPC' - : 'Renderer is stalled (< 30 fps) — main thread starved; pause crawl or close Logs window' + ? t('stats.fpsDegradedTitle', { + defaultValue: 'Renderer is degraded (30–49 fps) — likely competing with crawl IPC', + }) + : t('stats.fpsStalledTitle', { + defaultValue: + 'Renderer is stalled (< 30 fps) — main thread starved; pause crawl or close Logs window', + }) } /> {perf.heapMb !== null && ( @@ -149,7 +154,9 @@ export function StatsBar() { label={t('stats.heap')} value={`${perf.heapMb} MB`} valueClassName={heapClass(perf.heapMb)} - title="Renderer JS heap. >500 MB = warm, >1 GB = likely a listener / cache leak" + title={t('stats.heapTitle', { + defaultValue: 'Renderer JS heap. >500 MB = warm, >1 GB = likely a listener / cache leak', + })} /> )} {mem && ( @@ -158,26 +165,41 @@ export function StatsBar() { label={t('stats.rss')} value={formatBytes(mem.rss)} valueClassName={rssClass(mem.rss, mem.systemTotal)} - title={`Main-process resident set size (Electron + workers). System total: ${formatBytes(mem.systemTotal)}.`} + title={t('stats.rssTitle', { + defaultValue: + 'Main-process resident set size (Electron + workers). System total: {{total}}.', + total: formatBytes(mem.systemTotal), + })} /> {perUrlCost !== null && ( )} {capacity !== null && ( )} @@ -188,10 +210,17 @@ export function StatsBar() { valueClassName={lagClass(perf.inputLagMs)} title={ perf.inputLagMs < 16 - ? 'Main thread is responsive — input feels instant' + ? t('stats.lagResponsiveTitle', { + defaultValue: 'Main thread is responsive — input feels instant', + }) : perf.inputLagMs < 50 - ? 'Main thread is contended — light click stutter' - : 'Main thread is busy — IPC backed up; most likely sidebar SQL or table chunk fetch competing with the crawler' + ? t('stats.lagContendedTitle', { + defaultValue: 'Main thread is contended — light click stutter', + }) + : t('stats.lagBusyTitle', { + defaultValue: + 'Main thread is busy — IPC backed up; most likely sidebar SQL or table chunk fetch competing with the crawler', + }) } /> diff --git a/apps/desktop/src/renderer/src/hooks/usePerfMeter.ts b/apps/desktop/src/renderer/src/hooks/usePerfMeter.ts index 63e69a6..44708bf 100644 --- a/apps/desktop/src/renderer/src/hooks/usePerfMeter.ts +++ b/apps/desktop/src/renderer/src/hooks/usePerfMeter.ts @@ -53,7 +53,7 @@ export function usePerfMeter(): PerfSample { useEffect(() => { let cancelled = false; let rafId = 0; - let frameTimes: number[] = []; + const frameTimes: number[] = []; let lastEmittedFps = -1; let lastEmittedHeap = -1; let lastEmittedLag = -1; diff --git a/apps/desktop/src/renderer/src/i18n/labels.ts b/apps/desktop/src/renderer/src/i18n/labels.ts index 2d40edc..b936504 100644 --- a/apps/desktop/src/renderer/src/i18n/labels.ts +++ b/apps/desktop/src/renderer/src/i18n/labels.ts @@ -18,6 +18,20 @@ */ const TR_LABELS: Record = { + // Settings — User-Agent presets + 'Googlebot — Smartphone': 'Googlebot — Akıllı Telefon', + 'Googlebot — Desktop': 'Googlebot — Masaüstü', + 'Chrome — Desktop': 'Chrome — Masaüstü', + 'Chrome — Mobile (Pixel)': 'Chrome — Mobil (Pixel)', + 'FreeCrawl SEO (default)': 'FreeCrawl SEO (varsayılan)', + // Settings — Integration categories (Performance is defined below with the sub-tab labels) + AI: 'Yapay Zeka', + 'SEO Data': 'SEO Verisi', + // Settings — Integration auth types + 'API Key': 'API Anahtarı', + 'OAuth (your own client)': 'OAuth (kendi istemciniz)', + 'Service Account': 'Servis Hesabı', + Local: 'Yerel', // Top-level groups Summary: 'Özet', 'Crawl Data': 'Crawl Verisi', @@ -83,9 +97,48 @@ const TR_LABELS: Record = { // Indexability Indexable: 'İndekslenebilir', Noindex: 'Noindex', - Canonicalised: 'Canonicalised', + Canonicalised: 'Canonical edilmiş', 'Canonicalised (→ other)': 'Canonicalised (→ başka)', 'Non-Indexable': 'İndekslenemez', + // Indexability status (indexabilityStatusLabel values) + 'Blocked by robots.txt': 'robots.txt tarafından engellendi', + Redirected: 'Yönlendirildi', + 'Client Error': 'İstemci Hatası', + 'Server Error': 'Sunucu Hatası', + + // Analytics detail panel (AnalyticsRow labels) + Clicks: 'Tıklamalar', + Impressions: 'Gösterimler', + CTR: 'TO', + 'Avg position': 'Ort. konum', + Sessions: 'Oturumlar', + Users: 'Kullanıcılar', + Pageviews: 'Sayfa görüntülemeleri', + 'Engagement rate': 'Etkileşim oranı', + 'Avg session duration': 'Ort. oturum süresi', + Verdict: 'Karar', + Coverage: 'Kapsam', + Indexing: 'İndeksleme', + 'Last crawl': 'Son tarama', + 'Google canonical': 'Google canonical', + 'User canonical': 'Kullanıcı canonical', + + // Settings crawl presets (PRESETS labels + descriptions) + Fast: 'Hızlı', + Thorough: 'Kapsamlı', + 'Mobile-only': 'Yalnızca mobil', + 'Desktop-only': 'Yalnızca masaüstü', + Aggressive: 'Agresif', + 'High concurrency, short timeouts — for a quick first sweep on a healthy site. Skips media + retries.': + 'Yüksek eşzamanlılık, kısa zaman aşımları — sağlıklı bir sitede hızlı ilk tarama için. Medya + yeniden denemeleri atlar.', + 'Lower concurrency + extra retries; captures more on flaky origins. The default for large audits.': + 'Daha düşük eşzamanlılık + ek yeniden denemeler; kararsız kaynaklarda daha fazla yakalar. Büyük denetimler için varsayılan.', + 'Mimic Googlebot Smartphone — primary signal for mobile-first indexing. Combine with viewport audits.': + 'Googlebot Smartphone taklidi — mobil öncelikli indeksleme için birincil sinyal. Viewport denetimleriyle birleştirin.', + 'Mimic legacy Googlebot Desktop. Useful for comparing mobile vs. desktop renders.': + 'Eski Googlebot Desktop taklidi. Mobil ve masaüstü render karşılaştırması için kullanışlı.', + 'High parallelism + ignore robots — only for sites you own. Can trip rate-limit / WAF rules; use with caution.': + 'Yüksek paralellik + robots yok sayılır — yalnızca sahip olduğunuz siteler için. Hız sınırı / WAF kurallarını tetikleyebilir; dikkatli kullanın.', // Issues parent 'Page Titles': 'Sayfa Başlıkları', diff --git a/apps/desktop/src/renderer/src/i18n/locales/en.json b/apps/desktop/src/renderer/src/i18n/locales/en.json index d8c0c2e..b009ab6 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en.json @@ -90,7 +90,18 @@ "running": "Running", "paused": "Paused", "idle": "Idle", - "dismiss": "dismiss" + "dismiss": "dismiss", + "fpsSmoothTitle": "Renderer is smooth (≥ 50 fps)", + "fpsDegradedTitle": "Renderer is degraded (30–49 fps) — likely competing with crawl IPC", + "fpsStalledTitle": "Renderer is stalled (< 30 fps) — main thread starved; pause crawl or close Logs window", + "heapTitle": "Renderer JS heap. >500 MB = warm, >1 GB = likely a listener / cache leak", + "rssTitle": "Main-process resident set size (Electron + workers). System total: {{total}}.", + "sysFreeTitle": "OS-reported free memory. < 10% of system total triggers swap; pause the crawl before that. System total: {{total}}.", + "perUrlTitle": "Average bytes of RSS per crawled URL ({{urls}} URLs). Includes Electron overhead, so the marginal cost on real-world large crawls is typically lower.", + "capacityTitle": "Estimated additional URLs that fit in remaining system memory at the current per-URL cost. Calc: systemFree / perUrlCost. Treat as an upper bound — sustained throughput is usually CPU-bound first.", + "lagResponsiveTitle": "Main thread is responsive — input feels instant", + "lagContendedTitle": "Main thread is contended — light click stutter", + "lagBusyTitle": "Main thread is busy — IPC backed up; most likely sidebar SQL or table chunk fetch competing with the crawler" }, "menu": { "file": "File", @@ -233,7 +244,13 @@ "aggregatingFirst": "aggregating first {{n}}", "perPagePrefix": "This tab is per-page — showing data for", "perPageSuffix": "(primary URL of the selection).", - "selectUrl": "Select a URL from the table to see details." + "selectUrl": "Select a URL from the table to see details.", + "httpResponseTab": "Response ({{count}})", + "httpRequestTab": "Request ({{count}})", + "headersReconstructedNote": "Reconstructed from active crawl config — actual values may have differed if config changed since crawl.", + "headersCapturedNote": "Captured at crawl time.", + "noResponseHeaders": "No response headers captured for this URL.", + "noRequestHeaders": "No request headers — config may be empty." }, "filter": { "title": "Advanced Table Search", @@ -404,7 +421,15 @@ "crawlDelayMs": "Per-Worker Delay (ms after each request)", "retriesHeading": "Retries", "retryAttempts": "Retry Attempts (per URL on transient errors)", - "retryInitialDelayMs": "Initial Retry Delay (ms — exponential backoff)" + "retryInitialDelayMs": "Initial Retry Delay (ms — exponential backoff)", + "tipsHeading": "Throughput tips", + "ceilingValue": "~{{rps}} URL/s ({{workers}} parallel workers, ≤ {{cap}} RPS rate-limit{{delaySuffix}})", + "ceilingDelaySuffix": ", +{{delay}} ms post-request delay per worker", + "ceilingEmpty": "Set Max Concurrency and Max RPS to see the projected throughput.", + "worstCaseDelay": "Worst-case delay per failed URL ≈ initialDelay × (2 ^ attempts − 1) = {{value}}", + "tip1": "For a quick first sweep on a healthy site try the Fast preset (Settings → Presets) — concurrency 40, RPS 40, no retries.", + "tip2": "For unreliable origins or slow APIs try Thorough — concurrency 10, 3 retries, 30 s timeout.", + "tip3": "Concurrency × HTTP keep-alive = the steady-state connection count. Most servers comfortably handle 20–40; corporate proxies / WAFs often cap at 8–10." }, "requests": { "intro": "HTTP headers sent with every request.", @@ -466,7 +491,9 @@ "exportTitle": "Save the current rule set as a JSON file. Versioned envelope, safe to commit to source control.", "exportDisabled": "Add at least one rule before exporting.", "importConfirm": "Replace {{existing}} existing rule(s) with {{incoming}} imported rule(s)?", - "importSkipped": "Imported {{ok}} rule(s). {{skipped}} entries were skipped (invalid shape or over the 10-rule cap)." + "importSkipped": "Imported {{ok}} rule(s). {{skipped}} entries were skipped (invalid shape or over the 10-rule cap).", + "importFailedAlert": "Import failed: {{error}}", + "noValidRules": "No valid rules found in the file." }, "urlRewriting": { "intro": "Normalisation applied before URLs are deduplicated and queued.", @@ -638,7 +665,11 @@ "advancedAudits": "Advanced Audits", "mobileUsability": "Mobile Usability audit", "lcpCandidate": "LCP candidate detection", - "a11yAudit": "Accessibility audit (contrast + focus)" + "a11yAudit": "Accessibility audit (contrast + focus)", + "screenshotNone": "None (default)", + "screenshotFold": "Above the fold", + "screenshotFull": "Full page", + "screenshotBoth": "Both (full page + fold)" }, "performanceBudget": { "intro": "Set per-page performance ceilings. After each crawl, internal HTML pages that exceed any enabled threshold are flagged under the \"Over Performance Budget\" issue filter. Response time and page size come from the crawl itself; LCP and CLS use PageSpeed Insights data when present. Set any field to 0 to disable that individual check.", @@ -806,7 +837,8 @@ "external": "external", "largeCount": "{{n}} > 100 KB", "multiEmpty": "No tags discovered across the {{n}} selected pages.", - "multiSummary": "images across {{n}} pages" + "multiSummary": "images across {{n}} pages", + "showingFirst": "showing first {{shown}} of {{total}}" }, "links": { "showing": "Showing", @@ -928,7 +960,9 @@ "edgeOpacity": "Edge opacity", "edgeOpacityHint": "Lower = less visual noise on dense graphs.", "changesNeedRerun": "Some changes need a layout re-run.", - "rerunLayout": "Re-run layout" + "rerunLayout": "Re-run layout", + "title": "Visualization", + "occurrences": "{{count}} occurrences" }, "imagesTab": { "searchPlaceholder": "Search image URLs / alt…", @@ -1161,7 +1195,27 @@ "treeLoading": "Loading URL tree…", "treeEmpty": "No URLs to display — crawl a site or change the filter.", "expandAll": "Expand all", - "collapseAll": "Collapse all" + "collapseAll": "Collapse all", + "noUrlsHint": "Start a crawl or choose a different category.", + "statusUrls": "URLs:", + "statusLoaded": "{{count}} loaded", + "statusSelectedRows": "Selected Rows:", + "statusSelectedCells": "Selected Cells:", + "statusSelectedColumns": "Selected Columns:", + "rowHeader": "Row", + "rowNumberTitle": "Row number", + "rowSelectHint": "Click to select row · drag to select multiple rows", + "resizeHint": "Drag to resize · double-click to reset", + "sortByColumn": "Sort by this column", + "columnSortedAscHint": "Sorted ascending — click to flip", + "columnSortedDescHint": "Sorted descending — click to flip", + "columnSortedHint": "Sorted {{dir}} — click to flip", + "sortAsc": "ascending", + "sortDesc": "descending" + }, + "errorBoundary": { + "panelBroke": "Something broke in this panel.", + "dismiss": "Dismiss" }, "settings": { "title": "Settings", diff --git a/apps/desktop/src/renderer/src/i18n/locales/tr.json b/apps/desktop/src/renderer/src/i18n/locales/tr.json index 675ecb0..5778a63 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/tr.json +++ b/apps/desktop/src/renderer/src/i18n/locales/tr.json @@ -90,7 +90,18 @@ "running": "Çalışıyor", "paused": "Duraklatıldı", "idle": "Boşta", - "dismiss": "kapat" + "dismiss": "kapat", + "fpsSmoothTitle": "Arayüz akıcı (≥ 50 fps)", + "fpsDegradedTitle": "Arayüz yavaşladı (30–49 fps) — muhtemelen crawl IPC ile yarışıyor", + "fpsStalledTitle": "Arayüz takıldı (< 30 fps) — ana iş parçacığı aç kaldı; crawl'ı duraklatın veya Loglar penceresini kapatın", + "heapTitle": "Renderer JS belleği (heap). >500 MB = ısınmış, >1 GB = muhtemelen listener / önbellek sızıntısı", + "rssTitle": "Ana süreç yerleşik bellek boyutu (Electron + worker'lar). Sistem toplamı: {{total}}.", + "sysFreeTitle": "İşletim sisteminin bildirdiği boş bellek. Sistem toplamının < %10'u takas (swap) tetikler; ondan önce crawl'ı duraklatın. Sistem toplamı: {{total}}.", + "perUrlTitle": "Taranan URL başına ortalama RSS baytı ({{urls}} URL). Electron ek yükünü içerir; gerçek dünyadaki büyük crawl'larda marjinal maliyet genelde daha düşüktür.", + "capacityTitle": "Mevcut URL-başına maliyetle kalan sistem belleğine sığacak tahmini ek URL sayısı. Hesap: boşBellek / urlBaşınaMaliyet. Üst sınır olarak değerlendirin — sürekli verim genelde önce CPU'ya bağlıdır.", + "lagResponsiveTitle": "Ana iş parçacığı yanıt veriyor — girdi anında hissediliyor", + "lagContendedTitle": "Ana iş parçacığı yoğun — hafif tıklama takılması", + "lagBusyTitle": "Ana iş parçacığı meşgul — IPC birikti; büyük olasılıkla kenar çubuğu SQL'i veya tablo parça getirme crawler ile yarışıyor" }, "menu": { "file": "Dosya", @@ -233,7 +244,13 @@ "aggregatingFirst": "ilk {{n}} toplanıyor", "perPagePrefix": "Bu sekme sayfa başınadır — şu URL için veri gösteriliyor:", "perPageSuffix": "(seçimin birincil URL'i).", - "selectUrl": "Detayları görmek için tablodan bir URL seç." + "selectUrl": "Detayları görmek için tablodan bir URL seç.", + "httpResponseTab": "Yanıt ({{count}})", + "httpRequestTab": "İstek ({{count}})", + "headersReconstructedNote": "Aktif tarama yapılandırmasından yeniden oluşturuldu — taramadan sonra yapılandırma değiştiyse gerçek değerler farklı olabilir.", + "headersCapturedNote": "Tarama sırasında yakalandı.", + "noResponseHeaders": "Bu URL için yanıt başlığı yakalanmadı.", + "noRequestHeaders": "İstek başlığı yok — yapılandırma boş olabilir." }, "filter": { "title": "Gelişmiş Tablo Arama", @@ -250,6 +267,7 @@ "noValue": "(değer gerekmiyor)" }, "reports": { + "reportLabel": "Rapor", "report": { "pagesPerDir": "Dizin Başına Sayfa", "statusCodes": "Durum Kodu Histogramı", @@ -403,7 +421,15 @@ "crawlDelayMs": "Worker Başına Gecikme (her istekten sonra, ms)", "retriesHeading": "Yeniden Denemeler", "retryAttempts": "Yeniden Deneme Sayısı (geçici hatada URL başına)", - "retryInitialDelayMs": "İlk Yeniden Deneme Gecikmesi (ms — üstel backoff)" + "retryInitialDelayMs": "İlk Yeniden Deneme Gecikmesi (ms — üstel backoff)", + "tipsHeading": "Verimlilik ipuçları", + "ceilingValue": "~{{rps}} URL/sn ({{workers}} paralel worker, ≤ {{cap}} RPS rate-limit{{delaySuffix}})", + "ceilingDelaySuffix": ", worker başına istek sonrası +{{delay}} ms gecikme", + "ceilingEmpty": "Öngörülen verimi görmek için Maks Paralellik ve Maks RPS değerlerini ayarlayın.", + "worstCaseDelay": "Başarısız URL başına en kötü durum gecikmesi ≈ ilkGecikme × (2 ^ deneme − 1) = {{value}}", + "tip1": "Sağlıklı bir sitede hızlı bir ilk tarama için Fast ön ayarını deneyin (Ayarlar → Ön Ayarlar) — paralellik 40, RPS 40, yeniden deneme yok.", + "tip2": "Güvenilmez kaynaklar veya yavaş API'ler için Thorough deneyin — paralellik 10, 3 yeniden deneme, 30 sn zaman aşımı.", + "tip3": "Paralellik × HTTP keep-alive = kararlı durumdaki bağlantı sayısı. Çoğu sunucu rahatça 20–40 taşır; kurumsal proxy'ler / WAF'lar genelde 8–10'da sınırlar." }, "requests": { "intro": "Her istekle gönderilen HTTP başlıkları.", @@ -465,7 +491,9 @@ "exportTitle": "Mevcut kural setini JSON dosyasına kaydet. Versiyonlu sarmal, kaynak kontrolüne commit'lenebilir.", "exportDisabled": "Dışa aktarmadan önce en az bir kural ekle.", "importConfirm": "Mevcut {{existing}} kural, içe aktarılan {{incoming}} kuralla değiştirilsin mi?", - "importSkipped": "{{ok}} kural içe aktarıldı. {{skipped}} kayıt atlandı (geçersiz şema veya 10 kuralın üzerinde)." + "importSkipped": "{{ok}} kural içe aktarıldı. {{skipped}} kayıt atlandı (geçersiz şema veya 10 kuralın üzerinde).", + "importFailedAlert": "İçe aktarma başarısız: {{error}}", + "noValidRules": "Dosyada geçerli kural bulunamadı." }, "urlRewriting": { "intro": "URL'ler yinelemeden ve kuyruğa alınmadan önce uygulanan normalleştirme.", @@ -637,7 +665,11 @@ "advancedAudits": "Gelişmiş Denetimler", "mobileUsability": "Mobil Kullanılabilirlik denetimi", "lcpCandidate": "LCP aday tespiti", - "a11yAudit": "Erişilebilirlik denetimi (kontrast + odak)" + "a11yAudit": "Erişilebilirlik denetimi (kontrast + odak)", + "screenshotNone": "Yok (varsayılan)", + "screenshotFold": "Katlama üstü", + "screenshotFull": "Tam sayfa", + "screenshotBoth": "Her ikisi (tam sayfa + katlama)" }, "performanceBudget": { "intro": "Sayfa başına performans tavanları belirleyin. Her crawl'dan sonra, etkin eşiklerden herhangi birini aşan dahili HTML sayfaları \"Performans Bütçesini Aşan\" issue filtresinde işaretlenir. Yanıt süresi ve sayfa boyutu crawl'dan; LCP ve CLS mevcutsa PageSpeed Insights verisinden gelir. Bir kontrolü devre dışı bırakmak için ilgili alanı 0 yapın.", @@ -805,7 +837,8 @@ "external": "harici", "largeCount": "{{n}} > 100 KB", "multiEmpty": "Seçili {{n}} sayfada etiketi bulunamadı.", - "multiSummary": "{{n}} sayfada görseller" + "multiSummary": "{{n}} sayfada görseller", + "showingFirst": "ilk {{shown}} / {{total}} gösteriliyor" }, "links": { "showing": "Gösterilen", @@ -927,7 +960,9 @@ "edgeOpacity": "Kenar opaklığı", "edgeOpacityHint": "Düşük = yoğun grafiklerde daha az görsel gürültü.", "changesNeedRerun": "Bazı değişiklikler layout yeniden çalıştırması gerektirir.", - "rerunLayout": "Layout'u yeniden çalıştır" + "rerunLayout": "Layout'u yeniden çalıştır", + "title": "Görselleştirme", + "occurrences": "{{count}} kez geçiyor" }, "imagesTab": { "searchPlaceholder": "Görsel URL / alt ara…", @@ -1160,7 +1195,27 @@ "treeLoading": "URL ağacı yükleniyor…", "treeEmpty": "Gösterilecek URL yok — bir site tara veya filtreyi değiştir.", "expandAll": "Tümünü genişlet", - "collapseAll": "Tümünü daralt" + "collapseAll": "Tümünü daralt", + "noUrlsHint": "Bir crawl başlatın veya farklı bir kategori seçin.", + "statusUrls": "URL:", + "statusLoaded": "{{count}} yüklendi", + "statusSelectedRows": "Seçili Satırlar:", + "statusSelectedCells": "Seçili Hücreler:", + "statusSelectedColumns": "Seçili Sütunlar:", + "rowHeader": "Satır", + "rowNumberTitle": "Satır numarası", + "rowSelectHint": "Satır seçmek için tıkla · birden çok satır için sürükle", + "resizeHint": "Yeniden boyutlandırmak için sürükle · sıfırlamak için çift tıkla", + "sortByColumn": "Bu sütuna göre sırala", + "columnSortedAscHint": "Artan sıralandı — çevirmek için tıkla", + "columnSortedDescHint": "Azalan sıralandı — çevirmek için tıkla", + "columnSortedHint": "{{dir}} sıralandı — ters çevirmek için tıklayın", + "sortAsc": "artan", + "sortDesc": "azalan" + }, + "errorBoundary": { + "panelBroke": "Bu panelde bir şeyler bozuldu.", + "dismiss": "Kapat" }, "settings": { "title": "Ayarlar", diff --git a/apps/desktop/src/renderer/src/tabs/UrlsTab.tsx b/apps/desktop/src/renderer/src/tabs/UrlsTab.tsx index 0ee9fd8..6f14f61 100644 --- a/apps/desktop/src/renderer/src/tabs/UrlsTab.tsx +++ b/apps/desktop/src/renderer/src/tabs/UrlsTab.tsx @@ -1030,9 +1030,9 @@ export function UrlsTab() { zIndex: 13, background: 'rgb(23 23 23)', }} - title="Row number" + title={t('urlsTab.rowNumberTitle', { defaultValue: 'Row number' })} > - Row + {t('urlsTab.rowHeader', { defaultValue: 'Row' })}
{ @@ -1046,7 +1046,9 @@ export function UrlsTab() { resetColumn(ROW_NUM_KEY); }} onClick={(e) => e.stopPropagation()} - title="Drag to resize · double-click to reset" + title={t('urlsTab.resizeHint', { + defaultValue: 'Drag to resize · double-click to reset', + })} />
{ if (dragRef.current?.kind === 'row') applyRowDrag(vi.index); }} - title="Click to select row · drag to select multiple rows" + title={t('urlsTab.rowSelectHint', { + defaultValue: 'Click to select row · drag to select multiple rows', + })} > {vi.index + 1}
@@ -1236,7 +1240,7 @@ export function UrlsTab() { } }} > - +
); })} @@ -1263,7 +1267,9 @@ export function UrlsTab() {
{t('urlsTab.noUrlsToShow', { defaultValue: 'No URLs to show' })}
- Start a crawl or choose a different category. + {t('urlsTab.noUrlsHint', { + defaultValue: 'Start a crawl or choose a different category.', + })}
@@ -1275,28 +1281,31 @@ export function UrlsTab() { style={{ height: STATUS_BAR_HEIGHT }} > - URLs:{' '} + {t('urlsTab.statusUrls', { defaultValue: 'URLs:' })}{' '} {lazy.total.toLocaleString()} - ({lazy.loadedRows.toLocaleString()} loaded) + ({t('urlsTab.statusLoaded', { + defaultValue: '{{count}} loaded', + count: lazy.loadedRows.toLocaleString(), + })}) - Selected Rows:{' '} + {t('urlsTab.statusSelectedRows', { defaultValue: 'Selected Rows:' })}{' '} {selectedIds.size.toLocaleString()} - Selected Cells:{' '} + {t('urlsTab.statusSelectedCells', { defaultValue: 'Selected Cells:' })}{' '} {selectedCells.size.toLocaleString()} - Selected Columns:{' '} + {t('urlsTab.statusSelectedColumns', { defaultValue: 'Selected Columns:' })}{' '} {selectedColumns.size.toLocaleString()} @@ -1466,8 +1475,14 @@ function SortableHeaderCell(props: { onMouseDown={(e) => e.stopPropagation()} title={ isSortCol - ? `Sorted ${sortDir === 'asc' ? 'ascending' : 'descending'} — click to flip` - : 'Sort by this column' + ? t('urlsTab.columnSortedHint', { + defaultValue: 'Sorted {{dir}} — click to flip', + dir: + sortDir === 'asc' + ? t('urlsTab.sortAsc', { defaultValue: 'ascending' }) + : t('urlsTab.sortDesc', { defaultValue: 'descending' }), + }) + : t('urlsTab.sortByColumn', { defaultValue: 'Sort by this column' }) } > {isSortCol ? ( @@ -1489,7 +1504,9 @@ function SortableHeaderCell(props: { onResetColumn(id); }} onClick={(e) => e.stopPropagation()} - title="Drag to resize · double-click to reset" + title={t('urlsTab.resizeHint', { + defaultValue: 'Drag to resize · double-click to reset', + })} />
); @@ -1687,7 +1704,15 @@ function ColumnPickerPopover({ ); } -function Cell({ row, spec }: { row: CrawlUrlRow | null; spec: ColumnSpec }) { +function Cell({ + row, + spec, + lang, +}: { + row: CrawlUrlRow | null; + spec: ColumnSpec; + lang: string; +}) { if (row === null) { return …; } @@ -1723,7 +1748,7 @@ function Cell({ row, spec }: { row: CrawlUrlRow | null; spec: ColumnSpec }) { className={clsx('truncate', v === 'indexable' ? 'text-emerald-400' : 'text-amber-400')} title={v} > - {v === 'indexable' ? 'Indexable' : 'Non-Indexable'} + {translateLabel(v === 'indexable' ? 'Indexable' : 'Non-Indexable', lang)} ); } @@ -1733,9 +1758,10 @@ function Cell({ row, spec }: { row: CrawlUrlRow | null; spec: ColumnSpec }) { if (label === '') { return —; } + const localized = translateLabel(label, lang); return ( - - {label} + + {localized} ); } diff --git a/apps/desktop/src/renderer/src/tabs/VisualizationTab.tsx b/apps/desktop/src/renderer/src/tabs/VisualizationTab.tsx index a5b261c..76b1538 100644 --- a/apps/desktop/src/renderer/src/tabs/VisualizationTab.tsx +++ b/apps/desktop/src/renderer/src/tabs/VisualizationTab.tsx @@ -750,7 +750,7 @@ export function VisualizationTab() {
- Visualization + {t('viz.title', { defaultValue: 'Visualization' })}